Skip to content
Merged
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
15 changes: 11 additions & 4 deletions packages/helpdesk/CHARTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,15 @@ Ship a customer-support template where **AI is the product**, not an add-on. Mak
## Definition of "good enough" to ship v0.1
- [x] All 6 objects + state machines compile
- [x] Seed data exercises every dashboard widget
- [ ] Typecheck clean
- [ ] Build clean
- [x] Typecheck clean
- [x] Build clean (`objectstack build`)
- [ ] Dev server boots on :4006
- [ ] Agent Workbench renders metrics + tables with real data
- [ ] Ticket detail page renders AI fields visibly
- [x] Agent Workbench renders metrics + tables with real data
- [x] Ticket detail page renders AI fields visibly (set by the insert hook on every ticket)

## Runtime triage model (v0.1)
The `ai_*` baseline is populated **synchronously by `helpdesk_ticket.hook.ts`**
on insert — every ticket is numbered, SLA-dated, and AI-triaged (deterministic
stub) even with no LLM wired. `ai_triage_on_create.flow.ts` is the seam to
enrich that baseline with a real provider. (Earlier drafts referenced a
`helpdesk.aiTriageStub` function that was never defined; the hook replaces it.)
46 changes: 14 additions & 32 deletions packages/helpdesk/src/flows/ai_triage_on_create.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,18 @@ type Flow = Automation.Flow;
/**
* AI Triage On Create — runs when a ticket is created (status == "new").
*
* In this template the AI step is a **stub** that sets sensible defaults:
* - ai_category = "other"
* - ai_sentiment = "neutral"
* - ai_priority_suggestion = priority (echoed)
* - ai_language = customer.locale (or "en")
* - ai_summary = first 280 chars of description
* - ai_suggested_reply = templated acknowledgement
* - ai_suggested_kb_ids = []
* - ai_confidence = 0.5
* - ai_triage_at = now()
* The deterministic AI baseline (ai_summary / ai_category / ai_sentiment /
* ai_priority_suggestion / ai_language / ai_confidence / ai_triage_at) is set
* synchronously by `helpdesk_ticket.hook.ts` on insert, so every ticket has
* populated `ai_*` fields even with no LLM wired up. This flow is the seam for
* **richer** triage and routing on top of that baseline.
*
* To plug in a real LLM (OpenAI / Anthropic / Bedrock / Azure / 通义 / 文心):
* 1. Replace the `ai_triage` script node body with an HTTP call to your
* provider (use platform `services.http` or a custom hook).
* 2. Map provider output to the same fields — schema doesn't change.
* 3. Adjust the `criteria` / `set_status_triaged` thresholds for
* auto-triage vs human-triage routing (we use ai_confidence ≥ 0.6).
* 1. Add a `script` node before `set_status_triaged` that calls your provider
* (platform `services.http`) and updates the ticket with the response —
* same field shapes, so dashboards / automations / analytics don't change.
* 2. Gate `set_status_triaged` on a confidence threshold (e.g. ≥ 0.6) to
* route low-confidence tickets to a human triage queue instead.
*
* After triage, the flow advances status: new → triaged.
*/
Expand All @@ -46,20 +41,8 @@ export const AITriageOnCreateFlow: Flow = {
condition: 'status == "new"',
},
},
{
id: 'ai_triage',
type: 'script',
label: 'AI Triage (STUB — replace with LLM call)',
config: {
// Stub: defaults that look reasonable on a fresh seed.
// Production: call your LLM provider here, return structured JSON.
actionType: 'invoke_function',
functionName: 'helpdesk.aiTriageStub',
inputs: { ticketId: '{ticketId}' },
// The stub function (or your LLM wrapper) is expected to update
// the ticket record directly with ai_* fields.
},
},
// NOTE: the ai_* baseline is set by helpdesk_ticket.hook.ts on insert.
// Insert your LLM `script` node here (see the file header) to enrich it.
{
id: 'set_status_triaged',
type: 'update_record',
Expand All @@ -74,8 +57,7 @@ export const AITriageOnCreateFlow: Flow = {
],

edges: [
{ id: 'e1', source: 'start', target: 'ai_triage', type: 'default' },
{ id: 'e2', source: 'ai_triage', target: 'set_status_triaged', type: 'default' },
{ id: 'e3', source: 'set_status_triaged', target: 'end', type: 'default' },
{ id: 'e1', source: 'start', target: 'set_status_triaged', type: 'default' },
{ id: 'e2', source: 'set_status_triaged', target: 'end', type: 'default' },
],
};
6 changes: 4 additions & 2 deletions packages/helpdesk/src/objects/helpdesk_sla_policy.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { tmpl } from '@objectstack/spec';

