Implement document review suggestions backend

This commit is contained in:
Dotta committed 2026-06-07 01:13:04 +00:00
1 parent 33b6fab3b3
commit 2277a98fe6
22 files changed
+2693 -2

No files matched your search

@@ -0,0 +1,278 @@
CREATE TABLE IF NOT EXISTS "document_review_threads" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"document_key" text NOT NULL,
"status" text DEFAULT 'open' NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"resolved_by_agent_id" uuid,
"resolved_by_user_id" text,
"resolved_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "document_review_comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"thread_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"body" text NOT NULL,
"author_type" text NOT NULL,
"author_agent_id" uuid,
"author_user_id" text,
"created_by_run_id" uuid,
"issue_comment_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "document_suggestions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"document_key" text NOT NULL,
"kind" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"anchor_state" text DEFAULT 'active' NOT NULL,
"anchor_confidence" text DEFAULT 'exact' NOT NULL,
"original_revision_id" uuid,
"original_revision_number" integer NOT NULL,
"current_revision_id" uuid,
"current_revision_number" integer NOT NULL,
"selected_text" text NOT NULL,
"proposed_text" text,
"insertion_position" text,
"prefix_text" text DEFAULT '' NOT NULL,
"suffix_text" text DEFAULT '' NOT NULL,
"normalized_start" integer NOT NULL,
"normalized_end" integer NOT NULL,
"markdown_start" integer NOT NULL,
"markdown_end" integer NOT NULL,
"anchor_selector" jsonb NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"accepted_by_agent_id" uuid,
"accepted_by_user_id" text,
"accepted_at" timestamp with time zone,
"accepted_revision_id" uuid,
"rejected_by_agent_id" uuid,
"rejected_by_user_id" text,
"rejected_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "document_suggestion_comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"suggestion_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"body" text NOT NULL,
"author_type" text NOT NULL,
"author_agent_id" uuid,
"author_user_id" text,
"created_by_run_id" uuid,
"issue_comment_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "document_suggestion_anchor_snapshots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"suggestion_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"from_revision_id" uuid,
"from_revision_number" integer,
"to_revision_id" uuid,
"to_revision_number" integer NOT NULL,
"previous_anchor" jsonb NOT NULL,
"next_anchor" jsonb,
"anchor_state" text NOT NULL,
"anchor_confidence" text NOT NULL,
"failure_reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_threads" ADD CONSTRAINT "document_review_threads_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_threads" ADD CONSTRAINT "document_review_threads_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_threads" ADD CONSTRAINT "document_review_threads_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_threads" ADD CONSTRAINT "document_review_threads_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_threads" ADD CONSTRAINT "document_review_threads_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_comments" ADD CONSTRAINT "document_review_comments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_comments" ADD CONSTRAINT "document_review_comments_thread_id_document_review_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."document_review_threads"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_comments" ADD CONSTRAINT "document_review_comments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_comments" ADD CONSTRAINT "document_review_comments_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_comments" ADD CONSTRAINT "document_review_comments_author_agent_id_agents_id_fk" FOREIGN KEY ("author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_comments" ADD CONSTRAINT "document_review_comments_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_review_comments" ADD CONSTRAINT "document_review_comments_issue_comment_id_issue_comments_id_fk" FOREIGN KEY ("issue_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_original_revision_id_document_revisions_id_fk" FOREIGN KEY ("original_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_current_revision_id_document_revisions_id_fk" FOREIGN KEY ("current_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_accepted_by_agent_id_agents_id_fk" FOREIGN KEY ("accepted_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_accepted_revision_id_document_revisions_id_fk" FOREIGN KEY ("accepted_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_rejected_by_agent_id_agents_id_fk" FOREIGN KEY ("rejected_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_suggestion_id_document_suggestions_id_fk" FOREIGN KEY ("suggestion_id") REFERENCES "public"."document_suggestions"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_author_agent_id_agents_id_fk" FOREIGN KEY ("author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_issue_comment_id_issue_comments_id_fk" FOREIGN KEY ("issue_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_anchor_snapshots" ADD CONSTRAINT "document_suggestion_anchor_snapshots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_anchor_snapshots" ADD CONSTRAINT "document_suggestion_anchor_snapshots_suggestion_id_document_suggestions_id_fk" FOREIGN KEY ("suggestion_id") REFERENCES "public"."document_suggestions"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_anchor_snapshots" ADD CONSTRAINT "document_suggestion_anchor_snapshots_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_anchor_snapshots" ADD CONSTRAINT "document_suggestion_anchor_snapshots_from_revision_id_document_revisions_id_fk" FOREIGN KEY ("from_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_suggestion_anchor_snapshots" ADD CONSTRAINT "document_suggestion_anchor_snapshots_to_revision_id_document_revisions_id_fk" FOREIGN KEY ("to_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_review_threads_company_document_status_idx" ON "document_review_threads" USING btree ("company_id","document_id","status");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_review_threads_company_issue_status_idx" ON "document_review_threads" USING btree ("company_id","issue_id","status");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_review_comments_company_thread_created_at_idx" ON "document_review_comments" USING btree ("company_id","thread_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_review_comments_company_issue_created_at_idx" ON "document_review_comments" USING btree ("company_id","issue_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_review_comments_issue_comment_idx" ON "document_review_comments" USING btree ("issue_comment_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_review_comments_body_search_idx" ON "document_review_comments" USING gin ("body" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_suggestions_company_document_status_idx" ON "document_suggestions" USING btree ("company_id","document_id","status");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_suggestions_company_issue_status_idx" ON "document_suggestions" USING btree ("company_id","issue_id","status");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_suggestions_company_current_revision_pending_idx" ON "document_suggestions" USING btree ("company_id","document_id","current_revision_id","status");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_suggestions_company_anchor_state_idx" ON "document_suggestions" USING btree ("company_id","anchor_state");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_suggestion_comments_company_suggestion_created_at_idx" ON "document_suggestion_comments" USING btree ("company_id","suggestion_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_suggestion_comments_company_issue_created_at_idx" ON "document_suggestion_comments" USING btree ("company_id","issue_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_suggestion_comments_issue_comment_idx" ON "document_suggestion_comments" USING btree ("issue_comment_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_suggestion_comments_body_search_idx" ON "document_suggestion_comments" USING gin ("body" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_suggestion_anchor_snapshots_company_suggestion_created_at_idx" ON "document_suggestion_anchor_snapshots" USING btree ("company_id","suggestion_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_suggestion_anchor_snapshots_company_document_revision_idx" ON "document_suggestion_anchor_snapshots" USING btree ("company_id","document_id","to_revision_number");
@@ -701,6 +701,13 @@
"when": 1780794000000,
"tag": "0099_document_management_foundation",
"breakpoints": true
},
{
"idx": 100,
"version": "7",
"when": 1780794300000,
"tag": "0100_document_review_backend",
"breakpoints": true
}
]
}
@@ -0,0 +1,42 @@
import type { IssueCommentAuthorType } from "@paperclipai/shared";
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { documentReviewThreads } from "./document_review_threads.js";
import { documents } from "./documents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issueComments } from "./issue_comments.js";
import { issues } from "./issues.js";
export const documentReviewComments = pgTable(
"document_review_comments",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
threadId: uuid("thread_id").notNull().references(() => documentReviewThreads.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
body: text("body").notNull(),
authorType: text("author_type").$type<IssueCommentAuthorType>().notNull(),
authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }),
authorUserId: text("author_user_id"),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
issueCommentId: uuid("issue_comment_id").references(() => issueComments.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyThreadCreatedAtIdx: index("document_review_comments_company_thread_created_at_idx").on(
table.companyId,
table.threadId,
table.createdAt,
),
companyIssueCreatedAtIdx: index("document_review_comments_company_issue_created_at_idx").on(
table.companyId,
table.issueId,
table.createdAt,
),
issueCommentIdx: index("document_review_comments_issue_comment_idx").on(table.issueCommentId),
bodySearchIdx: index("document_review_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
}),
);
@@ -0,0 +1,37 @@
import type { DocumentReviewThreadStatus } from "@paperclipai/shared";
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { documents } from "./documents.js";
import { issues } from "./issues.js";
export const documentReviewThreads = pgTable(
"document_review_threads",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
documentKey: text("document_key").notNull(),
status: text("status").$type<DocumentReviewThreadStatus>().notNull().default("open"),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
resolvedByUserId: text("resolved_by_user_id"),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyDocumentStatusIdx: index("document_review_threads_company_document_status_idx").on(
table.companyId,
table.documentId,
table.status,
),
companyIssueStatusIdx: index("document_review_threads_company_issue_status_idx").on(
table.companyId,
table.issueId,
table.status,
),
}),
);
@@ -0,0 +1,42 @@
import type {
DocumentAnnotationAnchorConfidence,
DocumentAnnotationAnchorSnapshot,
DocumentAnnotationAnchorState,
} from "@paperclipai/shared";
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { documentRevisions } from "./document_revisions.js";
import { documentSuggestions } from "./document_suggestions.js";
import { documents } from "./documents.js";
export const documentSuggestionAnchorSnapshots = pgTable(
"document_suggestion_anchor_snapshots",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
suggestionId: uuid("suggestion_id").notNull().references(() => documentSuggestions.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
fromRevisionId: uuid("from_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
fromRevisionNumber: integer("from_revision_number"),
toRevisionId: uuid("to_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
toRevisionNumber: integer("to_revision_number").notNull(),
previousAnchor: jsonb("previous_anchor").$type<DocumentAnnotationAnchorSnapshot>().notNull(),
nextAnchor: jsonb("next_anchor").$type<DocumentAnnotationAnchorSnapshot | null>(),
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull(),
anchorConfidence: text("anchor_confidence").$type<DocumentAnnotationAnchorConfidence>().notNull(),
failureReason: text("failure_reason"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companySuggestionCreatedAtIdx: index("document_suggestion_anchor_snapshots_company_suggestion_created_at_idx").on(
table.companyId,
table.suggestionId,
table.createdAt,
),
companyDocumentRevisionIdx: index("document_suggestion_anchor_snapshots_company_document_revision_idx").on(
table.companyId,
table.documentId,
table.toRevisionNumber,
),
}),
);
@@ -0,0 +1,42 @@
import type { IssueCommentAuthorType } from "@paperclipai/shared";
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { documentSuggestions } from "./document_suggestions.js";
import { documents } from "./documents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issueComments } from "./issue_comments.js";
import { issues } from "./issues.js";
export const documentSuggestionComments = pgTable(
"document_suggestion_comments",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
suggestionId: uuid("suggestion_id").notNull().references(() => documentSuggestions.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
body: text("body").notNull(),
authorType: text("author_type").$type<IssueCommentAuthorType>().notNull(),
authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }),
authorUserId: text("author_user_id"),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
issueCommentId: uuid("issue_comment_id").references(() => issueComments.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companySuggestionCreatedAtIdx: index("document_suggestion_comments_company_suggestion_created_at_idx").on(
table.companyId,
table.suggestionId,
table.createdAt,
),
companyIssueCreatedAtIdx: index("document_suggestion_comments_company_issue_created_at_idx").on(
table.companyId,
table.issueId,
table.createdAt,
),
issueCommentIdx: index("document_suggestion_comments_issue_comment_idx").on(table.issueCommentId),
bodySearchIdx: index("document_suggestion_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
}),
);
@@ -0,0 +1,73 @@
import type {
DocumentAnnotationAnchorConfidence,
DocumentAnnotationAnchorSelector,
DocumentAnnotationAnchorState,
DocumentSuggestionInsertPosition,
DocumentSuggestionKind,
DocumentSuggestionStatus,
} from "@paperclipai/shared";
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { documentRevisions } from "./document_revisions.js";
import { documents } from "./documents.js";
import { issues } from "./issues.js";
export const documentSuggestions = pgTable(
"document_suggestions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
documentKey: text("document_key").notNull(),
kind: text("kind").$type<DocumentSuggestionKind>().notNull(),
status: text("status").$type<DocumentSuggestionStatus>().notNull().default("pending"),
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull().default("active"),
anchorConfidence: text("anchor_confidence").$type<DocumentAnnotationAnchorConfidence>().notNull().default("exact"),
originalRevisionId: uuid("original_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
originalRevisionNumber: integer("original_revision_number").notNull(),
currentRevisionId: uuid("current_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
currentRevisionNumber: integer("current_revision_number").notNull(),
selectedText: text("selected_text").notNull(),
proposedText: text("proposed_text"),
insertionPosition: text("insertion_position").$type<DocumentSuggestionInsertPosition>(),
prefixText: text("prefix_text").notNull().default(""),
suffixText: text("suffix_text").notNull().default(""),
normalizedStart: integer("normalized_start").notNull(),
normalizedEnd: integer("normalized_end").notNull(),
markdownStart: integer("markdown_start").notNull(),
markdownEnd: integer("markdown_end").notNull(),
anchorSelector: jsonb("anchor_selector").$type<DocumentAnnotationAnchorSelector>().notNull(),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
acceptedByAgentId: uuid("accepted_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
acceptedByUserId: text("accepted_by_user_id"),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
acceptedRevisionId: uuid("accepted_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
rejectedByAgentId: uuid("rejected_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
rejectedByUserId: text("rejected_by_user_id"),
rejectedAt: timestamp("rejected_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyDocumentStatusIdx: index("document_suggestions_company_document_status_idx").on(
table.companyId,
table.documentId,
table.status,
),
companyIssueStatusIdx: index("document_suggestions_company_issue_status_idx").on(
table.companyId,
table.issueId,
table.status,
),
companyCurrentRevisionPendingIdx: index("document_suggestions_company_current_revision_pending_idx").on(
table.companyId,
table.documentId,
table.currentRevisionId,
table.status,
),
companyAnchorStateIdx: index("document_suggestions_company_anchor_state_idx").on(table.companyId, table.anchorState),
}),
);
+5
View File
@@ -60,6 +60,11 @@ export { issueDocuments } from "./issue_documents.js";
export { documentAnnotationThreads } from "./document_annotation_threads.js";
export { documentAnnotationComments } from "./document_annotation_comments.js";
export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js";
export { documentReviewThreads } from "./document_review_threads.js";
export { documentReviewComments } from "./document_review_comments.js";
export { documentSuggestions } from "./document_suggestions.js";
export { documentSuggestionComments } from "./document_suggestion_comments.js";
export { documentSuggestionAnchorSnapshots } from "./document_suggestion_anchor_snapshots.js";
export { heartbeatRuns } from "./heartbeat_runs.js";
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js";
+12
View File
@@ -338,6 +338,9 @@ export type IssueReferenceSourceKind = (typeof ISSUE_REFERENCE_SOURCE_KINDS)[num
export const DOCUMENT_ANNOTATION_THREAD_STATUSES = ["open", "resolved"] as const;
export type DocumentAnnotationThreadStatus = (typeof DOCUMENT_ANNOTATION_THREAD_STATUSES)[number];
export const DOCUMENT_REVIEW_THREAD_STATUSES = ["open", "resolved"] as const;
export type DocumentReviewThreadStatus = (typeof DOCUMENT_REVIEW_THREAD_STATUSES)[number];
export const DOCUMENT_ANNOTATION_ANCHOR_STATES = ["active", "stale", "orphaned"] as const;
export type DocumentAnnotationAnchorState = (typeof DOCUMENT_ANNOTATION_ANCHOR_STATES)[number];
@@ -351,6 +354,15 @@ export const DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES = [
export type DocumentAnnotationAnchorConfidence =
(typeof DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES)[number];
export const DOCUMENT_SUGGESTION_KINDS = ["insertion", "deletion", "substitution"] as const;
export type DocumentSuggestionKind = (typeof DOCUMENT_SUGGESTION_KINDS)[number];
export const DOCUMENT_SUGGESTION_INSERT_POSITIONS = ["before", "after"] as const;
export type DocumentSuggestionInsertPosition = (typeof DOCUMENT_SUGGESTION_INSERT_POSITIONS)[number];
export const DOCUMENT_SUGGESTION_STATUSES = ["pending", "accepted", "rejected"] as const;
export type DocumentSuggestionStatus = (typeof DOCUMENT_SUGGESTION_STATUSES)[number];
export const DOCUMENT_STATUSES = ["draft", "in_review", "approved", "archived"] as const;
export type DocumentStatus = (typeof DOCUMENT_STATUSES)[number];
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { parseCriticMarkup, renderCriticMarkupSuggestion } from "./critic-markup.js";
describe("critic markup helpers", () => {
it("extracts CriticMarkup-like edits outside code", () => {
const parsed = parseCriticMarkup("Keep {++new++} {--old--} {~~rough~>polished~~}.");
expect(parsed.plainMarkdown).toBe("Keep new polished.");
expect(parsed.suggestions).toEqual([
{
kind: "insertion",
selectedText: "",
proposedText: "new",
markdownStart: 5,
markdownEnd: 8,
},
{
kind: "deletion",
selectedText: "old",
proposedText: null,
markdownStart: 9,
markdownEnd: 9,
},
{
kind: "substitution",
selectedText: "rough",
proposedText: "polished",
markdownStart: 10,
markdownEnd: 18,
},
]);
});
it("ignores markers inside inline code and fenced code", () => {
const parsed = parseCriticMarkup([
"Visible {--remove--}.",
"Inline `{++literal++}` stays.",
"```",
"{~~old~>new~~}",
"```",
].join("\n"));
expect(parsed.plainMarkdown).toBe([
"Visible .",
"Inline `{++literal++}` stays.",
"```",
"{~~old~>new~~}",
"```",
].join("\n"));
expect(parsed.suggestions).toHaveLength(1);
expect(parsed.suggestions[0]?.kind).toBe("deletion");
});
it("renders optional CriticMarkup export markers", () => {
expect(renderCriticMarkupSuggestion({
kind: "substitution",
selectedText: "old",
proposedText: "new",
})).toBe("{~~old~>new~~}");
});
});
+126
View File
@@ -0,0 +1,126 @@
import type { DocumentSuggestionKind } from "./constants.js";
export interface CriticMarkupSuggestion {
kind: DocumentSuggestionKind;
selectedText: string;
proposedText: string | null;
markdownStart: number;
markdownEnd: number;
}
export interface ParseCriticMarkupResult {
plainMarkdown: string;
suggestions: CriticMarkupSuggestion[];
}
export function parseCriticMarkup(markdown: string): ParseCriticMarkupResult {
let index = 0;
let lineStart = true;
let inFence = false;
let plainMarkdown = "";
const suggestions: CriticMarkupSuggestion[] = [];
const append = (value: string) => {
plainMarkdown += value;
lineStart = value.endsWith("\n");
};
while (index < markdown.length) {
const rest = markdown.slice(index);
const fence = lineStart ? rest.match(/^(\s*)(```+|~~~+)/) : null;
if (fence) {
const lineEnd = markdown.indexOf("\n", index);
const end = lineEnd === -1 ? markdown.length : lineEnd + 1;
append(markdown.slice(index, end));
index = end;
inFence = !inFence;
continue;
}
if (inFence) {
const lineEnd = markdown.indexOf("\n", index);
const end = lineEnd === -1 ? markdown.length : lineEnd + 1;
append(markdown.slice(index, end));
index = end;
continue;
}
const tick = rest.match(/^`+/);
if (tick) {
const marker = tick[0];
const close = markdown.indexOf(marker, index + marker.length);
const end = close === -1 ? index + marker.length : close + marker.length;
append(markdown.slice(index, end));
index = end;
continue;
}
if (rest.startsWith("{++")) {
const close = markdown.indexOf("++}", index + 3);
if (close !== -1) {
const proposedText = markdown.slice(index + 3, close);
const start = plainMarkdown.length;
append(proposedText);
suggestions.push({
kind: "insertion",
selectedText: "",
proposedText,
markdownStart: start,
markdownEnd: start + proposedText.length,
});
index = close + 3;
continue;
}
}
if (rest.startsWith("{--")) {
const close = markdown.indexOf("--}", index + 3);
if (close !== -1) {
const selectedText = markdown.slice(index + 3, close);
const start = plainMarkdown.length;
suggestions.push({
kind: "deletion",
selectedText,
proposedText: null,
markdownStart: start,
markdownEnd: start,
});
index = close + 3;
continue;
}
}
if (rest.startsWith("{~~")) {
const middle = markdown.indexOf("~>", index + 3);
const close = middle === -1 ? -1 : markdown.indexOf("~~}", middle + 2);
if (middle !== -1 && close !== -1) {
const selectedText = markdown.slice(index + 3, middle);
const proposedText = markdown.slice(middle + 2, close);
const start = plainMarkdown.length;
append(proposedText);
suggestions.push({
kind: "substitution",
selectedText,
proposedText,
markdownStart: start,
markdownEnd: start + proposedText.length,
});
index = close + 3;
continue;
}
}
append(markdown[index] ?? "");
index += 1;
}
return { plainMarkdown, suggestions };
}
export function renderCriticMarkupSuggestion(
suggestion: Pick<CriticMarkupSuggestion, "kind" | "selectedText" | "proposedText">,
) {
if (suggestion.kind === "insertion") return `{++${suggestion.proposedText ?? ""}++}`;
if (suggestion.kind === "deletion") return `{--${suggestion.selectedText}--}`;
return `{~~${suggestion.selectedText}~>${suggestion.proposedText ?? ""}~~}`;
}
+48
View File
@@ -88,8 +88,12 @@ export {
isSystemIssueDocumentKey,
ISSUE_REFERENCE_SOURCE_KINDS,
DOCUMENT_ANNOTATION_THREAD_STATUSES,
DOCUMENT_REVIEW_THREAD_STATUSES,
DOCUMENT_ANNOTATION_ANCHOR_STATES,
DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES,
DOCUMENT_SUGGESTION_KINDS,
DOCUMENT_SUGGESTION_INSERT_POSITIONS,
DOCUMENT_SUGGESTION_STATUSES,
DOCUMENT_STATUSES,
DOCUMENT_TYPES,
DOCUMENT_LINK_TARGET_TYPES,
@@ -215,8 +219,12 @@ export {
type SystemIssueDocumentKey,
type IssueReferenceSourceKind,
type DocumentAnnotationThreadStatus,
type DocumentReviewThreadStatus,
type DocumentAnnotationAnchorState,
type DocumentAnnotationAnchorConfidence,
type DocumentSuggestionKind,
type DocumentSuggestionInsertPosition,
type DocumentSuggestionStatus,
type DocumentStatus,
type DocumentType,
type DocumentLinkTargetType,
@@ -499,7 +507,23 @@ export type {
DocumentBacklink,
DocumentFeedbackCounts,
DocumentLink,
AcceptDocumentSuggestionRequest,
CreateDocumentReviewCommentRequest,
CreateDocumentReviewThreadRequest,
CreateDocumentSuggestionCommentRequest,
CreateDocumentSuggestionRequest,
DocumentReviewComment,
DocumentReviewIndex,
DocumentReviewIndexCounts,
DocumentReviewThread,
DocumentReviewThreadWithComments,
DocumentSuggestion,
DocumentSuggestionAnchorRemapSnapshot,
DocumentSuggestionComment,
DocumentSuggestionWithComments,
RejectDocumentSuggestionRequest,
UpdateDocumentAnnotationThreadRequest,
UpdateDocumentReviewThreadRequest,
AttachmentArtifactWorkProductMetadata,
Issue,
IssueAssigneeAdapterOverrides,
@@ -804,6 +828,12 @@ export {
type VerifyDocumentAnchorSelectorInput,
type VerifyDocumentAnchorSelectorResult,
} from "./document-anchors.js";
export {
parseCriticMarkup,
renderCriticMarkupSuggestion,
type CriticMarkupSuggestion,
type ParseCriticMarkupResult,
} from "./critic-markup.js";
export {
sidebarOrderPreferenceSchema,
@@ -965,6 +995,24 @@ export {
type CreateDocumentAnnotationComment,
type CreateDocumentAnnotationThread,
type UpdateDocumentAnnotationThread,
acceptDocumentSuggestionSchema,
createDocumentReviewCommentSchema,
createDocumentReviewThreadSchema,
createDocumentSuggestionCommentSchema,
createDocumentSuggestionSchema,
documentReviewThreadStatusSchema,
documentSuggestionInsertPositionSchema,
documentSuggestionKindSchema,
documentSuggestionStatusSchema,
rejectDocumentSuggestionSchema,
updateDocumentReviewThreadSchema,
type AcceptDocumentSuggestion,
type CreateDocumentReviewComment,
type CreateDocumentReviewThread,
type CreateDocumentSuggestion,
type CreateDocumentSuggestionComment,
type RejectDocumentSuggestion,
type UpdateDocumentReviewThread,
companySearchQuerySchema,
COMPANY_SEARCH_DEFAULT_LIMIT,
COMPANY_SEARCH_MAX_LIMIT,
@@ -0,0 +1,189 @@
import type {
DocumentAnnotationAnchorConfidence,
DocumentAnnotationAnchorState,
DocumentReviewThreadStatus,
DocumentSuggestionInsertPosition,
DocumentSuggestionKind,
DocumentSuggestionStatus,
IssueCommentAuthorType,
} from "../constants.js";
import type {
DocumentAnnotationAnchorSelector,
DocumentAnnotationAnchorSnapshot,
DocumentAnnotationComment,
DocumentAnnotationThreadWithComments,
} from "./document-annotation.js";
export interface DocumentReviewThread {
id: string;
companyId: string;
issueId: string;
documentId: string;
documentKey: string;
status: DocumentReviewThreadStatus;
createdByAgentId: string | null;
createdByUserId: string | null;
resolvedByAgentId: string | null;
resolvedByUserId: string | null;
resolvedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface DocumentReviewComment {
id: string;
companyId: string;
threadId: string;
issueId: string;
documentId: string;
body: string;
authorType: IssueCommentAuthorType;
authorAgentId: string | null;
authorUserId: string | null;
createdByRunId: string | null;
issueCommentId?: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface DocumentReviewThreadWithComments extends DocumentReviewThread {
comments: DocumentReviewComment[];
}
export interface DocumentSuggestion {
id: string;
companyId: string;
issueId: string;
documentId: string;
documentKey: string;
kind: DocumentSuggestionKind;
status: DocumentSuggestionStatus;
anchorState: DocumentAnnotationAnchorState;
anchorConfidence: DocumentAnnotationAnchorConfidence;
originalRevisionId: string | null;
originalRevisionNumber: number;
currentRevisionId: string | null;
currentRevisionNumber: number;
selectedText: string;
proposedText: string | null;
insertionPosition: DocumentSuggestionInsertPosition | null;
prefixText: string;
suffixText: string;
normalizedStart: number;
normalizedEnd: number;
markdownStart: number;
markdownEnd: number;
anchorSelector: DocumentAnnotationAnchorSelector;
createdByAgentId: string | null;
createdByUserId: string | null;
acceptedByAgentId: string | null;
acceptedByUserId: string | null;
acceptedAt: Date | null;
acceptedRevisionId: string | null;
rejectedByAgentId: string | null;
rejectedByUserId: string | null;
rejectedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface DocumentSuggestionComment {
id: string;
companyId: string;
suggestionId: string;
issueId: string;
documentId: string;
body: string;
authorType: IssueCommentAuthorType;
authorAgentId: string | null;
authorUserId: string | null;
createdByRunId: string | null;
issueCommentId?: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface DocumentSuggestionWithComments extends DocumentSuggestion {
comments: DocumentSuggestionComment[];
}
export interface DocumentSuggestionAnchorRemapSnapshot {
id: string;
companyId: string;
suggestionId: string;
documentId: string;
fromRevisionId: string | null;
fromRevisionNumber: number | null;
toRevisionId: string | null;
toRevisionNumber: number;
previousAnchor: DocumentAnnotationAnchorSnapshot;
nextAnchor: DocumentAnnotationAnchorSnapshot | null;
anchorState: DocumentAnnotationAnchorState;
anchorConfidence: DocumentAnnotationAnchorConfidence;
failureReason: string | null;
createdAt: Date;
}
export interface DocumentReviewIndexCounts {
unresolved: number;
openAnchoredThreads: number;
openReviewThreads: number;
pendingSuggestions: number;
resolvedAnchoredThreads: number;
resolvedReviewThreads: number;
acceptedSuggestions: number;
rejectedSuggestions: number;
staleAnchors: number;
orphanedAnchors: number;
}
export interface DocumentReviewIndex {
issueId: string;
documentId: string;
documentKey: string;
latestRevisionId: string | null;
latestRevisionNumber: number;
counts: DocumentReviewIndexCounts;
annotationThreads: DocumentAnnotationThreadWithComments[];
reviewThreads: DocumentReviewThreadWithComments[];
suggestions: DocumentSuggestionWithComments[];
}
export interface CreateDocumentReviewThreadRequest {
body: string;
issueCommentId?: string | null;
}
export interface CreateDocumentReviewCommentRequest {
body: string;
issueCommentId?: string | null;
}
export interface UpdateDocumentReviewThreadRequest {
status?: DocumentReviewThreadStatus;
}
export interface CreateDocumentSuggestionRequest {
baseRevisionId: string;
baseRevisionNumber: number;
kind: DocumentSuggestionKind;
selector: DocumentAnnotationAnchorSelector;
proposedText?: string | null;
insertionPosition?: DocumentSuggestionInsertPosition | null;
body?: string | null;
issueCommentId?: string | null;
}
export interface CreateDocumentSuggestionCommentRequest {
body: string;
issueCommentId?: string | null;
}
export interface AcceptDocumentSuggestionRequest {
baseRevisionId: string;
changeSummary?: string | null;
}
export interface RejectDocumentSuggestionRequest {
reason?: string | null;
}
+5
View File
@@ -23,6 +23,11 @@ export interface DocumentBacklink {
export interface DocumentFeedbackCounts {
openComments: number;
resolvedComments: number;
openReviewThreads: number;
resolvedReviewThreads: number;
pendingSuggestions: number;
acceptedSuggestions: number;
rejectedSuggestions: number;
staleAnchors: number;
orphanedAnchors: number;
}
+18
View File
@@ -175,6 +175,24 @@ export type {
DocumentFeedbackCounts,
DocumentLink,
} from "./document.js";
export type {
AcceptDocumentSuggestionRequest,
CreateDocumentReviewCommentRequest,
CreateDocumentReviewThreadRequest,
CreateDocumentSuggestionCommentRequest,
CreateDocumentSuggestionRequest,
DocumentReviewComment,
DocumentReviewIndex,
DocumentReviewIndexCounts,
DocumentReviewThread,
DocumentReviewThreadWithComments,
DocumentSuggestion,
DocumentSuggestionAnchorRemapSnapshot,
DocumentSuggestionComment,
DocumentSuggestionWithComments,
RejectDocumentSuggestionRequest,
UpdateDocumentReviewThreadRequest,
} from "./document-review.js";
export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type {
CompanySearchHighlight,
@@ -0,0 +1,89 @@
import { z } from "zod";
import {
DOCUMENT_REVIEW_THREAD_STATUSES,
DOCUMENT_SUGGESTION_INSERT_POSITIONS,
DOCUMENT_SUGGESTION_KINDS,
DOCUMENT_SUGGESTION_STATUSES,
} from "../constants.js";
import { documentAnnotationAnchorSelectorSchema } from "./document-annotation.js";
import { multilineTextSchema } from "./text.js";
export const documentReviewThreadStatusSchema = z.enum(DOCUMENT_REVIEW_THREAD_STATUSES);
export const documentSuggestionKindSchema = z.enum(DOCUMENT_SUGGESTION_KINDS);
export const documentSuggestionInsertPositionSchema = z.enum(DOCUMENT_SUGGESTION_INSERT_POSITIONS);
export const documentSuggestionStatusSchema = z.enum(DOCUMENT_SUGGESTION_STATUSES);
const reviewBodySchema = multilineTextSchema.pipe(z.string().min(1).max(20_000));
const suggestedTextSchema = z.string().max(100_000);
export const createDocumentReviewThreadSchema = z.object({
body: reviewBodySchema,
issueCommentId: z.string().uuid().nullable().optional(),
}).strict();
export const createDocumentReviewCommentSchema = z.object({
body: reviewBodySchema,
issueCommentId: z.string().uuid().nullable().optional(),
}).strict();
export const updateDocumentReviewThreadSchema = z.object({
status: documentReviewThreadStatusSchema.optional(),
}).strict().refine((value) => value.status != null, {
message: "At least one field must be provided",
});
export const createDocumentSuggestionSchema = z.object({
baseRevisionId: z.string().uuid(),
baseRevisionNumber: z.number().int().positive(),
kind: documentSuggestionKindSchema,
selector: documentAnnotationAnchorSelectorSchema,
proposedText: suggestedTextSchema.nullable().optional(),
insertionPosition: documentSuggestionInsertPositionSchema.nullable().optional(),
body: reviewBodySchema.nullable().optional(),
issueCommentId: z.string().uuid().nullable().optional(),
}).strict().superRefine((value, ctx) => {
const proposedText = value.proposedText ?? "";
if ((value.kind === "insertion" || value.kind === "substitution") && proposedText.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "proposedText is required for insertion and substitution suggestions",
path: ["proposedText"],
});
}
if (value.kind === "deletion" && proposedText.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "deletion suggestions cannot include proposedText",
path: ["proposedText"],
});
}
if (value.kind !== "insertion" && value.insertionPosition != null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "insertionPosition is only valid for insertion suggestions",
path: ["insertionPosition"],
});
}
});
export const createDocumentSuggestionCommentSchema = z.object({
body: reviewBodySchema,
issueCommentId: z.string().uuid().nullable().optional(),
}).strict();
export const acceptDocumentSuggestionSchema = z.object({
baseRevisionId: z.string().uuid(),
changeSummary: z.string().trim().min(1).max(500).nullable().optional(),
}).strict();
export const rejectDocumentSuggestionSchema = z.object({
reason: z.string().trim().min(1).max(2_000).nullable().optional(),
}).strict();
export type CreateDocumentReviewThread = z.infer<typeof createDocumentReviewThreadSchema>;
export type CreateDocumentReviewComment = z.infer<typeof createDocumentReviewCommentSchema>;
export type UpdateDocumentReviewThread = z.infer<typeof updateDocumentReviewThreadSchema>;
export type CreateDocumentSuggestion = z.infer<typeof createDocumentSuggestionSchema>;
export type CreateDocumentSuggestionComment = z.infer<typeof createDocumentSuggestionCommentSchema>;
export type AcceptDocumentSuggestion = z.infer<typeof acceptDocumentSuggestionSchema>;
export type RejectDocumentSuggestion = z.infer<typeof rejectDocumentSuggestionSchema>;
+21
View File
@@ -229,6 +229,27 @@ export {
type UpdateDocumentAnnotationThread,
} from "./document-annotation.js";
export {
acceptDocumentSuggestionSchema,
createDocumentReviewCommentSchema,
createDocumentReviewThreadSchema,
createDocumentSuggestionCommentSchema,
createDocumentSuggestionSchema,
documentReviewThreadStatusSchema,
documentSuggestionInsertPositionSchema,
documentSuggestionKindSchema,
documentSuggestionStatusSchema,
rejectDocumentSuggestionSchema,
updateDocumentReviewThreadSchema,
type AcceptDocumentSuggestion,
type CreateDocumentReviewComment,
type CreateDocumentReviewThread,
type CreateDocumentSuggestion,
type CreateDocumentSuggestionComment,
type RejectDocumentSuggestion,
type UpdateDocumentReviewThread,
} from "./document-review.js";
export {
createIssueSchema,
createIssueInputSchema,
@@ -0,0 +1,219 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
companies,
createDb,
documentAnnotationAnchorSnapshots,
documentAnnotationComments,
documentAnnotationThreads,
documentReviewComments,
documentReviewThreads,
documentRevisions,
documentSuggestionAnchorSnapshots,
documentSuggestionComments,
documentSuggestions,
documents,
issueComments,
issueDocuments,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { documentReviewService } from "../services/document-review.js";
import { documentService } from "../services/documents.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres document review service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("documentReviewService", () => {
let db!: ReturnType<typeof createDb>;
let docs!: ReturnType<typeof documentService>;
let review!: ReturnType<typeof documentReviewService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-document-review-");
db = createDb(tempDb.connectionString);
docs = documentService(db);
review = documentReviewService(db);
}, 20_000);
afterEach(async () => {
await db.delete(documentSuggestionAnchorSnapshots);
await db.delete(documentSuggestionComments);
await db.delete(documentSuggestions);
await db.delete(documentReviewComments);
await db.delete(documentReviewThreads);
await db.delete(documentAnnotationAnchorSnapshots);
await db.delete(documentAnnotationComments);
await db.delete(documentAnnotationThreads);
await db.delete(documentRevisions);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function createIssueWithDocument(body = "Alpha selected text omega") {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
identifier: "PAP-10523",
title: "Review backend",
description: "Validate document review primitives",
status: "in_progress",
priority: "medium",
});
const created = await docs.upsertIssueDocument({
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body,
});
return { companyId, issueId, document: created.document };
}
const selectedTextSelector = {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
};
it("indexes document-level review threads and pending suggestions for agents", async () => {
const { issueId, document } = await createIssueWithDocument();
await review.createReviewThread(
issueId,
"plan",
{ body: "Overall: tighten this document." },
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const suggestion = await review.createSuggestion(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
kind: "substitution",
selector: selectedTextSelector,
proposedText: "better text",
body: "Suggested replacement.",
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const index = await review.getReviewIndex(issueId, "plan", { includeComments: true });
expect(index.counts.unresolved).toBe(2);
expect(index.counts.openReviewThreads).toBe(1);
expect(index.counts.pendingSuggestions).toBe(1);
expect(index.reviewThreads[0]?.comments[0]?.body).toBe("Overall: tighten this document.");
expect(index.suggestions[0]?.id).toBe(suggestion.id);
expect(index.suggestions[0]?.comments[0]?.body).toBe("Suggested replacement.");
});
it("accepts a suggestion by creating an auditable document revision", async () => {
const { issueId, document } = await createIssueWithDocument();
const suggestion = await review.createSuggestion(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
kind: "substitution",
selector: selectedTextSelector,
proposedText: "better text",
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const accepted = await review.acceptSuggestion(
issueId,
"plan",
suggestion.id,
{ baseRevisionId: document.latestRevisionId!, changeSummary: "Accept suggested wording" },
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
expect(accepted.document.body).toBe("Alpha better text omega");
expect(accepted.suggestion.status).toBe("accepted");
expect(accepted.suggestion.acceptedRevisionId).toBe(accepted.revision.id);
const revisions = await docs.listIssueDocumentRevisions(issueId, "plan");
expect(revisions.map((revision) => revision.changeSummary)).toContain("Accept suggested wording");
const index = await review.getReviewIndex(issueId, "plan", { status: "all" });
expect(index.counts.pendingSuggestions).toBe(0);
expect(index.counts.acceptedSuggestions).toBe(1);
});
it("remaps pending suggestions across document revisions before acceptance", async () => {
const { issueId, document } = await createIssueWithDocument();
const suggestion = await review.createSuggestion(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
kind: "deletion",
selector: selectedTextSelector,
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const updated = await docs.upsertIssueDocument({
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body: "Beta selected text omega",
baseRevisionId: document.latestRevisionId!,
changeSummary: "Manual edit before suggestion acceptance",
});
const remapped = await review.remapOpenSuggestionsForDocument({
issueId,
key: "plan",
documentId: document.id,
nextRevisionId: updated.document.latestRevisionId,
nextRevisionNumber: updated.document.latestRevisionNumber,
nextBody: updated.document.body,
});
expect(remapped).toHaveLength(1);
expect(remapped[0]?.suggestion.id).toBe(suggestion.id);
expect(remapped[0]?.suggestion.anchorState).toBe("active");
expect(remapped[0]?.suggestion.markdownStart).toBe(5);
const accepted = await review.acceptSuggestion(
issueId,
"plan",
suggestion.id,
{ baseRevisionId: updated.document.latestRevisionId! },
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
expect(accepted.document.body).toBe("Beta omega");
});
});
+477
View File
@@ -20,6 +20,7 @@ import {
} from "@paperclipai/db";
import {
addIssueCommentSchema,
acceptDocumentSuggestionSchema,
acceptIssueThreadInteractionSchema,
attachmentArtifactWorkProductMetadataSchema,
cancelIssueThreadInteractionSchema,
@@ -32,6 +33,10 @@ import {
checkoutIssueSchema,
createDocumentAnnotationCommentSchema,
createDocumentAnnotationThreadSchema,
createDocumentReviewCommentSchema,
createDocumentReviewThreadSchema,
createDocumentSuggestionCommentSchema,
createDocumentSuggestionSchema,
createChildIssueSchema,
createIssueSchema,
resolveCreateIssueStatusDefault,
@@ -43,11 +48,13 @@ import {
linkIssueApprovalSchema,
issueDocumentKeySchema,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
rejectDocumentSuggestionSchema,
rejectIssueThreadInteractionSchema,
restoreIssueDocumentRevisionSchema,
respondIssueThreadInteractionSchema,
updateIssueWorkProductSchema,
updateDocumentAnnotationThreadSchema,
updateDocumentReviewThreadSchema,
upsertIssueDocumentSchema,
updateIssueSchema,
getClosedIsolatedExecutionWorkspaceMessage,
@@ -83,6 +90,7 @@ import {
clampIssueListLimit,
documentService,
documentAnnotationService,
documentReviewService,
logActivity,
projectService,
routineService,
@@ -960,6 +968,7 @@ export function issueRoutes(
const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db);
const documentAnnotationsSvc = documentAnnotationService(db);
const documentReviewsSvc = documentReviewService(db);
const issueReferencesSvc = issueReferenceService(db);
const issueThreadInteractionsSvc = issueThreadInteractionService(db);
const routinesSvc = routineService(db, {
@@ -3286,6 +3295,414 @@ export function issueRoutes(
},
);
router.get("/issues/:id/documents/:key/review-index", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const index = await documentReviewsSvc.getReviewIndex(issue.id, keyParsed.data, {
status: req.query.status === "all" ? "all" : "open",
includeComments: parseBooleanQuery(req.query.includeComments),
});
res.json(index);
});
router.post(
"/issues/:id/documents/:key/review-comments",
validate(createDocumentReviewThreadSchema),
async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const { actor, annotationActor } = annotationActorInput(req);
const thread = await documentReviewsSvc.createReviewThread(issue.id, keyParsed.data, req.body, annotationActor);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_review_thread_created",
entityType: "issue",
entityId: issue.id,
details: {
documentKey: thread.documentKey,
documentId: thread.documentId,
threadId: thread.id,
commentId: thread.comments[0]?.id ?? null,
},
});
res.status(201).json(thread);
},
);
router.post(
"/issues/:id/documents/:key/review-comments/:threadId/comments",
validate(createDocumentReviewCommentSchema),
async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const { actor, annotationActor } = annotationActorInput(req);
const comment = await documentReviewsSvc.addReviewComment(
issue.id,
keyParsed.data,
req.params.threadId as string,
req.body,
annotationActor,
);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_review_comment_added",
entityType: "issue",
entityId: issue.id,
details: {
documentKey: keyParsed.data,
threadId: comment.threadId,
commentId: comment.id,
bodySnippet: comment.body.slice(0, 120),
},
});
res.status(201).json(comment);
},
);
router.patch(
"/issues/:id/documents/:key/review-comments/:threadId",
validate(updateDocumentReviewThreadSchema),
async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const { actor, annotationActor } = annotationActorInput(req);
const thread = await documentReviewsSvc.updateReviewThread(
issue.id,
keyParsed.data,
req.params.threadId as string,
req.body,
annotationActor,
);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: thread.status === "resolved"
? "issue.document_review_thread_resolved"
: "issue.document_review_thread_reopened",
entityType: "issue",
entityId: issue.id,
details: {
documentKey: thread.documentKey,
documentId: thread.documentId,
threadId: thread.id,
status: thread.status,
},
});
res.json(thread);
},
);
router.post(
"/issues/:id/documents/:key/suggestions",
validate(createDocumentSuggestionSchema),
async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const { actor, annotationActor } = annotationActorInput(req);
const suggestion = await documentReviewsSvc.createSuggestion(issue.id, keyParsed.data, req.body, annotationActor);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_suggestion_created",
entityType: "issue",
entityId: issue.id,
details: {
documentKey: suggestion.documentKey,
documentId: suggestion.documentId,
suggestionId: suggestion.id,
kind: suggestion.kind,
revisionNumber: suggestion.currentRevisionNumber,
quote: suggestion.selectedText.slice(0, 240),
},
});
res.status(201).json(suggestion);
},
);
router.post(
"/issues/:id/documents/:key/suggestions/:suggestionId/comments",
validate(createDocumentSuggestionCommentSchema),
async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const { actor, annotationActor } = annotationActorInput(req);
const comment = await documentReviewsSvc.addSuggestionComment(
issue.id,
keyParsed.data,
req.params.suggestionId as string,
req.body,
annotationActor,
);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_suggestion_comment_added",
entityType: "issue",
entityId: issue.id,
details: {
documentKey: keyParsed.data,
suggestionId: comment.suggestionId,
commentId: comment.id,
bodySnippet: comment.body.slice(0, 120),
},
});
res.status(201).json(comment);
},
);
router.post(
"/issues/:id/documents/:key/suggestions/:suggestionId/reject",
validate(rejectDocumentSuggestionSchema),
async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const { actor, annotationActor } = annotationActorInput(req);
const suggestion = await documentReviewsSvc.rejectSuggestion(
issue.id,
keyParsed.data,
req.params.suggestionId as string,
req.body,
annotationActor,
);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_suggestion_rejected",
entityType: "issue",
entityId: issue.id,
details: {
documentKey: suggestion.documentKey,
documentId: suggestion.documentId,
suggestionId: suggestion.id,
kind: suggestion.kind,
reason: req.body.reason ?? null,
},
});
res.json(suggestion);
},
);
router.post(
"/issues/:id/documents/:key/suggestions/:suggestionId/accept",
validate(acceptDocumentSuggestionSchema),
async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const actor = getActorInfo(req);
const result = await documentReviewsSvc.acceptSuggestion(
issue.id,
keyParsed.data,
req.params.suggestionId as string,
req.body,
{
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
},
);
await issueReferencesSvc.syncDocument(result.document.id);
const remappedAnnotations = await documentAnnotationsSvc.remapOpenThreadsForDocument({
issueId: issue.id,
key: result.document.key,
documentId: result.document.id,
nextRevisionId: result.document.latestRevisionId,
nextRevisionNumber: result.document.latestRevisionNumber,
nextBody: result.document.body,
});
const remappedSuggestions = await documentReviewsSvc.remapOpenSuggestionsForDocument({
issueId: issue.id,
key: result.document.key,
documentId: result.document.id,
nextRevisionId: result.document.latestRevisionId,
nextRevisionNumber: result.document.latestRevisionNumber,
nextBody: result.document.body,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_suggestion_accepted",
entityType: "issue",
entityId: issue.id,
details: {
documentKey: result.document.key,
documentId: result.document.id,
suggestionId: result.suggestion.id,
kind: result.suggestion.kind,
acceptedRevisionId: result.revision.id,
revisionNumber: result.revision.revisionNumber,
},
});
for (const remap of remappedAnnotations) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_annotation_remapped",
entityType: "issue",
entityId: issue.id,
details: {
key: result.document.key,
documentId: result.document.id,
threadId: remap.thread.id,
revisionNumber: result.document.latestRevisionNumber,
anchorState: remap.thread.anchorState,
anchorConfidence: remap.thread.anchorConfidence,
snapshotId: remap.snapshot.id,
},
});
}
for (const remap of remappedSuggestions) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_suggestion_remapped",
entityType: "issue",
entityId: issue.id,
details: {
key: result.document.key,
documentId: result.document.id,
suggestionId: remap.suggestion.id,
revisionNumber: result.document.latestRevisionNumber,
anchorState: remap.suggestion.anchorState,
anchorConfidence: remap.suggestion.anchorConfidence,
snapshotId: remap.snapshot.id,
},
});
}
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "document",
actor,
documentChanged: true,
});
res.json(result);
},
);
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
@@ -3335,6 +3752,16 @@ export function issueRoutes(
nextRevisionNumber: doc.latestRevisionNumber,
nextBody: doc.body,
});
const remappedSuggestions = result.created
? []
: await documentReviewsSvc.remapOpenSuggestionsForDocument({
issueId: issue.id,
key: doc.key,
documentId: doc.id,
nextRevisionId: doc.latestRevisionId,
nextRevisionNumber: doc.latestRevisionNumber,
nextBody: doc.body,
});
await logActivity(db, {
companyId: issue.companyId,
@@ -3381,6 +3808,27 @@ export function issueRoutes(
},
});
}
for (const remap of remappedSuggestions) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_suggestion_remapped",
entityType: "issue",
entityId: issue.id,
details: {
key: doc.key,
documentId: doc.id,
suggestionId: remap.suggestion.id,
revisionNumber: doc.latestRevisionNumber,
anchorState: remap.suggestion.anchorState,
anchorConfidence: remap.suggestion.anchorConfidence,
snapshotId: remap.snapshot.id,
},
});
}
if (!result.created) {
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
@@ -3561,6 +4009,14 @@ export function issueRoutes(
nextRevisionNumber: result.document.latestRevisionNumber,
nextBody: result.document.body,
});
const remappedSuggestions = await documentReviewsSvc.remapOpenSuggestionsForDocument({
issueId: issue.id,
key: result.document.key,
documentId: result.document.id,
nextRevisionId: result.document.latestRevisionId,
nextRevisionNumber: result.document.latestRevisionNumber,
nextBody: result.document.body,
});
await logActivity(db, {
companyId: issue.companyId,
@@ -3608,6 +4064,27 @@ export function issueRoutes(
},
});
}
for (const remap of remappedSuggestions) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_suggestion_remapped",
entityType: "issue",
entityId: issue.id,
details: {
key: result.document.key,
documentId: result.document.id,
suggestionId: remap.suggestion.id,
revisionNumber: result.document.latestRevisionNumber,
anchorState: remap.suggestion.anchorState,
anchorConfidence: remap.suggestion.anchorConfidence,
snapshotId: remap.snapshot.id,
},
});
}
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
issue,
+832
View File
@@ -0,0 +1,832 @@
import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
documentAnnotationComments,
documentAnnotationThreads,
documentRevisions,
documentReviewComments,
documentReviewThreads,
documentSuggestionAnchorSnapshots,
documentSuggestionComments,
documentSuggestions,
documents,
issueComments,
issueDocuments,
} from "@paperclipai/db";
import {
anchorSnapshotToSelector,
remapDocumentAnchor,
selectorToAnchorSnapshot,
verifyDocumentAnchorSelector,
type AcceptDocumentSuggestion,
type CreateDocumentReviewComment,
type CreateDocumentReviewThread,
type CreateDocumentSuggestion,
type CreateDocumentSuggestionComment,
type DocumentAnnotationAnchorSnapshot,
type DocumentAnnotationComment,
type DocumentAnnotationThread,
type DocumentReviewComment,
type DocumentReviewIndex,
type DocumentReviewThread,
type DocumentSuggestion,
type DocumentSuggestionComment,
type RejectDocumentSuggestion,
type UpdateDocumentReviewThread,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
type ActorInput = {
actorType: "agent" | "user";
actorId: string;
agentId?: string | null;
userId?: string | null;
runId?: string | null;
};
type IssueDocumentRow = {
issueId: string;
companyId: string;
documentId: string;
documentKey: string;
title: string | null;
format: string;
latestBody: string;
latestRevisionId: string | null;
latestRevisionNumber: number;
};
const annotationThreadSelect = {
id: documentAnnotationThreads.id,
companyId: documentAnnotationThreads.companyId,
issueId: documentAnnotationThreads.issueId,
documentId: documentAnnotationThreads.documentId,
documentKey: documentAnnotationThreads.documentKey,
status: documentAnnotationThreads.status,
anchorState: documentAnnotationThreads.anchorState,
anchorConfidence: documentAnnotationThreads.anchorConfidence,
originalRevisionId: documentAnnotationThreads.originalRevisionId,
originalRevisionNumber: documentAnnotationThreads.originalRevisionNumber,
currentRevisionId: documentAnnotationThreads.currentRevisionId,
currentRevisionNumber: documentAnnotationThreads.currentRevisionNumber,
selectedText: documentAnnotationThreads.selectedText,
prefixText: documentAnnotationThreads.prefixText,
suffixText: documentAnnotationThreads.suffixText,
normalizedStart: documentAnnotationThreads.normalizedStart,
normalizedEnd: documentAnnotationThreads.normalizedEnd,
markdownStart: documentAnnotationThreads.markdownStart,
markdownEnd: documentAnnotationThreads.markdownEnd,
anchorSelector: documentAnnotationThreads.anchorSelector,
createdByAgentId: documentAnnotationThreads.createdByAgentId,
createdByUserId: documentAnnotationThreads.createdByUserId,
resolvedByAgentId: documentAnnotationThreads.resolvedByAgentId,
resolvedByUserId: documentAnnotationThreads.resolvedByUserId,
resolvedAt: documentAnnotationThreads.resolvedAt,
createdAt: documentAnnotationThreads.createdAt,
updatedAt: documentAnnotationThreads.updatedAt,
};
const annotationCommentSelect = {
id: documentAnnotationComments.id,
companyId: documentAnnotationComments.companyId,
threadId: documentAnnotationComments.threadId,
issueId: documentAnnotationComments.issueId,
documentId: documentAnnotationComments.documentId,
body: documentAnnotationComments.body,
authorType: documentAnnotationComments.authorType,
authorAgentId: documentAnnotationComments.authorAgentId,
authorUserId: documentAnnotationComments.authorUserId,
createdByRunId: documentAnnotationComments.createdByRunId,
issueCommentId: documentAnnotationComments.issueCommentId,
createdAt: documentAnnotationComments.createdAt,
updatedAt: documentAnnotationComments.updatedAt,
};
const reviewThreadSelect = {
id: documentReviewThreads.id,
companyId: documentReviewThreads.companyId,
issueId: documentReviewThreads.issueId,
documentId: documentReviewThreads.documentId,
documentKey: documentReviewThreads.documentKey,
status: documentReviewThreads.status,
createdByAgentId: documentReviewThreads.createdByAgentId,
createdByUserId: documentReviewThreads.createdByUserId,
resolvedByAgentId: documentReviewThreads.resolvedByAgentId,
resolvedByUserId: documentReviewThreads.resolvedByUserId,
resolvedAt: documentReviewThreads.resolvedAt,
createdAt: documentReviewThreads.createdAt,
updatedAt: documentReviewThreads.updatedAt,
};
const reviewCommentSelect = {
id: documentReviewComments.id,
companyId: documentReviewComments.companyId,
threadId: documentReviewComments.threadId,
issueId: documentReviewComments.issueId,
documentId: documentReviewComments.documentId,
body: documentReviewComments.body,
authorType: documentReviewComments.authorType,
authorAgentId: documentReviewComments.authorAgentId,
authorUserId: documentReviewComments.authorUserId,
createdByRunId: documentReviewComments.createdByRunId,
issueCommentId: documentReviewComments.issueCommentId,
createdAt: documentReviewComments.createdAt,
updatedAt: documentReviewComments.updatedAt,
};
const suggestionSelect = {
id: documentSuggestions.id,
companyId: documentSuggestions.companyId,
issueId: documentSuggestions.issueId,
documentId: documentSuggestions.documentId,
documentKey: documentSuggestions.documentKey,
kind: documentSuggestions.kind,
status: documentSuggestions.status,
anchorState: documentSuggestions.anchorState,
anchorConfidence: documentSuggestions.anchorConfidence,
originalRevisionId: documentSuggestions.originalRevisionId,
originalRevisionNumber: documentSuggestions.originalRevisionNumber,
currentRevisionId: documentSuggestions.currentRevisionId,
currentRevisionNumber: documentSuggestions.currentRevisionNumber,
selectedText: documentSuggestions.selectedText,
proposedText: documentSuggestions.proposedText,
insertionPosition: documentSuggestions.insertionPosition,
prefixText: documentSuggestions.prefixText,
suffixText: documentSuggestions.suffixText,
normalizedStart: documentSuggestions.normalizedStart,
normalizedEnd: documentSuggestions.normalizedEnd,
markdownStart: documentSuggestions.markdownStart,
markdownEnd: documentSuggestions.markdownEnd,
anchorSelector: documentSuggestions.anchorSelector,
createdByAgentId: documentSuggestions.createdByAgentId,
createdByUserId: documentSuggestions.createdByUserId,
acceptedByAgentId: documentSuggestions.acceptedByAgentId,
acceptedByUserId: documentSuggestions.acceptedByUserId,
acceptedAt: documentSuggestions.acceptedAt,
acceptedRevisionId: documentSuggestions.acceptedRevisionId,
rejectedByAgentId: documentSuggestions.rejectedByAgentId,
rejectedByUserId: documentSuggestions.rejectedByUserId,
rejectedAt: documentSuggestions.rejectedAt,
createdAt: documentSuggestions.createdAt,
updatedAt: documentSuggestions.updatedAt,
};
const suggestionCommentSelect = {
id: documentSuggestionComments.id,
companyId: documentSuggestionComments.companyId,
suggestionId: documentSuggestionComments.suggestionId,
issueId: documentSuggestionComments.issueId,
documentId: documentSuggestionComments.documentId,
body: documentSuggestionComments.body,
authorType: documentSuggestionComments.authorType,
authorAgentId: documentSuggestionComments.authorAgentId,
authorUserId: documentSuggestionComments.authorUserId,
createdByRunId: documentSuggestionComments.createdByRunId,
issueCommentId: documentSuggestionComments.issueCommentId,
createdAt: documentSuggestionComments.createdAt,
updatedAt: documentSuggestionComments.updatedAt,
};
function snapshotFromSuggestion(suggestion: Pick<DocumentSuggestion, "selectedText" | "prefixText" | "suffixText" | "normalizedStart" | "normalizedEnd" | "markdownStart" | "markdownEnd">): DocumentAnnotationAnchorSnapshot {
return {
selectedText: suggestion.selectedText,
prefixText: suggestion.prefixText,
suffixText: suggestion.suffixText,
normalizedStart: suggestion.normalizedStart,
normalizedEnd: suggestion.normalizedEnd,
markdownStart: suggestion.markdownStart,
markdownEnd: suggestion.markdownEnd,
};
}
function applySuggestionToBody(body: string, suggestion: DocumentSuggestion) {
if (suggestion.anchorState === "orphaned") {
throw conflict("Cannot accept an orphaned suggestion", { suggestionId: suggestion.id });
}
if (suggestion.kind === "insertion") {
const insertionText = suggestion.proposedText ?? "";
if (!insertionText) throw unprocessable("Insertion suggestion is missing proposedText");
const position = suggestion.insertionPosition === "before" ? suggestion.markdownStart : suggestion.markdownEnd;
return `${body.slice(0, position)}${insertionText}${body.slice(position)}`;
}
if (suggestion.kind === "deletion") {
return `${body.slice(0, suggestion.markdownStart)}${body.slice(suggestion.markdownEnd)}`;
}
const replacementText = suggestion.proposedText ?? "";
if (!replacementText) throw unprocessable("Substitution suggestion is missing proposedText");
return `${body.slice(0, suggestion.markdownStart)}${replacementText}${body.slice(suggestion.markdownEnd)}`;
}
export function documentReviewService(db: Db) {
async function getIssueDocument(issueId: string, key: string, dbOrTx: any = db): Promise<IssueDocumentRow | null> {
return dbOrTx
.select({
issueId: issueDocuments.issueId,
companyId: documents.companyId,
documentId: documents.id,
documentKey: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
.then((rows: IssueDocumentRow[]) => rows[0] ?? null);
}
async function assertLinkedIssueComment(issueId: string, commentId: string | null | undefined, dbOrTx: any = db) {
if (!commentId) return null;
const comment = await dbOrTx
.select({ id: issueComments.id, issueId: issueComments.issueId })
.from(issueComments)
.where(and(eq(issueComments.id, commentId), isNull(issueComments.deletedAt)))
.then((rows: Array<{ id: string; issueId: string }>) => rows[0] ?? null);
if (!comment || comment.issueId !== issueId) {
throw unprocessable("Linked issue comment must belong to this issue");
}
return comment;
}
async function commentsForReviewThreads(threadIds: string[], dbOrTx: any = db): Promise<DocumentReviewComment[]> {
if (threadIds.length === 0) return [];
return dbOrTx
.select(reviewCommentSelect)
.from(documentReviewComments)
.where(inArray(documentReviewComments.threadId, threadIds))
.orderBy(asc(documentReviewComments.createdAt), asc(documentReviewComments.id));
}
async function commentsForSuggestions(suggestionIds: string[], dbOrTx: any = db): Promise<DocumentSuggestionComment[]> {
if (suggestionIds.length === 0) return [];
return dbOrTx
.select(suggestionCommentSelect)
.from(documentSuggestionComments)
.where(inArray(documentSuggestionComments.suggestionId, suggestionIds))
.orderBy(asc(documentSuggestionComments.createdAt), asc(documentSuggestionComments.id));
}
async function commentsForAnnotationThreads(threadIds: string[], dbOrTx: any = db): Promise<DocumentAnnotationComment[]> {
if (threadIds.length === 0) return [];
return dbOrTx
.select(annotationCommentSelect)
.from(documentAnnotationComments)
.where(inArray(documentAnnotationComments.threadId, threadIds))
.orderBy(asc(documentAnnotationComments.createdAt), asc(documentAnnotationComments.id));
}
async function getReviewThreadForIssue(issueId: string, documentKey: string, threadId: string, dbOrTx: any = db) {
return dbOrTx
.select(reviewThreadSelect)
.from(documentReviewThreads)
.where(and(
eq(documentReviewThreads.id, threadId),
eq(documentReviewThreads.issueId, issueId),
eq(documentReviewThreads.documentKey, documentKey),
))
.then((rows: DocumentReviewThread[]) => rows[0] ?? null);
}
async function getSuggestionForIssue(issueId: string, documentKey: string, suggestionId: string, dbOrTx: any = db) {
return dbOrTx
.select(suggestionSelect)
.from(documentSuggestions)
.where(and(
eq(documentSuggestions.id, suggestionId),
eq(documentSuggestions.issueId, issueId),
eq(documentSuggestions.documentKey, documentKey),
))
.then((rows: DocumentSuggestion[]) => rows[0] ?? null);
}
return {
getReviewIndex: async (
issueId: string,
key: string,
options: { status?: "open" | "all"; includeComments?: boolean } = {},
): Promise<DocumentReviewIndex> => {
const doc = await getIssueDocument(issueId, key);
if (!doc) throw notFound("Document not found");
const openOnly = options.status !== "all";
const annotationConditions = [
eq(documentAnnotationThreads.issueId, issueId),
eq(documentAnnotationThreads.documentId, doc.documentId),
];
const reviewConditions = [
eq(documentReviewThreads.issueId, issueId),
eq(documentReviewThreads.documentId, doc.documentId),
];
const suggestionConditions = [
eq(documentSuggestions.issueId, issueId),
eq(documentSuggestions.documentId, doc.documentId),
];
if (openOnly) {
annotationConditions.push(eq(documentAnnotationThreads.status, "open"));
reviewConditions.push(eq(documentReviewThreads.status, "open"));
suggestionConditions.push(eq(documentSuggestions.status, "pending"));
}
const [
annotationThreads,
reviewThreads,
suggestions,
annotationCountRows,
reviewCountRows,
suggestionCountRows,
] = await Promise.all([
db.select(annotationThreadSelect).from(documentAnnotationThreads)
.where(and(...annotationConditions))
.orderBy(desc(documentAnnotationThreads.updatedAt), desc(documentAnnotationThreads.id)),
db.select(reviewThreadSelect).from(documentReviewThreads)
.where(and(...reviewConditions))
.orderBy(desc(documentReviewThreads.updatedAt), desc(documentReviewThreads.id)),
db.select(suggestionSelect).from(documentSuggestions)
.where(and(...suggestionConditions))
.orderBy(desc(documentSuggestions.updatedAt), desc(documentSuggestions.id)),
db.select({
openAnchoredThreads: sql<number>`count(*) filter (where ${documentAnnotationThreads.status} = 'open')`.mapWith(Number),
resolvedAnchoredThreads: sql<number>`count(*) filter (where ${documentAnnotationThreads.status} = 'resolved')`.mapWith(Number),
staleAnchors: sql<number>`count(*) filter (where ${documentAnnotationThreads.status} = 'open' and ${documentAnnotationThreads.anchorState} = 'stale')`.mapWith(Number),
orphanedAnchors: sql<number>`count(*) filter (where ${documentAnnotationThreads.status} = 'open' and ${documentAnnotationThreads.anchorState} = 'orphaned')`.mapWith(Number),
}).from(documentAnnotationThreads).where(and(
eq(documentAnnotationThreads.issueId, issueId),
eq(documentAnnotationThreads.documentId, doc.documentId),
)),
db.select({
openReviewThreads: sql<number>`count(*) filter (where ${documentReviewThreads.status} = 'open')`.mapWith(Number),
resolvedReviewThreads: sql<number>`count(*) filter (where ${documentReviewThreads.status} = 'resolved')`.mapWith(Number),
}).from(documentReviewThreads).where(and(
eq(documentReviewThreads.issueId, issueId),
eq(documentReviewThreads.documentId, doc.documentId),
)),
db.select({
pendingSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'pending')`.mapWith(Number),
acceptedSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'accepted')`.mapWith(Number),
rejectedSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'rejected')`.mapWith(Number),
staleAnchors: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'pending' and ${documentSuggestions.anchorState} = 'stale')`.mapWith(Number),
orphanedAnchors: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'pending' and ${documentSuggestions.anchorState} = 'orphaned')`.mapWith(Number),
}).from(documentSuggestions).where(and(
eq(documentSuggestions.issueId, issueId),
eq(documentSuggestions.documentId, doc.documentId),
)),
]);
const annotationCounts = annotationCountRows[0] ?? {
openAnchoredThreads: 0,
resolvedAnchoredThreads: 0,
staleAnchors: 0,
orphanedAnchors: 0,
};
const reviewCounts = reviewCountRows[0] ?? {
openReviewThreads: 0,
resolvedReviewThreads: 0,
};
const suggestionCounts = suggestionCountRows[0] ?? {
pendingSuggestions: 0,
acceptedSuggestions: 0,
rejectedSuggestions: 0,
staleAnchors: 0,
orphanedAnchors: 0,
};
const countsBase = {
openAnchoredThreads: annotationCounts.openAnchoredThreads,
resolvedAnchoredThreads: annotationCounts.resolvedAnchoredThreads,
openReviewThreads: reviewCounts.openReviewThreads,
resolvedReviewThreads: reviewCounts.resolvedReviewThreads,
pendingSuggestions: suggestionCounts.pendingSuggestions,
acceptedSuggestions: suggestionCounts.acceptedSuggestions,
rejectedSuggestions: suggestionCounts.rejectedSuggestions,
staleAnchors: annotationCounts.staleAnchors + suggestionCounts.staleAnchors,
orphanedAnchors: annotationCounts.orphanedAnchors + suggestionCounts.orphanedAnchors,
};
const [annotationComments, reviewComments, suggestionComments] = options.includeComments
? await Promise.all([
commentsForAnnotationThreads(annotationThreads.map((thread) => thread.id)),
commentsForReviewThreads(reviewThreads.map((thread) => thread.id)),
commentsForSuggestions(suggestions.map((suggestion) => suggestion.id)),
])
: [[], [], []] as const;
const annotationCommentsByThread = new Map<string, DocumentAnnotationComment[]>();
for (const comment of annotationComments) {
annotationCommentsByThread.set(comment.threadId, [...(annotationCommentsByThread.get(comment.threadId) ?? []), comment]);
}
const reviewCommentsByThread = new Map<string, DocumentReviewComment[]>();
for (const comment of reviewComments) {
reviewCommentsByThread.set(comment.threadId, [...(reviewCommentsByThread.get(comment.threadId) ?? []), comment]);
}
const commentsBySuggestion = new Map<string, DocumentSuggestionComment[]>();
for (const comment of suggestionComments) {
commentsBySuggestion.set(comment.suggestionId, [...(commentsBySuggestion.get(comment.suggestionId) ?? []), comment]);
}
return {
issueId,
documentId: doc.documentId,
documentKey: doc.documentKey,
latestRevisionId: doc.latestRevisionId,
latestRevisionNumber: doc.latestRevisionNumber,
counts: {
...countsBase,
unresolved: countsBase.openAnchoredThreads + countsBase.openReviewThreads + countsBase.pendingSuggestions,
},
annotationThreads: annotationThreads.map((thread) => ({
...thread,
comments: annotationCommentsByThread.get(thread.id) ?? [],
})),
reviewThreads: reviewThreads.map((thread) => ({
...thread,
comments: reviewCommentsByThread.get(thread.id) ?? [],
})),
suggestions: suggestions.map((suggestion) => ({
...suggestion,
comments: commentsBySuggestion.get(suggestion.id) ?? [],
})),
};
},
createReviewThread: async (
issueId: string,
key: string,
input: CreateDocumentReviewThread,
actor: ActorInput,
) => db.transaction(async (tx) => {
const doc = await getIssueDocument(issueId, key, tx);
if (!doc) throw notFound("Document not found");
const now = new Date();
const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx);
const [thread] = await tx.insert(documentReviewThreads).values({
companyId: doc.companyId,
issueId,
documentId: doc.documentId,
documentKey: doc.documentKey,
status: "open",
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
createdAt: now,
updatedAt: now,
}).returning(reviewThreadSelect);
const [comment] = await tx.insert(documentReviewComments).values({
companyId: doc.companyId,
threadId: thread.id,
issueId,
documentId: doc.documentId,
body: input.body,
authorType: actor.actorType,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
issueCommentId: linkedIssueComment?.id ?? null,
createdAt: now,
updatedAt: now,
}).returning(reviewCommentSelect);
return { ...thread, comments: [comment] };
}),
addReviewComment: async (
issueId: string,
key: string,
threadId: string,
input: CreateDocumentReviewComment,
actor: ActorInput,
) => db.transaction(async (tx) => {
const thread = await getReviewThreadForIssue(issueId, key, threadId, tx);
if (!thread) throw notFound("Review thread not found");
const now = new Date();
const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx);
const [comment] = await tx.insert(documentReviewComments).values({
companyId: thread.companyId,
threadId: thread.id,
issueId: thread.issueId,
documentId: thread.documentId,
body: input.body,
authorType: actor.actorType,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
issueCommentId: linkedIssueComment?.id ?? null,
createdAt: now,
updatedAt: now,
}).returning(reviewCommentSelect);
await tx.update(documentReviewThreads).set({ updatedAt: now }).where(eq(documentReviewThreads.id, thread.id));
return comment;
}),
updateReviewThread: async (
issueId: string,
key: string,
threadId: string,
input: UpdateDocumentReviewThread,
actor: ActorInput,
) => db.transaction(async (tx) => {
const thread = await getReviewThreadForIssue(issueId, key, threadId, tx);
if (!thread) throw notFound("Review thread not found");
if (!input.status || input.status === thread.status) return thread;
const now = new Date();
const [updated] = await tx.update(documentReviewThreads)
.set(input.status === "resolved"
? {
status: "resolved",
resolvedByAgentId: actor.agentId ?? null,
resolvedByUserId: actor.userId ?? null,
resolvedAt: now,
updatedAt: now,
}
: {
status: "open",
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
updatedAt: now,
})
.where(eq(documentReviewThreads.id, thread.id))
.returning(reviewThreadSelect);
return updated;
}),
createSuggestion: async (
issueId: string,
key: string,
input: CreateDocumentSuggestion,
actor: ActorInput,
) => db.transaction(async (tx) => {
await tx.execute(sql`
select ${documents.id}
from ${issueDocuments}
inner join ${documents} on ${issueDocuments.documentId} = ${documents.id}
where ${and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))}
for update of ${documents}
`);
const doc = await getIssueDocument(issueId, key, tx);
if (!doc) throw notFound("Document not found");
if (input.baseRevisionId !== doc.latestRevisionId || input.baseRevisionNumber !== doc.latestRevisionNumber) {
throw conflict("Suggestion anchor requires the current document revision", {
currentRevisionId: doc.latestRevisionId,
currentRevisionNumber: doc.latestRevisionNumber,
});
}
const verification = verifyDocumentAnchorSelector({ markdown: doc.latestBody, selector: input.selector });
if (!verification.ok || !verification.anchor) {
throw unprocessable("Suggestion anchor does not match the current document revision", {
reason: verification.reason,
});
}
const now = new Date();
const [suggestion] = await tx.insert(documentSuggestions).values({
companyId: doc.companyId,
issueId,
documentId: doc.documentId,
documentKey: doc.documentKey,
kind: input.kind,
status: "pending",
anchorState: "active",
anchorConfidence: "exact",
originalRevisionId: doc.latestRevisionId,
originalRevisionNumber: doc.latestRevisionNumber,
currentRevisionId: doc.latestRevisionId,
currentRevisionNumber: doc.latestRevisionNumber,
selectedText: verification.anchor.selectedText,
proposedText: input.kind === "deletion" ? null : input.proposedText ?? null,
insertionPosition: input.kind === "insertion" ? input.insertionPosition ?? "after" : null,
prefixText: verification.anchor.prefixText,
suffixText: verification.anchor.suffixText,
normalizedStart: verification.anchor.normalizedStart,
normalizedEnd: verification.anchor.normalizedEnd,
markdownStart: verification.anchor.markdownStart,
markdownEnd: verification.anchor.markdownEnd,
anchorSelector: input.selector,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
createdAt: now,
updatedAt: now,
}).returning(suggestionSelect);
const comments = [];
if (input.body) {
const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx);
const [comment] = await tx.insert(documentSuggestionComments).values({
companyId: doc.companyId,
suggestionId: suggestion.id,
issueId,
documentId: doc.documentId,
body: input.body,
authorType: actor.actorType,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
issueCommentId: linkedIssueComment?.id ?? null,
createdAt: now,
updatedAt: now,
}).returning(suggestionCommentSelect);
comments.push(comment);
}
return { ...suggestion, comments };
}),
addSuggestionComment: async (
issueId: string,
key: string,
suggestionId: string,
input: CreateDocumentSuggestionComment,
actor: ActorInput,
) => db.transaction(async (tx) => {
const suggestion = await getSuggestionForIssue(issueId, key, suggestionId, tx);
if (!suggestion) throw notFound("Suggestion not found");
const now = new Date();
const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx);
const [comment] = await tx.insert(documentSuggestionComments).values({
companyId: suggestion.companyId,
suggestionId: suggestion.id,
issueId: suggestion.issueId,
documentId: suggestion.documentId,
body: input.body,
authorType: actor.actorType,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
issueCommentId: linkedIssueComment?.id ?? null,
createdAt: now,
updatedAt: now,
}).returning(suggestionCommentSelect);
await tx.update(documentSuggestions).set({ updatedAt: now }).where(eq(documentSuggestions.id, suggestion.id));
return comment;
}),
rejectSuggestion: async (
issueId: string,
key: string,
suggestionId: string,
_input: RejectDocumentSuggestion,
actor: ActorInput,
) => db.transaction(async (tx) => {
const suggestion = await getSuggestionForIssue(issueId, key, suggestionId, tx);
if (!suggestion) throw notFound("Suggestion not found");
if (suggestion.status !== "pending") {
throw conflict("Only pending suggestions can be rejected", { status: suggestion.status });
}
const now = new Date();
const [updated] = await tx.update(documentSuggestions).set({
status: "rejected",
rejectedByAgentId: actor.agentId ?? null,
rejectedByUserId: actor.userId ?? null,
rejectedAt: now,
updatedAt: now,
}).where(eq(documentSuggestions.id, suggestion.id)).returning(suggestionSelect);
return updated;
}),
acceptSuggestion: async (
issueId: string,
key: string,
suggestionId: string,
input: AcceptDocumentSuggestion,
actor: ActorInput,
) => db.transaction(async (tx) => {
await tx.execute(sql`
select ${documents.id}
from ${issueDocuments}
inner join ${documents} on ${issueDocuments.documentId} = ${documents.id}
where ${and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))}
for update of ${documents}
`);
const doc = await getIssueDocument(issueId, key, tx);
if (!doc) throw notFound("Document not found");
const suggestion = await getSuggestionForIssue(issueId, key, suggestionId, tx);
if (!suggestion || suggestion.documentId !== doc.documentId) throw notFound("Suggestion not found");
if (suggestion.status !== "pending") {
throw conflict("Only pending suggestions can be accepted", { status: suggestion.status });
}
if (doc.latestRevisionId !== input.baseRevisionId) {
throw conflict("Document was updated by someone else", { currentRevisionId: doc.latestRevisionId });
}
if (suggestion.currentRevisionId !== doc.latestRevisionId || suggestion.currentRevisionNumber !== doc.latestRevisionNumber) {
throw conflict("Suggestion must be remapped to the current document revision before acceptance", {
currentRevisionId: doc.latestRevisionId,
currentRevisionNumber: doc.latestRevisionNumber,
suggestionRevisionId: suggestion.currentRevisionId,
suggestionRevisionNumber: suggestion.currentRevisionNumber,
});
}
const verification = verifyDocumentAnchorSelector({ markdown: doc.latestBody, selector: suggestion.anchorSelector });
if (!verification.ok) {
throw conflict("Suggestion anchor no longer matches the current document revision", { reason: verification.reason });
}
const now = new Date();
const nextBody = applySuggestionToBody(doc.latestBody, suggestion);
const nextRevisionNumber = doc.latestRevisionNumber + 1;
const [revision] = await tx.insert(documentRevisions).values({
companyId: doc.companyId,
documentId: doc.documentId,
revisionNumber: nextRevisionNumber,
title: doc.title,
format: doc.format,
body: nextBody,
changeSummary: input.changeSummary ?? `Accepted ${suggestion.kind} suggestion ${suggestion.id.slice(0, 8)}`,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
createdAt: now,
}).returning();
await tx.update(documents).set({
latestBody: nextBody,
latestRevisionId: revision.id,
latestRevisionNumber: nextRevisionNumber,
updatedByAgentId: actor.agentId ?? null,
updatedByUserId: actor.userId ?? null,
updatedAt: now,
}).where(eq(documents.id, doc.documentId));
await tx.update(issueDocuments).set({ updatedAt: now }).where(eq(issueDocuments.documentId, doc.documentId));
const [updatedSuggestion] = await tx.update(documentSuggestions).set({
status: "accepted",
acceptedByAgentId: actor.agentId ?? null,
acceptedByUserId: actor.userId ?? null,
acceptedAt: now,
acceptedRevisionId: revision.id,
updatedAt: now,
}).where(eq(documentSuggestions.id, suggestion.id)).returning(suggestionSelect);
return {
suggestion: updatedSuggestion,
document: {
id: doc.documentId,
companyId: doc.companyId,
issueId,
key: doc.documentKey,
title: doc.title,
format: doc.format,
body: nextBody,
latestRevisionId: revision.id,
latestRevisionNumber: nextRevisionNumber,
},
revision,
};
}),
remapOpenSuggestionsForDocument: async (input: {
issueId: string;
key: string;
documentId: string;
nextRevisionId: string | null;
nextRevisionNumber: number;
nextBody: string;
}) => db.transaction(async (tx) => {
const suggestions: DocumentSuggestion[] = await tx.select(suggestionSelect).from(documentSuggestions)
.where(and(
eq(documentSuggestions.issueId, input.issueId),
eq(documentSuggestions.documentId, input.documentId),
eq(documentSuggestions.status, "pending"),
));
const changed = [];
const now = new Date();
for (const suggestion of suggestions) {
if (suggestion.currentRevisionId === input.nextRevisionId) continue;
const previousAnchor = snapshotFromSuggestion(suggestion);
const remap = remapDocumentAnchor({
previousAnchor,
nextMarkdown: input.nextBody,
});
const nextAnchor = remap.anchor;
const nextSelector = nextAnchor ? anchorSnapshotToSelector(nextAnchor) : suggestion.anchorSelector;
const [updated] = await tx.update(documentSuggestions).set({
currentRevisionId: input.nextRevisionId,
currentRevisionNumber: input.nextRevisionNumber,
anchorState: remap.anchorState,
anchorConfidence: remap.confidence,
...(nextAnchor
? {
selectedText: nextAnchor.selectedText,
prefixText: nextAnchor.prefixText,
suffixText: nextAnchor.suffixText,
normalizedStart: nextAnchor.normalizedStart,
normalizedEnd: nextAnchor.normalizedEnd,
markdownStart: nextAnchor.markdownStart,
markdownEnd: nextAnchor.markdownEnd,
}
: {}),
anchorSelector: nextSelector,
updatedAt: now,
}).where(eq(documentSuggestions.id, suggestion.id)).returning(suggestionSelect);
const [snapshot] = await tx.insert(documentSuggestionAnchorSnapshots).values({
companyId: suggestion.companyId,
suggestionId: suggestion.id,
documentId: suggestion.documentId,
fromRevisionId: suggestion.currentRevisionId,
fromRevisionNumber: suggestion.currentRevisionNumber,
toRevisionId: input.nextRevisionId,
toRevisionNumber: input.nextRevisionNumber,
previousAnchor,
nextAnchor,
anchorState: remap.anchorState,
anchorConfidence: remap.confidence,
failureReason: remap.anchor ? null : remap.reason,
createdAt: now,
}).returning();
changed.push({ suggestion: updated, snapshot });
}
return changed;
}),
selectorToAnchorSnapshot,
};
}
+69 -2
View File
@@ -6,6 +6,8 @@ import {
documentAnnotationThreads,
documentLinks,
documentRevisions,
documentReviewThreads,
documentSuggestions,
documents,
goals,
heartbeatRuns,
@@ -223,6 +225,11 @@ function emptyFeedbackCounts(): DocumentFeedbackCounts {
return {
openComments: 0,
resolvedComments: 0,
openReviewThreads: 0,
resolvedReviewThreads: 0,
pendingSuggestions: 0,
acceptedSuggestions: 0,
rejectedSuggestions: 0,
staleAnchors: 0,
orphanedAnchors: 0,
};
@@ -258,12 +265,62 @@ export function documentService(db: Db) {
for (const row of rows) {
result.set(row.documentId, {
...emptyFeedbackCounts(),
openComments: row.openComments,
resolvedComments: row.resolvedComments,
staleAnchors: row.staleAnchors,
orphanedAnchors: row.orphanedAnchors,
});
}
const reviewRows = await db
.select({
documentId: documentReviewThreads.documentId,
openReviewThreads: sql<number>`count(*) filter (where ${documentReviewThreads.status} = 'open')`.mapWith(Number),
resolvedReviewThreads: sql<number>`count(*) filter (where ${documentReviewThreads.status} = 'resolved')`.mapWith(Number),
})
.from(documentReviewThreads)
.where(and(
eq(documentReviewThreads.companyId, companyId),
inArray(documentReviewThreads.documentId, documentIds),
))
.groupBy(documentReviewThreads.documentId);
for (const row of reviewRows) {
const existing = result.get(row.documentId) ?? emptyFeedbackCounts();
result.set(row.documentId, {
...existing,
openReviewThreads: row.openReviewThreads,
resolvedReviewThreads: row.resolvedReviewThreads,
});
}
const suggestionRows = await db
.select({
documentId: documentSuggestions.documentId,
pendingSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'pending')`.mapWith(Number),
acceptedSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'accepted')`.mapWith(Number),
rejectedSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'rejected')`.mapWith(Number),
staleAnchors: sql<number>`count(*) filter (where ${documentSuggestions.anchorState} = 'stale' and ${documentSuggestions.status} = 'pending')`.mapWith(Number),
orphanedAnchors: sql<number>`count(*) filter (where ${documentSuggestions.anchorState} = 'orphaned' and ${documentSuggestions.status} = 'pending')`.mapWith(Number),
})
.from(documentSuggestions)
.where(and(
eq(documentSuggestions.companyId, companyId),
inArray(documentSuggestions.documentId, documentIds),
))
.groupBy(documentSuggestions.documentId);
for (const row of suggestionRows) {
const existing = result.get(row.documentId) ?? emptyFeedbackCounts();
result.set(row.documentId, {
...existing,
pendingSuggestions: row.pendingSuggestions,
acceptedSuggestions: row.acceptedSuggestions,
rejectedSuggestions: row.rejectedSuggestions,
staleAnchors: existing.staleAnchors + row.staleAnchors,
orphanedAnchors: existing.orphanedAnchors + row.orphanedAnchors,
});
}
return result;
}
@@ -461,12 +518,22 @@ export function documentService(db: Db) {
)`);
}
if (query.hasOpenFeedback) {
conditions.push(sql`exists (
conditions.push(sql`(exists (
select 1 from ${documentAnnotationThreads}
where ${documentAnnotationThreads.companyId} = ${documents.companyId}
and ${documentAnnotationThreads.documentId} = ${documents.id}
and ${documentAnnotationThreads.status} = 'open'
)`);
) or exists (
select 1 from ${documentReviewThreads}
where ${documentReviewThreads.companyId} = ${documents.companyId}
and ${documentReviewThreads.documentId} = ${documents.id}
and ${documentReviewThreads.status} = 'open'
) or exists (
select 1 from ${documentSuggestions}
where ${documentSuggestions.companyId} = ${documents.companyId}
and ${documentSuggestions.documentId} = ${documents.id}
and ${documentSuggestions.status} = 'pending'
))`);
}
const rows = await db
+1
View File
@@ -8,6 +8,7 @@ export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } fr
export { assetService } from "./assets.js";
export { documentService, extractLegacyPlanBody } from "./documents.js";
export { documentAnnotationService } from "./document-annotations.js";
export { documentReviewService } from "./document-review.js";
export {
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
buildContinuationSummaryMarkdown,