diff --git a/docs/guides/ai-support.md b/docs/guides/ai-support.md index 7e00642..6d3a065 100644 --- a/docs/guides/ai-support.md +++ b/docs/guides/ai-support.md @@ -2,8 +2,9 @@ OpenHosting can draft ticket replies for your staff and classify incoming tickets, using **your own** API key. Nothing is sent to a model provider unless -you enable a provider and switch a feature on, and nothing an AI writes reaches -a customer without a person pressing send. +you enable a provider and switch a feature on. Reply drafts remain staff-reviewed; +the separate auto-resolution feature is an explicit opt-in with stricter source, +priority, and confidence gates. ## Setting it up @@ -25,6 +26,8 @@ a customer without a person pressing send. | Draft sign-off | — | Appended verbatim, e.g. `— The support team` | | Classify new tickets | off | Sets department and priority when a ticket is created | | Minimum triage confidence | 0.7 | Below this the customer's own choices are kept | +| Auto-resolve grounded tier-1 tickets | off | Posts and closes only a non-urgent answer grounded in published articles | +| Minimum auto-resolve confidence | 0.92 | Below this the ticket remains open for staff | ## Reply drafts @@ -66,11 +69,27 @@ Two deliberate limits: support form that fails because an AI is unavailable would be worse than no classification at all. +## Automatic tier-1 resolution + +Auto-resolution is off by default and is independent from reply drafts and +triage. It only runs for a newly opened ticket that is not high priority. The +model must return a structured answer above the configured confidence threshold, +mark the request as not requiring a person, and cite one or more article IDs +that still exist in the published knowledgebase. Invented, unpublished, or +missing citations reject the answer. + +Account changes, refunds, disputes, outages, security/abuse reports, uncertain +diagnoses, and any request needing an external action must stay with staff. A +qualifying answer is posted with an **Automated** badge, the ticket is closed in +the same database transaction, and `ticket.auto_resolved` records its confidence +and source article IDs in the audit log. If a staff/customer update races the +model response, the optimistic write fails and no automated message is posted. + ## Costs and privacy - You are billed by your provider for the tokens each feature uses. A reply - draft sends your published knowledgebase plus the ticket thread; a - classification sends the subject and first message only. + draft and auto-resolution send published knowledgebase content plus relevant + ticket text; classification sends the subject and first message. - Ticket content and knowledgebase articles are sent to the provider you configured, when a feature runs. Customer passwords, payment details and API keys are never part of a prompt. diff --git a/prisma/migrations/z_12_ai_support/migration.sql b/prisma/migrations/z_12_ai_support/migration.sql new file mode 100644 index 0000000..07e4ffa --- /dev/null +++ b/prisma/migrations/z_12_ai_support/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "TicketMessage" +ADD COLUMN "isAiGenerated" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1b8a5f2..3299597 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -583,14 +583,15 @@ model Ticket { } model TicketMessage { - id String @id @default(cuid()) - ticketId String - ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - message String - attachments TicketAttachment[] - createdAt DateTime @default(now()) + id String @id @default(cuid()) + ticketId String + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + message String + isAiGenerated Boolean @default(false) + attachments TicketAttachment[] + createdAt DateTime @default(now()) } // Database storage remains the default; S3-compatible storage keeps only a key. diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 08d7477..3de1c3c 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -105,6 +105,18 @@ const SECTIONS: Array<{ type: "text", help: "Below this the customer's own choices are kept.", }, + { + key: "ai_auto_resolve", + label: "Auto-resolve grounded tier-1 tickets", + type: "checkbox", + help: "Opt-in. Only closes non-urgent new tickets when the answer cites published knowledgebase articles above the confidence threshold.", + }, + { + key: "ai_auto_resolve_min_confidence", + label: "Minimum auto-resolve confidence (0-1)", + type: "text", + help: "Defaults to 0.92. Lower thresholds increase the risk of an incorrect automatic answer.", + }, ], }, { diff --git a/src/components/ticket-thread.tsx b/src/components/ticket-thread.tsx index 7d456dd..9a2f181 100644 --- a/src/components/ticket-thread.tsx +++ b/src/components/ticket-thread.tsx @@ -29,8 +29,9 @@ export async function TicketThread({
- {message.user.firstName} {message.user.lastName}
- {isStaff && (
+ {isAi
+ ? t("ticket.aiSupport")
+ : `${message.user.firstName} ${message.user.lastName}`}
+ {isAi ? (
+
+ {t("ticket.automated")}
+
+ ) : isStaff ? (
{t("ticket.staff")}
- )}
+ ) : null}
{isViewer && !isStaff && (
{t("ticket.you")}
)}
diff --git a/src/lib/actions/client.ts b/src/lib/actions/client.ts
index de1fdfa..cc8717a 100644
--- a/src/lib/actions/client.ts
+++ b/src/lib/actions/client.ts
@@ -251,6 +251,25 @@ export async function createTicket(
} catch {
// classification is best-effort
}
+ // Optional tier-1 resolution is separately gated by published sources and a
+ // high confidence threshold. Provider failure never blocks ticket creation.
+ try {
+ const { autoResolveTicket } = await import("@/lib/services/ai");
+ const resolution = await autoResolveTicket(ticket.id);
+ if (resolution) {
+ await audit("ticket.auto_resolved", {
+ userId: user.id,
+ targetType: "ticket",
+ targetId: ticket.id,
+ metadata: {
+ confidence: resolution.confidence,
+ sourceArticleIds: resolution.sourceArticleIds,
+ },
+ });
+ }
+ } catch {
+ // automatic resolution is best-effort; the open ticket remains available
+ }
redirect(`/dashboard/tickets/${ticket.id}`);
}
diff --git a/src/lib/ai-policy.test.ts b/src/lib/ai-policy.test.ts
new file mode 100644
index 0000000..008fc38
--- /dev/null
+++ b/src/lib/ai-policy.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, test } from "vitest";
+import { parseGroundedAutoResolution } from "@/lib/ai-policy";
+
+const published = new Set(["kb-1", "kb-2"]);
+
+describe("AI auto-resolution policy", () => {
+ test("accepts a high-confidence answer grounded in published articles", () => {
+ expect(
+ parseGroundedAutoResolution(
+ {
+ answer: "Follow the documented reset steps, then sign in again.",
+ confidence: 0.96,
+ requiresHuman: false,
+ sourceArticleIds: ["kb-1"],
+ },
+ 0.92,
+ published,
+ ),
+ ).toEqual({
+ answer: "Follow the documented reset steps, then sign in again.",
+ confidence: 0.96,
+ sourceArticleIds: ["kb-1"],
+ });
+ });
+
+ test("rejects escalation, low confidence, and uncited answers", () => {
+ const base = {
+ answer: "This answer is long enough to pass the content length check.",
+ confidence: 0.96,
+ requiresHuman: false,
+ sourceArticleIds: ["kb-1"],
+ };
+ expect(
+ parseGroundedAutoResolution(
+ { ...base, requiresHuman: true },
+ 0.92,
+ published,
+ ),
+ ).toBeNull();
+ expect(
+ parseGroundedAutoResolution(
+ { ...base, confidence: 0.91 },
+ 0.92,
+ published,
+ ),
+ ).toBeNull();
+ expect(
+ parseGroundedAutoResolution(
+ { ...base, sourceArticleIds: [] },
+ 0.92,
+ published,
+ ),
+ ).toBeNull();
+ });
+
+ test("rejects references to unpublished or invented articles", () => {
+ expect(
+ parseGroundedAutoResolution(
+ {
+ answer: "This answer is long enough but cites an unknown source.",
+ confidence: 0.99,
+ requiresHuman: false,
+ sourceArticleIds: ["kb-missing"],
+ },
+ 0.92,
+ published,
+ ),
+ ).toBeNull();
+ });
+});
diff --git a/src/lib/ai-policy.ts b/src/lib/ai-policy.ts
new file mode 100644
index 0000000..eccc6bc
--- /dev/null
+++ b/src/lib/ai-policy.ts
@@ -0,0 +1,47 @@
+export type GroundedAutoResolution = {
+ answer: string;
+ confidence: number;
+ sourceArticleIds: string[];
+};
+
+export function parseGroundedAutoResolution(
+ value: unknown,
+ minimumConfidence: number,
+ publishedArticleIds: ReadonlySet