/**
* SLA Policy — first-response and resolution targets in minutes, per priority.
* The `ai_triage_on_create` flow stamps `first_response_due_at` and
* `resolution_due_at` on the ticket using the policy attached to its tier.
* `helpdesk_ticket.hook.ts` stamps `first_response_due_at` and
* `resolution_due_at` on insert using these per-priority minutes (the hook
* ships the same defaults inline to stay payload-only; a fork can read the
* customer's tier-matched policy here instead).
*
* Keeping minutes-per-priority as scalar fields (not a JSON map) so they're
* filterable / chart-able. In a fork add business-hours calendar awareness.
Expand Down
79 changes: 73 additions & 6 deletions packages/helpdesk/src/objects/helpdesk_ticket.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,88 @@
import type { Hook, HookContext } from '@objectstack/spec/data';

/**
* Ticket automation hook — stamps `resolved_at` when a ticket reaches the
* resolved status without one. (Previously a dead object-level `workflow`;
* `workflows` is not a supported 7.x ObjectSchema field.)
* Ticket automation hook (payload-only — no nested writes).
*
* beforeInsert makes a freshly-created ticket immediately usable:
* - `ticket_number` — auto-assigned (TIC-YYYYMMDD-NNNN) so the required/
* unique field never blocks creation from the agent UI or the portal.
* - SLA due dates — `first_response_due_at` / `resolution_due_at` derived
* from priority using the same default minutes as `helpdesk_sla_policy`.
* (A fork can read the customer's actual policy; cross-object reads are a
* fork concern, so the defaults live here to keep the hook payload-only.)
* - AI triage baseline — the deterministic stub the charter promises: the
* `ai_*` fields are populated on every ticket so "AI is the product" is
* true at runtime, not just in seed data. Replace with a real LLM call in
* `ai_triage_on_create.flow.ts` for richer triage; the field shapes stay.
*
* beforeUpdate stamps `resolved_at` when a ticket reaches the resolved status.
*/

// First-response / resolution targets in minutes, mirroring the SLA policy
// defaults. Keyed by ticket priority.
const FIRST_RESPONSE_MIN: Record<string, number> = {
low: 1440,
normal: 480,
high: 120,
urgent: 30,
};
const RESOLUTION_MIN: Record<string, number> = {
low: 10080,
normal: 2880,
high: 1440,
urgent: 240,
};

