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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions docs/guides/ai-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions prisma/migrations/z_12_ai_support/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "TicketMessage"
ADD COLUMN "isAiGenerated" BOOLEAN NOT NULL DEFAULT false;
17 changes: 9 additions & 8 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions src/app/admin/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
],
},
{
Expand Down
17 changes: 12 additions & 5 deletions src/components/ticket-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,28 @@ export async function TicketThread({
<div className="space-y-6">
<div className="space-y-4">
{ticket.messages.map((message) => {
const isStaff = Boolean(message.user.roleId);
const isViewer = message.userId === viewerId;
const isAi = message.isAiGenerated;
const isStaff = Boolean(message.user.roleId) || isAi;
const isViewer = message.userId === viewerId && !isAi;
return (
<div
key={message.id}
className={`card p-5 ${isStaff ? "border-brand-200 bg-brand-50/50" : ""}`}
>
<div className="mb-2 flex items-center justify-between text-sm">
<p className="font-medium">
{message.user.firstName} {message.user.lastName}
{isStaff && (
{isAi
? t("ticket.aiSupport")
: `${message.user.firstName} ${message.user.lastName}`}
{isAi ? (
<span className="badge ml-2 bg-brand-100 text-brand-700">
{t("ticket.automated")}
</span>
) : isStaff ? (
<span className="badge ml-2 bg-brand-100 text-brand-700">
{t("ticket.staff")}
</span>
)}
) : null}
{isViewer && !isStaff && (
<span className="ml-2 text-xs text-slate-400">{t("ticket.you")}</span>
)}
Expand Down
19 changes: 19 additions & 0 deletions src/lib/actions/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand Down
70 changes: 70 additions & 0 deletions src/lib/ai-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
47 changes: 47 additions & 0 deletions src/lib/ai-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export type GroundedAutoResolution = {
answer: string;
confidence: number;
sourceArticleIds: string[];
};

export function parseGroundedAutoResolution(
value: unknown,
minimumConfidence: number,
publishedArticleIds: ReadonlySet<string>,
): GroundedAutoResolution | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Record<string, unknown>;
if (candidate.requiresHuman !== false) return null;
if (
typeof candidate.confidence !== "number" ||
!Number.isFinite(candidate.confidence) ||
candidate.confidence < minimumConfidence ||
candidate.confidence > 1
) {
return null;
}
if (typeof candidate.answer !== "string") return null;
const answer = candidate.answer.trim();
if (answer.length < 20 || answer.length > 4_000) return null;
if (!Array.isArray(candidate.sourceArticleIds)) return null;
const sourceArticleIds = [
...new Set(
candidate.sourceArticleIds.filter(
(id): id is string =>
typeof id === "string" && publishedArticleIds.has(id),
),
),
];
if (
sourceArticleIds.length === 0 ||
sourceArticleIds.length !== candidate.sourceArticleIds.length
) {
return null;
}

return {
answer,
confidence: candidate.confidence,
sourceArticleIds,
};
}
2 changes: 2 additions & 0 deletions src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ const en = {
"ticket.closed": "This ticket is closed.",
"ticket.attachments": "Attachments (up to 3 files, 5 MB each)",
"ticket.staff": "Staff",
"ticket.aiSupport": "AI support",
"ticket.automated": "Automated",
"ticket.you": "(you)",

"account.title": "Account settings",
Expand Down
Loading
Loading