Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export interface DeckeImprovementLeg { id: string; leg: number; asked: string |
export interface DeckeImprovementEvent { ordinal: number; batch: number; batchOrdinal: number; legId: string | null; kind: string; payload: unknown }
export interface DeckeImprovementTurn { seq: number; asked: string; answered: string; tools: unknown; feedback: number | null; feedbackComment: string | null; offsetSeconds: number; tokens: DeckeImprovementTokens; costUsd: number | null; costCoverage: DeckeImprovementCoverage; buildSha: string | null; buildPr: number | null; finishReason: string | null; hasError: boolean; legs: DeckeImprovementLeg[]; events: DeckeImprovementEvent[] }
export interface DeckeImprovementDetail { conversation: DeckeImprovementConversation; turns: DeckeImprovementTurn[] }
/** A knowledge note as a reviewer sees it (`GET /admin/knowledge`). Every text field is the proposer's, not DeckPal's. */
export type KnowledgeNoteStatus = 'pending' | 'approved' | 'rejected'
export interface KnowledgeNote { id: number; cards: string[]; statement: string; sourceUrl: string; quote: string; status: KnowledgeNoteStatus; proposedAt: string; flags: string[]; reviewedAt: string | null; reviewReason: string | null }
export interface KnowledgeQueue { notes: KnowledgeNote[]; next: number | null }

const BASE = isCloudMode ? '/api' : '/deckpal/api'

Expand Down Expand Up @@ -2296,6 +2300,10 @@ export const api = {
/** Shared Deck-E chats (pseudonymised), using the SQL reader's bucketed shape. */
adminDeckeImprovementList: (params: string, signal?: AbortSignal) => get<DeckeImprovementList>('/admin/decke-improvement?' + params, signal),
adminDeckeImprovement: (id: string, signal?: AbortSignal) => get<DeckeImprovementDetail>('/admin/decke-improvement/' + encodeURIComponent(id), signal),
/** Knowledge notes waiting for (or past) review; needs knowledge.review in a browser session. */
adminKnowledgeNotes: (params: string, signal?: AbortSignal) => get<KnowledgeQueue>('/admin/knowledge?' + params, signal),
adminReviewKnowledgeNote: (id: number, body: { decision: 'approved' | 'rejected'; reason: string }) =>
send<{ note: KnowledgeNote }>('POST', `/admin/knowledge/${id}/review`, body),
adminAiConversation: (id: string, params: string, signal?: AbortSignal) => get<{ items: AiUsageDetail[]; total: number; limit: number; offset: number }>('/admin/ai-usage/conversations/' + encodeURIComponent(id) + '?' + params, signal),
meFeatures: (signal?: AbortSignal) => get<{ features: FeatureAccess[] }>('/me/features', signal),
setMeFeature: (key: string, optedIn: boolean, expectedRevision: number) => send<{ features: FeatureAccess[] }>('PATCH', '/me/features/' + encodeURIComponent(key), { optedIn, expectedRevision }),
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ const AdminAudit = lazyRoute('./routes/admin/Audit', () => import('./routes/admi
const AdminFeatures = lazyRoute('./routes/admin/Features', () => import('./routes/admin/Features'), 'AdminFeatures')
const AdminAiUsage = lazyRoute('./routes/admin/AiUsage', () => import('./routes/admin/AiUsage'), 'AdminAiUsage')
const AdminDeckeChats = lazyRoute('./routes/admin/DeckeChats', () => import('./routes/admin/DeckeChats'), 'AdminDeckeChats')
const AdminKnowledge = lazyRoute('./routes/admin/Knowledge', () => import('./routes/admin/Knowledge'), 'KnowledgeReview')
const DevKnowledge = lazyRoute('./routes/admin/Knowledge', () => import('./routes/admin/Knowledge'), 'DevKnowledgeReview')

// The two things `RootComponent` mounts on every page and almost nobody sees:
// Deck-E's host (two accounts) and the support prompt (signed in, cloud only).
Expand Down Expand Up @@ -257,6 +259,7 @@ const TOOL_PERMISSIONS: Record<string, string> = {
'/devtools': 'devtools.access', '/scan': 'scanner.use', '/design': 'design.view', '/dev/decke': 'diagnostics.view',
'/dev/chat-ui': 'diagnostics.view', '/dev/decke-compare': 'diagnostics.view',
'/dev/scan-harness': 'diagnostics.view', '/dev/quad-labeler': 'scanner.label', '/dev/quad-harvest': 'scanner.label',
'/dev/knowledge': 'knowledge.review',
}
function SecureOutlet() {
const pathname = useRouterState({ select: s => s.location.pathname })
Expand Down Expand Up @@ -840,6 +843,9 @@ const adminRoute = createRoute({ getParentRoute: () => rootRoute, path: '/admin'
const adminFeaturesRoute = createRoute({ getParentRoute: () => adminRoute, path: '/features', component: AdminFeatures })
const adminUsageRoute = createRoute({ getParentRoute: () => adminRoute, path: '/usage', component: AdminAiUsage })
const adminDeckeChatsRoute = createRoute({ getParentRoute: () => adminRoute, path: '/decke-chats', beforeLoad: () => requireCapability('decke.improvement.read'), component: AdminDeckeChats })
const adminKnowledgeRoute = createRoute({ getParentRoute: () => adminRoute, path: '/knowledge', beforeLoad: () => requireCapability('knowledge.review'), component: AdminKnowledge })
// The same review screen for a contributor given knowledge.review: /admin needs admin.access, which a contributor never holds.
const devKnowledgeRoute = createRoute({ getParentRoute: () => rootRoute, path: '/dev/knowledge', beforeLoad: () => requireCapability('knowledge.review'), component: DevKnowledge })
const adminIndexRoute = createRoute({ getParentRoute: () => adminRoute, path: '/', component: AdminOverview })
const adminUsersRoute = createRoute({ getParentRoute: () => adminRoute, path: '/users', beforeLoad: () => requireCapability('users.read'), component: AdminUsers })
const adminUserRoute = createRoute({ getParentRoute: () => adminRoute, path: '/users/$userId', beforeLoad: () => requireCapability('users.read'), component: AdminUserDetail })
Expand All @@ -857,8 +863,8 @@ const creditsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/cred
}), component: Credits })

const routeTree = rootRoute.addChildren([
devtoolsRoute, adminToolsRoute,
adminRoute.addChildren([adminFeaturesRoute, adminUsageRoute, adminDeckeChatsRoute, adminIndexRoute, adminUsersRoute, adminUserRoute, adminRolesRoute, adminSettingsRoute, adminAuditRoute]),
devtoolsRoute, adminToolsRoute, devKnowledgeRoute,
adminRoute.addChildren([adminFeaturesRoute, adminUsageRoute, adminDeckeChatsRoute, adminKnowledgeRoute, adminIndexRoute, adminUsersRoute, adminUserRoute, adminRolesRoute, adminSettingsRoute, adminAuditRoute]),
creditsRoute,
...coreRoutes,
designRoute,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/routes/Devtools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const DEV_TOOLS = [
{ to: '/dev/scan-harness', name: 'Scanner harness', permission: 'diagnostics.view', description: 'Exercise detector fixtures and camera diagnostics.' },
{ to: '/dev/quad-labeler', name: 'Quad labeler', permission: 'scanner.label', description: 'Capture and annotate training photos in the shared photo corpus.' },
{ to: '/dev/quad-harvest', name: 'Training photo review', permission: 'scanner.label', description: 'Inspect captured training photos and labels.' },
{ to: '/dev/knowledge', name: 'Knowledge notes', permission: 'knowledge.review', description: 'Approve or reject the rules notes agents propose before every account sees them.' },
]
export function Devtools() {
const access = useAccess()
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/routes/admin/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const ADMIN_SECTIONS = [
{ key: 'audit', label: 'Audit', to: '/admin/audit', permission: 'audit.read' },
{ key: 'usage', label: 'AI usage', to: '/admin/usage', permission: 'admin.access' },
{ key: 'decke-chats', label: 'Deck-E chats', to: '/admin/decke-chats', permission: 'decke.improvement.read' },
{ key: 'knowledge', label: 'Knowledge notes', to: '/admin/knowledge', permission: 'knowledge.review' },
{ key: 'features', label: 'Features', to: '/admin/features', permission: 'roles.manage' },
]
export default function Admin() {
Expand Down
81 changes: 81 additions & 0 deletions apps/web/src/routes/admin/Knowledge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api, type KnowledgeNote, type KnowledgeNoteStatus } from '../../lib/api'
import { useAccess } from '../../lib/access'
import { Button, Content, DataTable, EmptyState, Tabs } from '../../components/ui'
import { ConfirmAction, fmtDate } from './shared'

/**
* Knowledge notes review (Phase 6): what agents proposed about how cards interact, approved or rejected here before
* every account sees it in get_card and rules_lookup.
*
* Gated on knowledge.review alone, never admin.access: a contributor role can be given review, and a contributor
* never holds admin.access. So this component does not use useAdminQuery (which requires admin.access), and it is
* mounted twice: as an Administration tab for admins, and at /dev/knowledge for contributors.
*
* Every text field is the proposer's. React escapes it; the source link opens in a new tab with no referrer and is
* shown in full, so the reviewer sees where it goes before following it.
*/
const STATUSES: { key: KnowledgeNoteStatus; label: string }[] = [
{ key: 'pending', label: 'Waiting' },
{ key: 'approved', label: 'Approved' },
{ key: 'rejected', label: 'Rejected' },
]

function NoteBody({ note }: { note: KnowledgeNote }) {
return <div className="min-w-0 space-y-[10px]">
<p className="text-[13px] font-semibold text-text-secondary">{note.cards.join(' · ')}</p>
<p className="whitespace-pre-wrap break-words text-text-body">{note.statement}</p>
<blockquote className="border-l-[3px] border-border-default pl-[12px] text-[14px] italic text-text-muted break-words">“{note.quote}”</blockquote>
<a href={note.sourceUrl} target="_blank" rel="noopener noreferrer nofollow" className="block break-all text-[13px] text-link">{note.sourceUrl}</a>
{note.flags.length > 0 && <ul aria-label="Automatic checks" className="flex flex-wrap gap-[6px]">{note.flags.map(flag =>
<li key={flag} className="rounded-full bg-warning/15 px-[10px] py-[3px] text-[12px] font-semibold text-warning">{flag}</li>)}</ul>}
{note.reviewReason && <p className="text-[13px] text-text-muted">Review note: {note.reviewReason}</p>}
</div>
}

export function KnowledgeReview() {
const access = useAccess()
const [status, setStatus] = useState<KnowledgeNoteStatus>('pending')
const [before, setBefore] = useState<number | null>(null)
const [deciding, setDeciding] = useState<{ note: KnowledgeNote; decision: 'approved' | 'rejected' } | null>(null)
const allowed = access.ready && access.permissions.includes('knowledge.review')
const params = new URLSearchParams({ status, limit: '50', ...(before ? { before: String(before) } : {}) }).toString()
// The key starts with 'admin' so ConfirmAction's save refreshes it after a decision.
const query = useQuery({ queryKey: ['admin', access.identity, access.revision, 'knowledge', params], queryFn: ({ signal }) => api.adminKnowledgeNotes(params, signal), enabled: allowed, retry: false, staleTime: 0 })
if (!allowed) return <EmptyState icon="lock" title="Note review unavailable" body="Reviewing knowledge notes needs the knowledge.review permission." />
return <section className="min-w-0 space-y-[20px]">
<h2 className="font-display text-[24px] text-text-primary">Knowledge notes</h2>
<p className="max-w-[760px] text-text-muted">Findings agents proposed about how cards interact. An approved note appears for every account in card details and rules answers, under the note’s source. Check the quote against the source before approving; reject anything wrong, unsourced or personal.</p>
<Tabs variant="pill" size="sm" value={status} onChange={key => { setStatus(key as KnowledgeNoteStatus); setBefore(null) }} items={STATUSES} />
<DataTable label="Knowledge notes" rows={query.data?.notes ?? []} getRowId={note => String(note.id)} getRowLabel={note => note.cards.join(', ')}
loading={query.isPending} refreshing={query.isFetching && !query.isPending} error={query.error?.message} onRetry={() => void query.refetch()}
empty={<EmptyState icon="lists" title={status === 'pending' ? 'Nothing waiting' : 'No notes here yet'} body={status === 'pending' ? 'New proposals appear here.' : 'Decided notes appear here.'} />}
columns={[
{ id: 'note', header: 'Note', className: 'min-w-[320px] max-w-[640px]', cell: note => <NoteBody note={note} /> },
{ id: 'proposed', header: 'Proposed', className: 'min-w-[150px]', cell: note => <time dateTime={note.proposedAt}>{fmtDate(note.proposedAt)}</time> },
status === 'pending'
? { id: 'decide', header: 'Decision', className: 'min-w-[200px]', cell: note => <div className="flex flex-wrap gap-[8px]">
<Button size="sm" onClick={() => setDeciding({ note, decision: 'approved' })}>Approve</Button>
<Button size="sm" variant="ghost" onClick={() => setDeciding({ note, decision: 'rejected' })}>Reject</Button>
</div> }
: { id: 'reviewed', header: 'Decided', className: 'min-w-[150px]', cell: note => note.reviewedAt ? <time dateTime={note.reviewedAt}>{fmtDate(note.reviewedAt)}</time> : '—' },
]} />
{query.data?.next && <Button variant="ghost" onClick={() => setBefore(query.data?.next ?? null)}>Older notes</Button>}
{before && <Button variant="ghost" onClick={() => setBefore(null)}>Back to newest</Button>}
{deciding && <ConfirmAction
title={deciding.decision === 'approved' ? 'Approve note' : 'Reject note'}
description={deciding.decision === 'approved'
? `Every account will see this note about ${deciding.note.cards.join(', ')}. A decision is final and recorded in the audit log.`
: 'The proposer will see it as rejected; nobody else ever sees it. Say why, briefly.'}
reasonRequired={deciding.decision === 'rejected'}
reasonOptional
action={reason => api.adminReviewKnowledgeNote(deciding.note.id, { decision: deciding.decision, reason })}
close={() => setDeciding(null)} />}
</section>
}

/** /dev/knowledge: the same screen for a contributor, who has Dev tools but not Administration. */
export function DevKnowledgeReview() {
return <Content><div className="pb-[40px]"><KnowledgeReview /></div></Content>
}
6 changes: 4 additions & 2 deletions apps/web/src/routes/admin/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,17 @@ export function useAdminSave() {
}
return { busy, error, success, save }
}
export function ConfirmAction({ title, description, action, close, reasonRequired = true }: {
title: string; description: string; action: (reason: string) => Promise<unknown>; close: () => void; reasonRequired?: boolean
/** `reasonRequired` (default) demands a reason; `reasonOptional` shows the field without demanding it; neither hides it. */
export function ConfirmAction({ title, description, action, close, reasonRequired = true, reasonOptional = false }: {
title: string; description: string; action: (reason: string) => Promise<unknown>; close: () => void; reasonRequired?: boolean; reasonOptional?: boolean
}) {
const [reason, setReason] = useState('')
const state = useAdminSave()
const submit = (e: FormEvent) => { e.preventDefault(); void state.save(() => action(reason.trim()), close) }
return <Sheet title={title} onClose={() => { if (state.busy) return false; close() }}><form onSubmit={submit} className="space-y-[16px]">
<p className="text-text-body">{description}</p>
{reasonRequired && <Field label="Reason" required minLength={3} maxLength={500} value={reason} onChange={e => setReason(e.target.value)} hint="Recorded in the administrative audit log." />}
{!reasonRequired && reasonOptional && <Field label="Reason (optional)" maxLength={500} value={reason} onChange={e => setReason(e.target.value)} hint="Recorded in the administrative audit log." />}
{state.error && <FormAlert kind="error">{state.error}</FormAlert>}
<div className="flex flex-wrap gap-[12px]"><Button type="submit" loading={state.busy}>{title}</Button><Button variant="ghost" disabled={state.busy} onClick={close}>Cancel</Button></div>
</form></Sheet>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
date: "2026-10-26"
title: "Knowledge notes are reviewed in Administration and in Dev tools"
decided_by: "Chey (via Claude)"
areas: ["web", "admin"]
supersedes: []
---
## 2026-10-26 — Knowledge notes are reviewed in Administration and in Dev tools
**Decided by:** Chey (via Claude)

**Decision:** The review screen for knowledge notes (Phase 6, part two) is one component (`routes/admin/Knowledge.tsx`), mounted in two places:
- **Administration:** the "Knowledge notes" tab at `/admin/knowledge`, for admins.
- **Dev tools:** the "Knowledge notes" entry at `/dev/knowledge`, for anyone else holding `knowledge.review`.

Both routes require `knowledge.review` and nothing else. The screen does not use `useAdminQuery`, which requires `admin.access`.

On the screen, a reviewer:
- sees what is waiting, approved or rejected;
- reads each note's cards, statement, quote, full source link and automatic-check flags;
- approves (a reason is optional);
- or rejects (a reason is required).

Each decision is final and audited (#358).

**Why:**
- The owner asked for an administration surface that "users/admins of a certain reasonable tier" can reach. #358 lets a contributor role (tier 30) be given `knowledge.review`.
- `/admin` requires `admin.access`, which a contributor can never hold (the tier ceiling). A tab only under Administration would make that grant useless.
- Contributors already reach Dev tools.

**Implications:**
- The source link opens in a new tab with `noopener noreferrer nofollow` and is printed in full, so the reviewer sees where it goes first. All proposer text renders as text.
- The browser suite covers the screen at 1280 px: text stays text, flags show, a rejection without a reason is not sent, and decided notes move to their tab.
- The chunk is precached like the other Administration screens. Only dev-only routes are excluded from the precache.
Loading
Loading