Skip to content

Commit 1f79151

Browse files
committed
Merge pull request #11 from xiaolai/ada-tagging
feat(web/moderation): Ada-as-tagger + retire off_topic — POLICY_PROMPT_V=2
2 parents 8013282 + 27ca746 commit 1f79151

23 files changed

Lines changed: 1063 additions & 49 deletions

File tree

web/editorial/rubric.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ extensions:
273273
# rate than ClauDepot, and routing.feed_threshold acts as a per-persona bar.
274274
persona_overlays:
275275
ada:
276-
description: "Evidence-first skeptic. Asks 'where's the eval?'. Strongest on engineer / infra-shipper content. Also serves as the platform's AI policy moderator (the synchronous policy gate every submission and comment passes through before publish — see /office/policy)."
276+
description: "Evidence-first skeptic. Asks 'where's the eval?'. Strongest on engineer / infra-shipper content. Also serves as the platform's AI policy moderator (the synchronous policy gate every submission and comment passes through before publish — see /office/policy) and tags accepted submissions, picking up to two topical tags from the active vocabulary or proposing new tags for staff review at /admin/flags."
277277
multipliers:
278278
evidence_quality: 1.5
279279
mechanism_specificity: 1.2

web/src/app/(reader)/admin/flags/page.tsx

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { sql } from "drizzle-orm";
1+
import { eq, sql } from "drizzle-orm";
22

33
import { db } from "@/db/client";
4-
import { submissionTags, tags } from "@/db/schema";
4+
import { submissions, submissionTags, tags } from "@/db/schema";
55
import { CreateTagForm } from "@/components/prototype/admin/CreateTagForm";
6+
import { PendingTagRow } from "@/components/prototype/admin/PendingTagRow";
67
import { TagRow } from "@/components/prototype/admin/TagRow";
78
import { staffGate } from "@/lib/staff-gate";
89