const ticketHook: Hook = {
name: 'helpdesk_ticket_automation',
object: 'helpdesk_ticket',
events: ['beforeUpdate'],
events: ['beforeInsert', 'beforeUpdate'],
priority: 100,
description: 'Stamp resolved_at when a ticket is resolved.',
description: 'Auto-number, derive SLA due dates, seed AI triage baseline, stamp resolved_at.',
handler: async (ctx: HookContext) => {
const { input, previous } = ctx as HookContext & {
const { event, input, previous } = ctx as HookContext & {
input: Record<string, unknown>;
previous?: Record<string, unknown>;
};

if (event === 'beforeInsert') {
if (!input.ticket_number) {
const ts = new Date();
const date = `${ts.getUTCFullYear()}${String(ts.getUTCMonth() + 1).padStart(2, '0')}${String(
ts.getUTCDate(),
).padStart(2, '0')}`;
const rand = Math.floor(Math.random() * 9000 + 1000);
input.ticket_number = `TIC-${date}-${rand}`;
}

const priority = (input.priority as string) || 'normal';
const now = Date.now();
if (input.first_response_due_at == null) {
const mins = FIRST_RESPONSE_MIN[priority] ?? FIRST_RESPONSE_MIN.normal;
input.first_response_due_at = new Date(now + mins * 60000).toISOString();
}
if (input.resolution_due_at == null) {
const mins = RESOLUTION_MIN[priority] ?? RESOLUTION_MIN.normal;
input.resolution_due_at = new Date(now + mins * 60000).toISOString();
}

// AI triage baseline (deterministic stub — replace with an LLM in the flow).
if (input.ai_triage_at == null) {
if (input.ai_summary == null) {
input.ai_summary = String(input.description ?? '').slice(0, 280);
}
if (input.ai_category == null) input.ai_category = 'other';
if (input.ai_sentiment == null) input.ai_sentiment = 'neutral';
if (input.ai_priority_suggestion == null) input.ai_priority_suggestion = priority;
if (input.ai_language == null) input.ai_language = 'en';
if (input.ai_confidence == null) input.ai_confidence = 0.5;
if (input.ai_suggested_kb_ids == null) input.ai_suggested_kb_ids = [];
input.ai_triage_at = new Date().toISOString();
}
return;
}

// beforeUpdate — stamp resolved_at on resolution.
if (!previous) return;
const status = (input.status ?? previous.status) as string;
const resolvedAt = input.resolved_at ?? previous.resolved_at;
Expand Down
4 changes: 2 additions & 2 deletions packages/helpdesk/src/portals/customer.portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export const HelpdeskCustomerPortal = definePortal({
},
{
path: '/kb',
viewRef: 'helpdesk_kb_article.published_list',
viewRef: 'helpdesk_kb_article.published_articles',
rateLimit: { rule: '100/hour/ip', scope: 'ip' },
},
],
Expand Down Expand Up @@ -103,7 +103,7 @@ export const HelpdeskCustomerPortal = definePortal({
label: 'Knowledge Base',
icon: 'book-open',
order: 3,
viewRef: 'helpdesk_kb_article.published_list',
viewRef: 'helpdesk_kb_article.published_articles',
},
],

Expand Down
4 changes: 4 additions & 0 deletions packages/helpdesk/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export const en: TranslationData = {
},
angry_tickets: { label: 'Angry Customers', description: 'AI detected angry sentiment' },
my_queue: { label: 'My Queue', description: 'Tickets assigned to me' },
my_tickets: {
label: 'My Tickets',
description: 'Portal view of the customer’s own tickets',
},
},
},
helpdesk_customer: {
Expand Down
1 change: 1 addition & 0 deletions packages/helpdesk/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const zhCN: TranslationData = {
breaching_tickets: { label: 'SLA 超时', description: '已超过解决截止时间' },
angry_tickets: { label: '愤怒客户', description: 'AI 识别为愤怒情感' },
my_queue: { label: '我的队列', description: '分配给我的工单' },
my_tickets: { label: '我的工单', description: '门户中客户本人的工单' },
},
},
helpdesk_customer: {
Expand Down
13 changes: 13 additions & 0 deletions packages/helpdesk/src/views/helpdesk_ticket.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,19 @@ export const TicketViews = defineView({
],
sort: [{ field: 'priority', order: 'desc' }],
},

// Customer-facing list used by the help-center portal. The magic-link bind
// attributes a portal user to their `customer` record; the helpdesk_customer
// profile (viewAllRecords: false) further scopes rows to the user's own.
my_tickets: {
name: 'my_tickets',
type: 'grid',
label: 'My Tickets',
data: { provider: 'object', object: 'helpdesk_ticket' },
columns: ['ticket_number', 'name', 'status', 'priority', 'first_response_due_at'],
filter: [{ field: 'customer', operator: 'equals', value: '{currentUser}' }],
sort: [{ field: 'status', order: 'asc' }],
},
},

form: {
Expand Down
Loading