Add feedback voting and thumbs capture flow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-02 09:11:49 -05:00
parent 3db6bdfc3c
commit c0d0d03bce
66 changed files with 18988 additions and 78 deletions

View file

@ -41,6 +41,10 @@ export function companyService(db: Db) {
budgetMonthlyCents: companies.budgetMonthlyCents,
spentMonthlyCents: companies.spentMonthlyCents,
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
feedbackDataSharingConsentByUserId: companies.feedbackDataSharingConsentByUserId,
feedbackDataSharingTermsVersion: companies.feedbackDataSharingTermsVersion,
brandColor: companies.brandColor,
logoAssetId: companyLogos.assetId,
createdAt: companies.createdAt,

View file

@ -2289,7 +2289,7 @@ function buildManifestFromPackageFiles(
const skillPaths = Array.from(new Set([...referencedSkillPaths, ...discoveredSkillPaths])).sort();
const manifest: CompanyPortabilityManifest = {
schemaVersion: 4,
schemaVersion: 5,
generatedAt: new Date().toISOString(),
source: opts?.sourceLabel ?? null,
includes: {
@ -2309,6 +2309,18 @@ function buildManifestFromPackageFiles(
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
? paperclipCompany.requireBoardApprovalForNewAgents
: readCompanyApprovalDefault(companyFrontmatter),
feedbackDataSharingEnabled:
typeof paperclipCompany.feedbackDataSharingEnabled === "boolean"
? paperclipCompany.feedbackDataSharingEnabled
: false,
feedbackDataSharingConsentAt:
typeof paperclipCompany.feedbackDataSharingConsentAt === "string"
? paperclipCompany.feedbackDataSharingConsentAt
: null,
feedbackDataSharingConsentByUserId:
asString(paperclipCompany.feedbackDataSharingConsentByUserId),
feedbackDataSharingTermsVersion:
asString(paperclipCompany.feedbackDataSharingTermsVersion),
},
sidebar: paperclipSidebar,
agents: [],
@ -3227,6 +3239,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
brandColor: company.brandColor ?? null,
logoPath: companyLogoPath,
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false,
feedbackDataSharingEnabled: company.feedbackDataSharingEnabled ? true : undefined,
feedbackDataSharingConsentAt: company.feedbackDataSharingConsentAt?.toISOString() ?? null,
feedbackDataSharingConsentByUserId: company.feedbackDataSharingConsentByUserId ?? null,
feedbackDataSharingTermsVersion: company.feedbackDataSharingTermsVersion ?? null,
}),
sidebar: stripEmptyValues(sidebarOrder),
agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined,
@ -3736,6 +3752,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
requireBoardApprovalForNewAgents: include.company
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
: true,
feedbackDataSharingEnabled: include.company
? (sourceManifest.company?.feedbackDataSharingEnabled ?? false)
: false,
feedbackDataSharingConsentAt: include.company && sourceManifest.company?.feedbackDataSharingConsentAt
? new Date(sourceManifest.company.feedbackDataSharingConsentAt)
: null,
feedbackDataSharingConsentByUserId: include.company
? (sourceManifest.company?.feedbackDataSharingConsentByUserId ?? null)
: null,
feedbackDataSharingTermsVersion: include.company
? (sourceManifest.company?.feedbackDataSharingTermsVersion ?? null)
: null,
});
if (mode === "agent_safe" && options?.sourceCompanyId) {
await access.copyActiveUserMemberships(options.sourceCompanyId, created.id);
@ -3753,6 +3781,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
description: sourceManifest.company.description,
brandColor: sourceManifest.company.brandColor,
requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents,
feedbackDataSharingEnabled: sourceManifest.company.feedbackDataSharingEnabled,
feedbackDataSharingConsentAt: sourceManifest.company.feedbackDataSharingConsentAt
? new Date(sourceManifest.company.feedbackDataSharingConsentAt)
: null,
feedbackDataSharingConsentByUserId: sourceManifest.company.feedbackDataSharingConsentByUserId,
feedbackDataSharingTermsVersion: sourceManifest.company.feedbackDataSharingTermsVersion,
});
targetCompany = updated ?? targetCompany;
companyAction = "updated";

View file

@ -171,6 +171,7 @@ export function documentService(db: Db) {
baseRevisionId?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
createdByRunId?: string | null;
}) => {
const key = normalizeDocumentKey(input.key);
const issue = await db
@ -231,6 +232,7 @@ export function documentService(db: Db) {
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdByRunId: input.createdByRunId ?? null,
createdAt: now,
})
.returning();
@ -304,6 +306,7 @@ export function documentService(db: Db) {
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdByRunId: input.createdByRunId ?? null,
createdAt: now,
})
.returning();

View file

