380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
/**
|
||
* generate-flagged-report.ts
|
||
*
|
||
* Reads all flagged content from the database and generates a pretty
|
||
* markdown report organized by content type. The report includes:
|
||
* - Summary table with counts per content type
|
||
* - Plant images flagged for review
|
||
* - Disease images flagged for review
|
||
* - Disease symptoms flagged for review
|
||
* - Disease causes flagged for review
|
||
* - Disease treatment steps flagged for review
|
||
* - Disease prevention tips flagged for review
|
||
*
|
||
* Usage:
|
||
* npx tsx scripts/generate-flagged-report.ts [--min-flags N] [--output path/to/report.md]
|
||
*
|
||
* Options:
|
||
* --min-flags Minimum flag count to include (default: 1)
|
||
* --output Output path (default: scripts/.flagged-content-review-needed.md)
|
||
*/
|
||
|
||
import dotenv from "dotenv";
|
||
import path from "node:path";
|
||
|
||
// Load DB config from .env.development (or .env.production if NODE_ENV=production)
|
||
const envFile =
|
||
process.env.NODE_ENV === "production" ? "../.env.production" : "../.env.development";
|
||
dotenv.config({ path: path.resolve(__dirname, envFile) });
|
||
import { createClient } from "@libsql/client";
|
||
import fs from "node:fs";
|
||
|
||
// ─── Config ─────────────────────────────────────────────────────────────────
|
||
|
||
const MIN_FLAGS = parseInt(
|
||
process.argv.find((a) => a.startsWith("--min-flags="))?.split("=")[1] ?? "1",
|
||
10,
|
||
);
|
||
const OUTPUT_PATH =
|
||
process.argv.find((a) => a.startsWith("--output="))?.split("=")[1] ??
|
||
path.join(__dirname, ".flagged-content-review-needed.md");
|
||
|
||
// ─── DB Connection ──────────────────────────────────────────────────────────
|
||
|
||
const db = createClient({
|
||
url: process.env.DATABASE_URL!,
|
||
authToken: process.env.DATABASE_TOKEN!,
|
||
});
|
||
|
||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||
|
||
interface FlaggedRow {
|
||
id: string;
|
||
content_type: string;
|
||
content_id: string;
|
||
field_name: string;
|
||
notes: string;
|
||
flag_count: number;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
interface PlantRow {
|
||
id: string;
|
||
common_name: string;
|
||
scientific_name: string;
|
||
family: string;
|
||
image_url: string;
|
||
}
|
||
|
||
interface DiseaseRow {
|
||
id: string;
|
||
name: string;
|
||
scientific_name: string;
|
||
plant_id: string;
|
||
image_url: string;
|
||
}
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
const CONTENT_TYPE_LABELS: Record<string, { emoji: string; title: string; description: string }> = {
|
||
plant_image: {
|
||
emoji: "🪴",
|
||
title: "Plant Images Flagged for Review",
|
||
description: "Plant images that users have flagged as potentially incorrect or low quality.",
|
||
},
|
||
disease_image: {
|
||
emoji: "📸",
|
||
title: "Disease Images Flagged for Review",
|
||
description:
|
||
"Disease symptom images that users have flagged as potentially incorrect or misleading.",
|
||
},
|
||
disease_symptoms: {
|
||
emoji: "⚠️",
|
||
title: "Disease Symptoms Flagged for Review",
|
||
description: "Symptom descriptions that users have flagged as potentially inaccurate.",
|
||
},
|
||
disease_causes: {
|
||
emoji: "🔍",
|
||
title: "Disease Causes Flagged for Review",
|
||
description:
|
||
"Causes and contributing factors that users have flagged as potentially incorrect.",
|
||
},
|
||
disease_treatment: {
|
||
emoji: "💊",
|
||
title: "Disease Treatment Steps Flagged for Review",
|
||
description:
|
||
"Treatment instructions that users have flagged as potentially incorrect or harmful.",
|
||
},
|
||
disease_prevention: {
|
||
emoji: "🛡️",
|
||
title: "Disease Prevention Tips Flagged for Review",
|
||
description: "Prevention tips that users have flagged as potentially incorrect or misleading.",
|
||
},
|
||
};
|
||
|
||
function formatDate(iso: string): string {
|
||
const d = new Date(iso);
|
||
return d.toLocaleDateString("en-US", {
|
||
year: "numeric",
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||
|
||
async function main() {
|
||
console.log(`📋 Generating flagged content report (min flags: ${MIN_FLAGS})...`);
|
||
|
||
// Fetch flagged content
|
||
const flaggedRs = await db.execute({
|
||
sql: "SELECT * FROM flagged_content WHERE flag_count >= ? ORDER BY content_type, flag_count DESC, updated_at DESC",
|
||
args: [MIN_FLAGS],
|
||
});
|
||
const flaggedRows = flaggedRs.rows as unknown as FlaggedRow[];
|
||
|
||
if (flaggedRows.length === 0) {
|
||
const report = [
|
||
"# 🚩 Flagged Content Review — Nothing to Review",
|
||
"",
|
||
`Generated: ${new Date().toISOString()}`,
|
||
"",
|
||
"**No content has been flagged for review yet.**",
|
||
"",
|
||
"Flagged items will appear here once users flag content for manual review.",
|
||
"",
|
||
"---",
|
||
"",
|
||
`_Report generated with min-flags=${MIN_FLAGS}_`,
|
||
"",
|
||
].join("\n");
|
||
|
||
fs.writeFileSync(OUTPUT_PATH, report, "utf-8");
|
||
console.log(`✅ Report written to ${OUTPUT_PATH} (no flagged items)`);
|
||
db.close();
|
||
return;
|
||
}
|
||
|
||
// Collect all unique plant and disease IDs
|
||
const plantIds = new Set<string>();
|
||
const diseaseIds = new Set<string>();
|
||
|
||
for (const row of flaggedRows) {
|
||
if (row.content_type === "plant_image") {
|
||
plantIds.add(row.content_id);
|
||
} else {
|
||
diseaseIds.add(row.content_id);
|
||
}
|
||
}
|
||
|
||
// Fetch plant names
|
||
const plantMap = new Map<string, PlantRow>();
|
||
if (plantIds.size > 0) {
|
||
const plantRs = await db.execute({
|
||
sql: `SELECT id, common_name, scientific_name, family, image_url FROM plants WHERE id IN (${[...plantIds].map(() => "?").join(",")})`,
|
||
args: [...plantIds],
|
||
});
|
||
for (const row of plantRs.rows as unknown as PlantRow[]) {
|
||
plantMap.set(row.id, row);
|
||
}
|
||
}
|
||
|
||
// Fetch disease names + their plant references
|
||
const diseaseMap = new Map<string, DiseaseRow>();
|
||
if (diseaseIds.size > 0) {
|
||
const diseaseRs = await db.execute({
|
||
sql: `SELECT id, name, scientific_name, plant_id, image_url FROM diseases WHERE id IN (${[...diseaseIds].map(() => "?").join(",")})`,
|
||
args: [...diseaseIds],
|
||
});
|
||
for (const row of diseaseRs.rows as unknown as DiseaseRow[]) {
|
||
diseaseMap.set(row.id, row);
|
||
if (!plantMap.has(row.plant_id)) {
|
||
plantIds.add(row.plant_id);
|
||
}
|
||
}
|
||
// Fetch any missing plant references for diseases
|
||
if (plantIds.size > 0) {
|
||
const missingPlantIds = [...plantIds].filter((id) => !plantMap.has(id));
|
||
if (missingPlantIds.length > 0) {
|
||
const plantRs = await db.execute({
|
||
sql: `SELECT id, common_name, scientific_name, family, image_url FROM plants WHERE id IN (${missingPlantIds.map(() => "?").join(",")})`,
|
||
args: missingPlantIds,
|
||
});
|
||
for (const row of plantRs.rows as unknown as PlantRow[]) {
|
||
plantMap.set(row.id, row);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Group by content type
|
||
const groups: Record<string, FlaggedRow[]> = {};
|
||
for (const row of flaggedRows) {
|
||
if (!groups[row.content_type]) groups[row.content_type] = [];
|
||
groups[row.content_type].push(row);
|
||
}
|
||
|
||
// ─── Build Report ────────────────────────────────────────────────────────
|
||
|
||
const lines: string[] = [];
|
||
const totalFlags = flaggedRows.reduce((sum, r) => sum + r.flag_count, 0);
|
||
|
||
lines.push("# 🚩 Flagged Content — Manual Review Needed");
|
||
lines.push("");
|
||
lines.push(`Generated: ${new Date().toISOString()}`);
|
||
lines.push("");
|
||
lines.push(
|
||
flaggedRows.length === 1
|
||
? `**${flaggedRows.length} item** flagged for review (${totalFlags} total flags).`
|
||
: `**${flaggedRows.length} items** flagged for review (${totalFlags} total flags).`,
|
||
);
|
||
lines.push("");
|
||
lines.push("Most data in this knowledge base is not reviewed by humans. ");
|
||
lines.push("Items listed below have been flagged by users for manual review. ");
|
||
lines.push("Please review each item and take appropriate action.");
|
||
lines.push("");
|
||
|
||
// Summary table
|
||
lines.push("## 📊 Summary");
|
||
lines.push("");
|
||
lines.push("| Content Type | Count | Total Flags |");
|
||
lines.push("|---|---|---|");
|
||
const orderedTypes = [
|
||
"plant_image",
|
||
"disease_image",
|
||
"disease_symptoms",
|
||
"disease_causes",
|
||
"disease_treatment",
|
||
"disease_prevention",
|
||
];
|
||
for (const type of orderedTypes) {
|
||
const items = groups[type];
|
||
if (!items) continue;
|
||
const label = CONTENT_TYPE_LABELS[type]?.title ?? type;
|
||
const count = items.length;
|
||
const sumFlags = items.reduce((s, r) => s + r.flag_count, 0);
|
||
lines.push(`| ${label} | ${count} | ${sumFlags} |`);
|
||
}
|
||
lines.push(`| **Total** | **${flaggedRows.length}** | **${totalFlags}** |`);
|
||
lines.push("");
|
||
lines.push("---");
|
||
lines.push("");
|
||
|
||
// Detail sections per content type
|
||
for (const type of orderedTypes) {
|
||
const items = groups[type];
|
||
if (!items) continue;
|
||
|
||
const config = CONTENT_TYPE_LABELS[type];
|
||
lines.push(`## ${config?.emoji ?? "📋"} ${config?.title ?? type}`);
|
||
lines.push("");
|
||
lines.push(config?.description ?? "");
|
||
lines.push("");
|
||
lines.push(`**${items.length} item${items.length === 1 ? "" : "s"} flagged**`);
|
||
lines.push("");
|
||
|
||
for (const item of items) {
|
||
// Build label
|
||
let label = item.content_id;
|
||
let plantLabel = "";
|
||
|
||
if (type === "plant_image") {
|
||
const plant = plantMap.get(item.content_id);
|
||
if (plant) {
|
||
label = `${plant.common_name} (_${plant.scientific_name}_)`;
|
||
plantLabel = `${plant.family} family`;
|
||
}
|
||
} else {
|
||
const disease = diseaseMap.get(item.content_id);
|
||
if (disease) {
|
||
const plant = plantMap.get(disease.plant_id);
|
||
const plantName = plant?.common_name ?? disease.plant_id;
|
||
label = `${disease.name} (_${disease.scientific_name}_) on **${plantName}**`;
|
||
plantLabel = `Affects: ${plantName}`;
|
||
}
|
||
}
|
||
|
||
const flagWord = item.flag_count === 1 ? "flag" : "flags";
|
||
const firstFlagged = formatDate(item.created_at);
|
||
const lastFlagged = formatDate(item.updated_at);
|
||
|
||
lines.push(`### ${label}`);
|
||
lines.push("");
|
||
lines.push(`- **Field:** \`${item.field_name}\``);
|
||
lines.push(`- **Flags:** ${item.flag_count} ${flagWord}`);
|
||
lines.push(`- **First flagged:** ${firstFlagged}`);
|
||
lines.push(`- **Last flagged:** ${lastFlagged}`);
|
||
if (plantLabel) {
|
||
lines.push(`- **${plantLabel}**`);
|
||
}
|
||
if (item.notes) {
|
||
lines.push(`- **User notes:** ${item.notes}`);
|
||
}
|
||
|
||
// Show the content data if we can fetch it
|
||
if (type === "plant_image") {
|
||
const plant = plantMap.get(item.content_id);
|
||
if (plant?.image_url) {
|
||
lines.push("");
|
||
lines.push(` `);
|
||
}
|
||
} else {
|
||
const disease = diseaseMap.get(item.content_id);
|
||
if (type === "disease_image" && disease?.image_url) {
|
||
lines.push("");
|
||
lines.push(` `);
|
||
}
|
||
}
|
||
|
||
lines.push("");
|
||
}
|
||
|
||
lines.push("---");
|
||
lines.push("");
|
||
}
|
||
|
||
// Footer
|
||
lines.push("## ℹ️ How This Works");
|
||
lines.push("");
|
||
lines.push("1. **Users** click the 🚩 Flag button on any content they believe needs review.");
|
||
lines.push("2. **The system** stores the flag in the database with a counter.");
|
||
lines.push(
|
||
"3. **This report** is generated by querying the database and formatting the results.",
|
||
);
|
||
lines.push("4. **Reviewers** go through each item and take action (fix, update, or dismiss).");
|
||
lines.push("");
|
||
lines.push("### Taking Action");
|
||
lines.push("");
|
||
lines.push("After reviewing an item, you can clear its flags by running:");
|
||
lines.push("");
|
||
lines.push("```sql");
|
||
lines.push("DELETE FROM flagged_content WHERE id = '<item-id>';");
|
||
lines.push("```");
|
||
lines.push("");
|
||
lines.push("Or clear all flags for a specific item by running:");
|
||
lines.push("");
|
||
lines.push("```sql");
|
||
lines.push(
|
||
"UPDATE flagged_content SET flag_count = 0 WHERE content_id = '<id>' AND field_name = '<field>';",
|
||
);
|
||
lines.push("```");
|
||
lines.push("");
|
||
lines.push("---");
|
||
lines.push("");
|
||
lines.push(`_Report generated with min-flags=${MIN_FLAGS}_`);
|
||
|
||
// Write report
|
||
fs.writeFileSync(OUTPUT_PATH, lines.join("\n"), "utf-8");
|
||
console.log(`✅ Report written to ${OUTPUT_PATH}`);
|
||
console.log(` ${flaggedRows.length} items, ${totalFlags} total flags`);
|
||
db.close();
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error("❌ Failed to generate report:", err);
|
||
process.exit(1);
|
||
});
|