Files
PaperClipAI/server/src/services/decision-training.ts
T
DottaandPaperclip 051ae4d102 feat: restore decision training library and inspector (#9779)
## Thinking Path

> - Paperclip is the open source control plane people use to manage
AI-agent companies and supervise governed work.
> - Decisions capture high-value operator judgment, and the
decision-training foundation merged in #9702 freezes that evidence for
later evaluation and learning.
> - Operators still need the UI from the closed stacked PR #9718 to
intentionally capture examples and inspect the resulting dataset.
> - GitHub automatically closed #9718 when its stacked base branch was
deleted after #9702 merged, leaving the server foundation on `master`
without the corresponding UI.
> - This pull request restores the final UI and its still-required
supporting API fields directly on current `master`, while excluding the
obsolete migration and duplicated server-foundation diffs.
> - The benefit is a reviewable replacement PR that preserves the
completed decision-training workflow without replaying stale stack
history.

## Linked Issues or Issue Description

- Refs #9718
- Refs #9702

## What Changed

- Restored the top-level `/training` library and record inspector with
search, filters, JSONL export, notes editing, and evidence tabs.
- Restored the Decisions-row training affordance and capture drawer,
including preview, provenance, deletion, cache refresh, and approval
consistency behavior.
- Restored the shared types and focused server support needed by the UI
without reintroducing decision-training migrations or the already-merged
server foundation.
- Restored focused UI and attention-service tests from the final #9718
state.
- Credit to the authors and reviewers of #9718; this recovery
transplants their final reviewed delta after the stacked base deletion.

## Verification

- `pnpm exec vitest run ui/src/pages/Training.test.tsx
ui/src/components/DecisionTrainingDrawer.test.tsx
ui/src/components/AttentionQueueRow.test.tsx
server/src/__tests__/attention-service.test.ts
server/src/__tests__/decision-training.test.ts` — 5 files, 48 tests
passed.
- `pnpm --filter @paperclipai/shared typecheck` — passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts` — 3
tests passed; confirms exact route/OpenAPI parity for the restored
preview endpoint.
- `pnpm check:token-gates` — the restored files are clean; the
repository-wide command currently reports five pre-existing false
positives where comments reference GitHub issue `#9627` as if it were a
color literal.

## Risks

- Low migration risk: this PR contains no database migrations and is
based directly on current `master`.
- The main behavioral risk is cache invalidation across Decisions and
Training views; focused tests cover capture, update, deletion, row
state, and approval refresh behavior.
- The token-gate baseline remains red on unrelated `#9627` comment
references; this PR does not modify those files.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.3-Codex, reasoning with repository/tool access and
code execution. Context window not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-17 13:27:32 -05:00

404 lines
14 KiB
TypeScript

import {
and,
asc,
desc,
eq,
ilike,
isNotNull,
lte,
or,
sql,
type SQL,
} from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
approvals,
decisionTrainingExamples,
executionWorkspaces,
heartbeatRuns,
issueApprovals,
issueComments,
issueExecutionDecisions,
issues,
issueThreadInteractions,
projectWorkspaces,
} from "@paperclipai/db";
import type {
DecisionTrainingNotesHistoryEntry,
DecisionTrainingSnapshotV1,
DecisionTrainingSourceKind,
} from "@paperclipai/shared";
import { DECISION_TRAINING_RETENTION_POLICY } from "@paperclipai/shared";
import { conflict, notFound } from "../errors.js";
type CaptureInput = {
companyId: string;
sourceKind: DecisionTrainingSourceKind;
sourceId: string;
issueId: string;
};
type SourceDecision = {
cutoffAt: Date;
outcome: string | null;
payload: Record<string, unknown>;
actor: Record<string, unknown> | null;
exactRunId: string | null;
};
type ListInput = {
projectId?: string;
kind?: DecisionTrainingSourceKind;
author?: string;
q?: string;
};
type ScrubDeletedCommentsInput = {
companyId: string;
issueId: string;
commentIds: string[];
deletedAt: Date;
};
function jsonCopy(value: unknown): Record<string, unknown> {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
function findCommitSha(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
if (Array.isArray(value)) {
for (const item of value) {
const found = findCommitSha(item);
if (found) return found;
}
return null;
}
const record = value as Record<string, unknown>;
for (const key of ["commitSha", "commitSHA", "gitCommitSha", "headSha", "commit"]) {
const candidate = record[key];
if (typeof candidate === "string" && /^[0-9a-f]{7,64}$/i.test(candidate)) return candidate;
}
for (const nested of Object.values(record)) {
const found = findCommitSha(nested);
if (found) return found;
}
return null;
}
async function loadSourceDecision(db: Db, input: CaptureInput, capturedAt: Date): Promise<SourceDecision> {
if (input.sourceKind === "interaction") {
const row = await db.query.issueThreadInteractions.findFirst({
where: and(
eq(issueThreadInteractions.id, input.sourceId),
eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.issueId),
),
});
if (!row) throw notFound("Decision interaction not found");
const resolved = row.resolvedAt != null && row.status !== "pending";
return {
cutoffAt: resolved ? row.resolvedAt! : capturedAt,
outcome: resolved ? row.status : null,
payload: jsonCopy({
kind: row.kind,
title: row.title,
summary: row.summary,
payload: row.payload,
result: resolved ? row.result : null,
}),
actor: jsonCopy(resolved
? { userId: row.resolvedByUserId, agentId: row.resolvedByAgentId }
: { userId: row.createdByUserId, agentId: row.createdByAgentId }),
exactRunId: row.sourceRunId,
};
}
if (input.sourceKind === "approval") {
const rows = await db
.select({ approval: approvals })
.from(approvals)
.innerJoin(issueApprovals, and(
eq(issueApprovals.approvalId, approvals.id),
eq(issueApprovals.issueId, input.issueId),
eq(issueApprovals.companyId, input.companyId),
))
.where(and(eq(approvals.id, input.sourceId), eq(approvals.companyId, input.companyId)))
.limit(1);
const row = rows[0]?.approval;
if (!row) throw notFound("Decision approval not found");
const resolved = row.decidedAt != null && row.status !== "pending";
return {
cutoffAt: resolved ? row.decidedAt! : capturedAt,
outcome: resolved ? row.status : null,
payload: jsonCopy({ type: row.type, payload: row.payload, decisionNote: resolved ? row.decisionNote : null }),
actor: jsonCopy(resolved
? { userId: row.decidedByUserId }
: { userId: row.requestedByUserId, agentId: row.requestedByAgentId }),
exactRunId: null,
};
}
const row = await db.query.issueExecutionDecisions.findFirst({
where: and(
eq(issueExecutionDecisions.id, input.sourceId),
eq(issueExecutionDecisions.companyId, input.companyId),
eq(issueExecutionDecisions.issueId, input.issueId),
),
});
if (!row) throw notFound("Execution decision not found");
return {
cutoffAt: row.createdAt,
outcome: row.outcome,
payload: jsonCopy({ stageId: row.stageId, stageType: row.stageType, body: row.body }),
actor: jsonCopy({ userId: row.actorUserId, agentId: row.actorAgentId }),
exactRunId: row.createdByRunId,
};
}
export async function captureDecisionSnapshot(
db: Db,
input: CaptureInput,
capturedAt = new Date(),
): Promise<{ cutoffAt: Date; decisionOutcome: string | null; snapshot: DecisionTrainingSnapshotV1 }> {
const issue = await db.query.issues.findFirst({
where: and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)),
});
if (!issue) throw notFound("Issue not found");
const decision = await loadSourceDecision(db, input, capturedAt);
const comments = await db
.select()
.from(issueComments)
.where(and(
eq(issueComments.companyId, input.companyId),
eq(issueComments.issueId, input.issueId),
lte(issueComments.createdAt, decision.cutoffAt),
))
.orderBy(asc(issueComments.createdAt), asc(issueComments.id));
const runs = await db
.select()
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.companyId, input.companyId),
isNotNull(heartbeatRuns.startedAt),
lte(heartbeatRuns.startedAt, decision.cutoffAt),
lte(heartbeatRuns.updatedAt, decision.cutoffAt),
or(
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${input.issueId}`,
sql`${heartbeatRuns.contextSnapshot} ->> 'taskId' = ${input.issueId}`,
),
))
.orderBy(asc(heartbeatRuns.startedAt), asc(heartbeatRuns.id));
const [projectWorkspace] = issue.projectId
? await db
.select()
.from(projectWorkspaces)
.where(and(
eq(projectWorkspaces.companyId, input.companyId),
eq(projectWorkspaces.projectId, issue.projectId),
lte(projectWorkspaces.createdAt, decision.cutoffAt),
lte(projectWorkspaces.updatedAt, decision.cutoffAt),
))
.orderBy(desc(projectWorkspaces.isPrimary), desc(projectWorkspaces.updatedAt))
.limit(1)
: [];
const [executionWorkspace] = await db
.select()
.from(executionWorkspaces)
.where(and(
eq(executionWorkspaces.companyId, input.companyId),
eq(executionWorkspaces.sourceIssueId, input.issueId),
lte(executionWorkspaces.openedAt, decision.cutoffAt),
lte(executionWorkspaces.lastUsedAt, decision.cutoffAt),
lte(executionWorkspaces.updatedAt, decision.cutoffAt),
))
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.id))
.limit(1);
const exactRun = decision.exactRunId ? runs.find((run) => run.id === decision.exactRunId) ?? null : null;
const latestRunWithCommit = [...runs].reverse().find((run) => findCommitSha(run.contextSnapshot)) ?? null;
const exactCommit = exactRun ? findCommitSha(exactRun.contextSnapshot) : null;
const nearestCommit = latestRunWithCommit ? findCommitSha(latestRunWithCommit.contextSnapshot) : null;
const workspaceCommit = findCommitSha(executionWorkspace?.metadata) ?? findCommitSha(projectWorkspace?.metadata);
const commitSha = exactCommit ?? nearestCommit ?? workspaceCommit;
return {
cutoffAt: decision.cutoffAt,
decisionOutcome: decision.outcome,
snapshot: {
version: 1,
retention: {
policy: DECISION_TRAINING_RETENTION_POLICY,
commentDeletion: "redact",
issueDeletion: "cascade",
},
capturedAt: capturedAt.toISOString(),
cutoff: {
at: decision.cutoffAt.toISOString(),
lastCommentId: comments.at(-1)?.id ?? null,
commentCount: comments.length,
},
issue: jsonCopy(issue),
comments: comments.map(jsonCopy),
runs: runs.map(jsonCopy),
decision: {
kind: input.sourceKind,
payload: decision.payload,
actor: decision.actor,
outcome: decision.outcome,
},
code: {
repoUrl: executionWorkspace?.repoUrl ?? projectWorkspace?.repoUrl ?? null,
ref: executionWorkspace?.branchName
?? executionWorkspace?.baseRef
?? projectWorkspace?.repoRef
?? projectWorkspace?.defaultRef
?? null,
commitSha: commitSha ?? null,
resolution: exactCommit
? "exact"
: nearestCommit
? "nearest_run"
: workspaceCommit
? "workspace"
: "none",
},
},
};
}
export function decisionTrainingService(db: Db) {
return {
// Capture the snapshot the way create() would, but without persisting it, so
// the drawer's create state can preview exactly what will be frozen before
// the user commits.
preview: async (input: CaptureInput) => captureDecisionSnapshot(db, input),
create: async (input: CaptureInput & { notes: string; createdByUserId: string }) => {
const captured = await captureDecisionSnapshot(db, input);
const rows = await db
.insert(decisionTrainingExamples)
.values({
companyId: input.companyId,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
issueId: input.issueId,
cutoffAt: captured.cutoffAt,
notes: input.notes,
notesHistory: [],
decisionOutcome: captured.decisionOutcome,
retentionPolicy: DECISION_TRAINING_RETENTION_POLICY,
snapshot: captured.snapshot,
createdByUserId: input.createdByUserId,
})
.onConflictDoNothing({
target: [
decisionTrainingExamples.sourceKind,
decisionTrainingExamples.sourceId,
decisionTrainingExamples.createdByUserId,
],
})
.returning();
if (!rows[0]) throw conflict("This decision is already trained by this user");
return rows[0];
},
list: async (companyId: string, input: ListInput = {}) => {
const filters: SQL[] = [eq(decisionTrainingExamples.companyId, companyId)];
if (input.projectId) filters.push(eq(issues.projectId, input.projectId));
if (input.kind) filters.push(eq(decisionTrainingExamples.sourceKind, input.kind));
if (input.author) filters.push(eq(decisionTrainingExamples.createdByUserId, input.author));
if (input.q) {
const query = `%${input.q}%`;
filters.push(or(
ilike(decisionTrainingExamples.notes, query),
ilike(issues.title, query),
ilike(issues.identifier, query),
)!);
}
return db
.select({ example: decisionTrainingExamples, issueTitle: issues.title, issueIdentifier: issues.identifier })
.from(decisionTrainingExamples)
.innerJoin(issues, eq(decisionTrainingExamples.issueId, issues.id))
.where(and(...filters))
.orderBy(desc(decisionTrainingExamples.createdAt), desc(decisionTrainingExamples.id));
},
getById: async (id: string) => db.query.decisionTrainingExamples.findFirst({
where: eq(decisionTrainingExamples.id, id),
}),
updateNotes: async (id: string, author: string, notes: string) => db.transaction(async (tx) => {
const row = await tx.query.decisionTrainingExamples.findFirst({
where: eq(decisionTrainingExamples.id, id),
});
if (!row) return null;
if (notes === row.notes) return row;
const history: DecisionTrainingNotesHistoryEntry[] = [
...(row.notesHistory ?? []),
{ author, at: new Date().toISOString(), body: row.notes },
];
const [updated] = await tx
.update(decisionTrainingExamples)
.set({ notes, notesHistory: history, updatedAt: new Date() })
.where(eq(decisionTrainingExamples.id, id))
.returning();
return updated ?? null;
}),
scrubDeletedComments: async (
input: ScrubDeletedCommentsInput,
dbOrTx: any = db,
) => {
if (input.commentIds.length === 0) return { updatedCount: 0 };
const commentIds = new Set(input.commentIds);
const rows = await dbOrTx
.select({ id: decisionTrainingExamples.id, snapshot: decisionTrainingExamples.snapshot })
.from(decisionTrainingExamples)
.where(and(
eq(decisionTrainingExamples.companyId, input.companyId),
eq(decisionTrainingExamples.issueId, input.issueId),
));
let updatedCount = 0;
for (const row of rows) {
let changed = false;
const comments = row.snapshot.comments.map((comment: Record<string, unknown>) => {
if (typeof comment.id !== "string" || !commentIds.has(comment.id)) return comment;
changed = true;
return {
id: comment.id,
issueId: input.issueId,
body: "",
presentation: null,
metadata: null,
deletedAt: input.deletedAt.toISOString(),
retentionRedaction: {
reason: "source_comment_deleted",
policy: DECISION_TRAINING_RETENTION_POLICY,
},
};
});
if (!changed) continue;
await dbOrTx
.update(decisionTrainingExamples)
.set({
retentionPolicy: DECISION_TRAINING_RETENTION_POLICY,
snapshot: {
...row.snapshot,
retention: {
policy: DECISION_TRAINING_RETENTION_POLICY,
commentDeletion: "redact",
issueDeletion: "cascade",
},
comments,
},
updatedAt: input.deletedAt,
})
.where(eq(decisionTrainingExamples.id, row.id));
updatedCount += 1;
}
return { updatedCount };
},
delete: async (id: string) => db
.delete(decisionTrainingExamples)
.where(eq(decisionTrainingExamples.id, id))
.returning({ id: decisionTrainingExamples.id }),
};
}