@ -0,0 +1,193 @@
import { createHash } from "node:crypto";
import { redactCurrentUserText } from "../log-redaction.js";
import { sanitizeRecord } from "../redaction.js";
export type FeedbackRedactionState = {
redactedFields: Set<string>;
truncatedFields: Set<string>;
omittedFields: Set<string>;
notes: Set<string>;
counts: Map<string, number>;
};
type PatternReplacement = string | ((match: string, ...args: string[]) => string);
type RedactionPattern = {
kind: string;
regex: RegExp;
replacement: PatternReplacement;
};
const SECRET_ASSIGNMENT_RE =
/\b(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)\s*[:=]\s*([^\s,;]+)/gi;
const FREE_TEXT_PATTERNS: RedactionPattern[] = [
{
kind: "pem_block",
regex: /-----BEGIN [^-]+-----[\s\S]+?-----END [^-]+-----/g,
replacement: "[REDACTED_PEM_BLOCK]",
},
{
kind: "secret_assignment",
regex: SECRET_ASSIGNMENT_RE,
replacement: (_match, key: string) => `${key}=[REDACTED]`,
},
{
kind: "bearer_token",
regex: /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi,
replacement: "Bearer [REDACTED_TOKEN]",
},
{
kind: "github_token",
regex: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
replacement: "[REDACTED_GITHUB_TOKEN]",
},
{
kind: "provider_api_key",
regex: /\bsk-(?:ant-)?[A-Za-z0-9_-]{12,}\b/g,
replacement: "[REDACTED_API_KEY]",
},
{
kind: "jwt",
regex: /\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?\b/g,
replacement: "[REDACTED_JWT]",
},
{
kind: "dsn",
regex: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|kafka|nats|mssql):\/\/[^\s<>'")]+/gi,
replacement: "[REDACTED_CONNECTION_STRING]",
},
{
kind: "email",
regex: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
replacement: "[REDACTED_EMAIL]",
},
{
kind: "phone",
regex: /(?<!\w)(?:\+?\d[\d ()-]{7,}\d)(?!\w)/g,
replacement: "[REDACTED_PHONE]",
},
];
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function increment(state: FeedbackRedactionState, kind: string, count: number) {
if (count <= 0) return;
state.counts.set(kind, (state.counts.get(kind) ?? 0) + count);
}
function recordField(state: FeedbackRedactionState, fieldPath: string) {
if (fieldPath.trim().length === 0) return;
state.redactedFields.add(fieldPath);
}
function applyPattern(input: string, pattern: RedactionPattern) {
const matches = Array.from(input.matchAll(pattern.regex)).length;
if (matches === 0) {
pattern.regex.lastIndex = 0;
return { output: input, matches: 0 };
}
const output = input.replace(pattern.regex, pattern.replacement as never);
pattern.regex.lastIndex = 0;
return { output, matches };
}
export function createFeedbackRedactionState(): FeedbackRedactionState {
return {
redactedFields: new Set<string>(),
truncatedFields: new Set<string>(),
omittedFields: new Set<string>(),
notes: new Set<string>(),
counts: new Map<string, number>(),
};
}
export function sanitizeFeedbackText(
input: string,
state: FeedbackRedactionState,
fieldPath: string,
maxLength: number,
) {
let output = redactCurrentUserText(input);
if (output !== input) {
recordField(state, fieldPath);
increment(state, "current_user", 1);
}
for (const pattern of FREE_TEXT_PATTERNS) {
const result = applyPattern(output, pattern);
if (result.matches > 0) {
output = result.output;
recordField(state, fieldPath);
increment(state, pattern.kind, result.matches);
}
}
if (output.length > maxLength) {
output = `${output.slice(0, Math.max(0, maxLength - 1))}...`;
state.truncatedFields.add(fieldPath);
}
return output;
}
export function sanitizeFeedbackValue(
value: unknown,
state: FeedbackRedactionState,
fieldPath: string,
maxStringLength: number,
): unknown {
if (typeof value === "string") {
return sanitizeFeedbackText(value, state, fieldPath, maxStringLength);
}
if (Array.isArray(value)) {
return value.map((entry, index) =>
sanitizeFeedbackValue(entry, state, `${fieldPath}[${index}]`, maxStringLength));
}
if (!isPlainRecord(value)) {
return value;
}
const structurallySanitized = sanitizeRecord(value);
if (stableStringify(structurallySanitized) !== stableStringify(value)) {
recordField(state, fieldPath);
increment(state, "structured_secret", 1);
}
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(structurallySanitized)) {
output[key] = sanitizeFeedbackValue(entry, state, `${fieldPath}.${key}`, maxStringLength);
}
return output;
}
export function finalizeFeedbackRedactionSummary(state: FeedbackRedactionState) {
return {
strategy: "deterministic_feedback_v2",
redactedFields: Array.from(state.redactedFields).sort(),
truncatedFields: Array.from(state.truncatedFields).sort(),
omittedFields: Array.from(state.omittedFields).sort(),
notes: Array.from(state.notes).sort(),
counts: Object.fromEntries(Array.from(state.counts.entries()).sort(([left], [right]) => left.localeCompare(right))),
} satisfies Record<string, unknown>;
}
export function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`);
return `{${entries.join(",")}}`;
}
export function sha256Digest(value: unknown) {
return createHash("sha256").update(stableStringify(value)).digest("hex");
}

View file

@ -0,0 +1,54 @@
import type { FeedbackTraceBundle } from "@paperclipai/shared";
import type { Config } from "../config.js";
function buildFeedbackShareObjectKey(bundle: FeedbackTraceBundle, exportedAt: Date) {
const year = String(exportedAt.getUTCFullYear());
const month = String(exportedAt.getUTCMonth() + 1).padStart(2, "0");
const day = String(exportedAt.getUTCDate()).padStart(2, "0");
return `feedback-traces/${bundle.companyId}/${year}/${month}/${day}/${bundle.exportId ?? bundle.traceId}.json`;
}
export interface FeedbackTraceShareClient {
uploadTraceBundle(bundle: FeedbackTraceBundle): Promise<{ objectKey: string }>;
}
export function createFeedbackTraceShareClientFromConfig(
config: Pick<Config, "feedbackExportBackendUrl" | "feedbackExportBackendToken">,
): FeedbackTraceShareClient | null {
const baseUrl = config.feedbackExportBackendUrl?.trim();
if (!baseUrl) return null;
const token = config.feedbackExportBackendToken?.trim();
const endpoint = new URL("/feedback-traces", baseUrl).toString();
return {
async uploadTraceBundle(bundle) {
const exportedAt = new Date();
const objectKey = buildFeedbackShareObjectKey(bundle, exportedAt);
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
objectKey,
exportedAt: exportedAt.toISOString(),
bundle,
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(detail.trim() || `Feedback trace upload failed with HTTP ${response.status}`);
}
const payload = await response.json().catch(() => null) as { objectKey?: unknown } | null;
return {
objectKey: typeof payload?.objectKey === "string" && payload.objectKey.trim().length > 0
? payload.objectKey
: objectKey,
};
},
};
}

File diff suppressed because it is too large Load diff

View file

@ -2615,7 +2615,7 @@ export function heartbeatService(db: Db) {
workspace: executionWorkspace,
runtimeServices,
}),
{ agentId: agent.id },
{ agentId: agent.id, runId: run.id },
);
} catch (err) {
await onLog(
@ -2705,7 +2705,7 @@ export function heartbeatService(db: Db) {
workspace: executionWorkspace,
runtimeServices: adapterManagedRuntimeServices,
}),
{ agentId: agent.id },
{ agentId: agent.id, runId: run.id },
);
} catch (err) {
await onLog(

View file

@ -1,4 +1,5 @@
export { companyService } from "./companies.js";
export { feedbackService } from "./feedback.js";
export { companySkillService } from "./company-skills.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";

View file

@ -1,6 +1,7 @@
import type { Db } from "@paperclipai/db";
import { companies, instanceSettings } from "@paperclipai/db";
import {
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
instanceGeneralSettingsSchema,
type InstanceGeneralSettings,
instanceExperimentalSettingsSchema,
@ -18,10 +19,13 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
if (parsed.success) {
return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
feedbackDataSharingPreference:
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
};
}
return {
censorUsernameInLogs: false,
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
};
}

View file

@ -21,7 +21,7 @@ import {
projectWorkspaces,
projects,
} from "@paperclipai/db";
import { extractAgentMentionIds, extractProjectMentionIds } from "@paperclipai/shared";
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
@ -467,6 +467,28 @@ function withActiveRuns(
export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db);
async function getIssueByUuid(id: string) {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
async function getIssueByIdentifier(identifier: string) {
const row = await db
.select()
.from(issues)
.where(eq(issues.identifier, identifier.toUpperCase()))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
@ -883,26 +905,19 @@ export function issueService(db: Db) {
return row ?? null;
},
getById: async (id: string) => {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
getById: async (raw: string) => {
const id = raw.trim();
if (/^[A-Z]+-\d+$/i.test(id)) {
return getIssueByIdentifier(id);
}
if (!isUuidLike(id)) {
return null;
}
return getIssueByUuid(id);
},
getByIdentifier: async (identifier: string) => {
const row = await db
.select()
.from(issues)
.where(eq(issues.identifier, identifier.toUpperCase()))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
return getIssueByIdentifier(identifier);
},
create: async (
@ -1542,7 +1557,11 @@ export function issueService(db: Db) {
return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
})),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
addComment: async (
issueId: string,
body: string,
actor: { agentId?: string; userId?: string; runId?: string | null },
) => {
const issue = await db
.select({ companyId: issues.companyId })
.from(issues)
@ -1562,6 +1581,7 @@ export function issueService(db: Db) {
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
body: redactedBody,
})
.returning();