10+
const PENDING_SAMPLE_LIMIT = 3;
11+
912
export default async function AdminFlags({
1013
searchParams,
1114
}: {
@@ -15,7 +18,9 @@ export default async function AdminFlags({
1518
const gate = await staffGate(sp);
1619
if (gate) return gate;
1720

18-
// Group counts in one round-trip instead of N+1 queries.
21+
// Group counts in one round-trip instead of N+1 queries. Filter
22+
// out pending_review=true rows — those render in the dedicated
23+
// section above with their own approve/reject actions.
1924
const rows = await db
2025
.select({
2126
slug: tags.slug,
@@ -28,8 +33,76 @@ export default async function AdminFlags({
2833
)`,
2934
})
3035
.from(tags)
36+
.where(eq(tags.pendingReview, false))
3137
.orderBy(tags.sortOrder);
3238

39+
// Migration 0022 — pending Ada-proposed tags awaiting staff review.
40+
// We pull post counts inline so staff sees how many submissions
41+
// would lose this tag if they reject it. Sample titles below.
42+
const pendingRows = await db
43+
.select({
44+
slug: tags.slug,
45+
name: tags.name,
46+
tagline: tags.tagline,
47+
postCount: sql<number>`(
48+
SELECT COUNT(*)::int FROM ${submissionTags}
49+
WHERE ${submissionTags.tagSlug} = ${tags.slug}
50+
)`,
51+
})
52+
.from(tags)
53+
.where(eq(tags.pendingReview, true))
54+
.orderBy(tags.slug);
55+
56+
// Fetch sample titles for every pending tag in ONE query using
57+
// ROW_NUMBER() OVER (PARTITION BY tag_slug ORDER BY created_at DESC).
58+
// The CTE picks the top N per tag, then we group in JS. Avoids
59+
// the previous N+1 (one query per pending tag) so the page stays
60+
// responsive even if a quiet vocabulary suddenly accumulates a
61+
// long pending list (e.g. after a model upgrade that changes
62+
// tagging behavior).
63+
const samplesByTag: Record<string, string[]> = {};
64+
const pendingSlugs = pendingRows.map((t) => t.slug);
65+
if (pendingSlugs.length > 0) {
66+
// sql.join builds the literal `($1, $2, ...)` IN clause with
67+
// proper parameter binding — passing an array directly to sql``
68+
// would interpolate as text and break.
69+
const slugList = sql.join(
70+
pendingSlugs.map((s) => sql`${s}`),
71+
sql`, `,
72+
);
73+
const sampleRows = await db.execute<{
74+
tag_slug: string;
75+
title: string;
76+
}>(sql`
77+
SELECT tag_slug, title FROM (
78+
SELECT
79+
${submissionTags.tagSlug} AS tag_slug,
80+
${submissions.title} AS title,
81+
ROW_NUMBER() OVER (
82+
PARTITION BY ${submissionTags.tagSlug}
83+
ORDER BY ${submissions.createdAt} DESC
84+
) AS rn
85+
FROM ${submissionTags}
86+
INNER JOIN ${submissions}
87+
ON ${submissions.id} = ${submissionTags.submissionId}
88+
WHERE ${submissionTags.tagSlug} IN (${slugList})
89+
) ranked
90+
WHERE rn <= ${PENDING_SAMPLE_LIMIT}
91+
ORDER BY tag_slug, rn
92+
`);
93+
// db.execute returns either { rows: [...] } (pg adapter) or
94+
// an array directly (neon-http) — handle both shapes.
95+
const rows: Array<{ tag_slug: string; title: string }> = Array.isArray(
96+
sampleRows,
97+
)
98+
? sampleRows
99+
: (sampleRows as { rows: Array<{ tag_slug: string; title: string }> })
100+
.rows;
101+
for (const row of rows) {
102+
(samplesByTag[row.tag_slug] ??= []).push(row.title);
103+
}
104+
}
105+
33106
return (
34107
<section>
35108
<h2>Tag vocabulary</h2>
@@ -44,6 +117,43 @@ export default async function AdminFlags({
44117
<code>moderation_log</code>.
45118
</p>
46119

120+
{pendingRows.length > 0 ? (
121+
<>
122+
<h3 className="proto-h3">Pending review ({pendingRows.length})</h3>
123+
<p className="proto-dek">
124+
Ada proposed these tags during moderation. Approve to add
125+
them to the public vocabulary, or reject to delete the tag
126+
and unlink it from any submissions that picked it up. The
127+
moderator's tag-vocab cache is cleared on approve so Ada
128+
picks up the change on the next submission.
129+
</p>
130+
<table className="proto-mod-table">
131+
<thead>
132+
<tr>
133+
<th>Slug</th>
134+
<th>Name</th>
135+
<th>Tagline</th>
136+
<th>Posts (samples)</th>
137+
<th>Action</th>
138+
</tr>
139+
</thead>
140+
<tbody>
141+
{pendingRows.map((t) => (
142+
<PendingTagRow
143+
key={t.slug}
144+
slug={t.slug}
145+
name={t.name}
146+
tagline={t.tagline}
147+
postCount={t.postCount}
148+
sampleTitles={samplesByTag[t.slug] ?? []}
149+
/>
150+
))}
151+
</tbody>
152+
</table>
153+
</>
154+
) : null}
155+
156+
<h3 className="proto-h3">Active vocabulary</h3>
47157
<table className="proto-mod-table">
48158
<thead>
49159
<tr>

web/src/app/(reader)/office/policy/page.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,17 @@ export const dynamic = "force-dynamic";
2525
*/
2626

2727
const CATEGORY_NOTES: Record<string, string> = {
28-
spam: "Off-topic promotion, link farms, repetitive postings, paid promotion without disclosure.",
28+
spam: "Promotional content with no surrounding discussion, link farms, repetitive postings, paid promotion without disclosure.",
2929
abuse: "Harassment, slurs, threats, targeted personal attacks against an identified person or group.",
3030
illegal:
3131
"CSAM; distributing malware or stolen credentials; flagrant copyright violation. Discussion of these is allowed; distribution is not.",
3232
doxxing:
3333
"Exposing a private individual's home address, phone number, government ID, or non-public personal email tied to a real-name target.",
34-
off_topic:
35-
"Submissions only — clearly unrelated to AI tools / AI-augmented work / LLM technique. Comments are not rejected for off-topic.",
34+
// Legacy category — retired in POLICY_PROMPT_V="2". Kept here so
35+
// historical policy_decisions rows with category='off_topic'
36+
// render with a recognizable label on this page if they surface
37+
// in the aggregate. New rejects never use this category.
38+
off_topic: "[legacy] Submissions retired from this category in v2.",
3639
};
3740

3841
export default async function OfficePolicyPage() {
@@ -96,12 +99,14 @@ export default async function OfficePolicyPage() {
9699
</header>
97100

98101
<section className="proto-section">
99-
<h2>The five categories</h2>
102+
<h2>Categories</h2>
100103
<p className="office-section-lede">
101-
Reject only when content clearly fits one. When in doubt,
102-
pass. The rubric is intentionally small — five categories,
103-
not fifty — because every additional category trades
104-
precision for argued-edges.
104+
Universal trust-and-safety taxonomy. Reject only when
105+
content clearly fits one. When in doubt, pass. Topical
106+
fit (does this submission match the platform&rsquo;s
107+
audience) is NOT a moderator concern — that&rsquo;s
108+
handled by editorial scoring and community voting, not
109+
by the gate.
105110
</p>
106111
<dl className="office-policy-categories">
107112
{POLICY_CATEGORIES.map((cat) => (
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"use client";
2+
3+
import { useActionState } from "react";
4+
import {
5+
approvePendingTag,
6+
rejectPendingTag,
7+
type TagActionState,
8+
} from "@/lib/actions/admin-tag";
9+
10+
const INIT: TagActionState = { ok: true, message: "" };
11+
12+
/**
13+
* One row of the /admin/flags pending-review table. Lists an
14+
* Ada-proposed tag with sample submissions linking it.
15+
*
16+
* Approve flips pending_review=false and (optionally) edits the
17+
* placeholder name. Reject deletes the tag (cascading
18+
* submission_tags rows) — staff has decided it shouldn't enter the
19+
* vocabulary.
20+
*
21+
* Two independent action states so each form's flash message is
22+
* scoped to its own submit button.
23+
*/
24+
export function PendingTagRow({
25+
slug,
26+
name,
27+
tagline,
28+
postCount,
29+
sampleTitles,
30+
}: {
31+
slug: string;
32+
name: string;
33+
tagline: string | null;
34+
postCount: number;
35+
sampleTitles: string[];
36+
}) {
37+
const [approveState, approveAction, approvePending] = useActionState(
38+
approvePendingTag,
39+
INIT,
40+
);
41+
const [rejectState, rejectAction, rejectPending] = useActionState(
42+
rejectPendingTag,
43+
INIT,
44+
);
45+
46+
const approveFormId = `approve-${slug}`;
47+
48+
return (
49+
<tr>
50+
<td>
51+
<code>{slug}</code>
52+
</td>
53+
<td>
54+
<form
55+
id={approveFormId}
56+
action={approveAction}
57+
className="proto-tag-edit"
58+
>
59+
<input type="hidden" name="slug" value={slug} />
60+
<input
61+
type="text"
62+
name="name"
63+
defaultValue={name}
64+
className="proto-input proto-input-inline"
65+
aria-label={`Display name for ${slug}`}
66+
required
67+
/>
68+
</form>
69+
{approveState.message ? (
70+
<p
71+
className={`proto-form-flash ${approveState.ok ? "proto-form-flash-ok" : "proto-form-flash-err"}`}
72+
>
73+
{approveState.message}
74+
</p>
75+
) : null}
76+
</td>
77+
<td>
78+
<input
79+
type="text"
80+
name="tagline"
81+
form={approveFormId}
82+
defaultValue={tagline ?? ""}
83+
className="proto-input proto-input-inline proto-input-wide"
84+
aria-label={`Tagline for ${slug}`}
85+
placeholder="Optional one-line description"
86+
/>
87+
</td>
88+
<td>
89+
{postCount}
90+
{sampleTitles.length > 0 ? (
91+
<ul className="proto-pending-samples">
92+
{sampleTitles.map((title) => (
93+
<li key={title} title={title}>
94+
{title}
95+
</li>
96+
))}
97+
</ul>
98+
) : null}
99+
</td>
100+
<td className="proto-mod-actions">
101+
<button
102+
type="submit"
103+
form={approveFormId}
104+
className="proto-mod-btn proto-mod-btn-keep"
105+
disabled={approvePending}
106+
>
107+
{approvePending ? "Approving…" : "Approve"}
108+
</button>
109+
<form action={rejectAction}>
110+
<input type="hidden" name="slug" value={slug} />
111+
<button
112+
type="submit"
113+
className="proto-mod-btn proto-mod-btn-remove"
114+
disabled={rejectPending}
115+
>
116+
{rejectPending ? "Rejecting…" : "Reject"}
117+
</button>
118+
</form>
119+
{rejectState.message ? (
120+
<p
121+
className={`proto-form-flash ${rejectState.ok ? "proto-form-flash-ok" : "proto-form-flash-err"}`}
122+
>
123+
{rejectState.message}
124+
</p>
125+
) : null}
126+
</td>
127+
</tr>
128+
);
129+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
-- 0022_ai_tagging — Ada gains a second job: tag every accepted
2+
-- submission with two tags during the same moderate() call.
3+
--
4+
-- Two columns added, both additive + idempotent:
5+
--
6+
-- 1. tags.pending_review — boolean, default false. When Ada
7+
-- proposes a tag that doesn't exist in the vocabulary yet, the
8+
-- new row goes in with pending_review=true and stays hidden
9+
-- from the public /c catalog until staff approves it at
10+
-- /admin/tags. Staff approval flips the flag to false; the tag
11+
-- enters the live vocabulary.
12+
--
13+
-- 2. submission_tags.source — text, default 'user', constrained
14+
-- to {'ai','user'}. Distinguishes Ada-applied tags from
15+
-- user-applied tags (the latter come from the submit form's
16+
-- tags field). Used for analytics ("did AI tags drive search
17+
-- engagement?"), audit, and override logic (user-supplied
18+
-- tags win on duplicates).
19+
--
20+
-- The existing 5-tag-per-submission cap (enforced in the input
21+
-- schema) still holds: user can supply up to 5; Ada adds up to 2;
22+
-- duplicates dedupe; the union is capped at 5 by the create path.
23+
24+
ALTER TABLE "tags"
25+
ADD COLUMN IF NOT EXISTS "pending_review" boolean NOT NULL DEFAULT false;
26+
--> statement-breakpoint
27+
28+
ALTER TABLE "submission_tags"
29+
ADD COLUMN IF NOT EXISTS "source" text NOT NULL DEFAULT 'user';
30+
--> statement-breakpoint
31+
32+
-- Postgres lacks `ADD CONSTRAINT IF NOT EXISTS`, so guard the add
33+
-- via pg_catalog so a re-run (e.g. running this file by hand on a
34+
-- DB where it already applied) doesn't error. The catalog query
35+
-- looks for the constraint by exact name on the target table.
36+
DO $$
37+
BEGIN
38+
IF NOT EXISTS (
39+
SELECT 1 FROM pg_constraint
40+
WHERE conname = 'submission_tags_source_check'
41+
AND conrelid = 'submission_tags'::regclass
42+
) THEN
43+
ALTER TABLE "submission_tags"
44+
ADD CONSTRAINT "submission_tags_source_check"
45+
CHECK ("source" IN ('ai', 'user'));
46+
END IF;
47+
END$$;
48+
--> statement-breakpoint
49+
50+
-- Index supports the /admin/tags review page (pending tags first,
51+
-- then ordered by when Ada proposed them so newest goes to top).
52+
-- No created_at column on tags today — adding one is a separate
53+
-- slice; use slug as a tie-breaker for now.
54+
CREATE INDEX IF NOT EXISTS "idx_tags_pending_review"
55+
ON "tags" ("pending_review", "slug")
56+
WHERE "pending_review" = true;

web/src/db/migrations/meta/_journal.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
{ "idx": 18, "version": "7", "when": 1778169999000, "tag": "0018_policy_moderation", "breakpoints": true },
2424
{ "idx": 19, "version": "7", "when": 1778199999000, "tag": "0019_policy_moderation_followups","breakpoints": true },
2525
{ "idx": 20, "version": "7", "when": 1778210000000, "tag": "0020_moderation_retro_queue", "breakpoints": true },
26-
{ "idx": 21, "version": "7", "when": 1778220000000, "tag": "0021_moderation_prompts", "breakpoints": true }
26+
{ "idx": 21, "version": "7", "when": 1778220000000, "tag": "0021_moderation_prompts", "breakpoints": true },
27+
{ "idx": 22, "version": "7", "when": 1778316000000, "tag": "0022_ai_tagging", "breakpoints": true }
2728
]
2829
}

0 commit comments

Comments
 (0)