diff --git a/packages/helpdesk/CHARTER.md b/packages/helpdesk/CHARTER.md index 224ced8..5003342 100644 --- a/packages/helpdesk/CHARTER.md +++ b/packages/helpdesk/CHARTER.md @@ -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.) diff --git a/packages/helpdesk/src/flows/ai_triage_on_create.flow.ts b/packages/helpdesk/src/flows/ai_triage_on_create.flow.ts index 0ff3db2..87b9833 100644 --- a/packages/helpdesk/src/flows/ai_triage_on_create.flow.ts +++ b/packages/helpdesk/src/flows/ai_triage_on_create.flow.ts @@ -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. */ @@ -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', @@ -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' }, ], }; diff --git a/packages/helpdesk/src/objects/helpdesk_sla_policy.object.ts b/packages/helpdesk/src/objects/helpdesk_sla_policy.object.ts index d9c06be..838da70 100644 --- a/packages/helpdesk/src/objects/helpdesk_sla_policy.object.ts +++ b/packages/helpdesk/src/objects/helpdesk_sla_policy.object.ts @@ -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. diff --git a/packages/helpdesk/src/objects/helpdesk_ticket.hook.ts b/packages/helpdesk/src/objects/helpdesk_ticket.hook.ts index 5d2bed9..e68e62e 100644 --- a/packages/helpdesk/src/objects/helpdesk_ticket.hook.ts +++ b/packages/helpdesk/src/objects/helpdesk_ticket.hook.ts @@ -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 = { + low: 1440, + normal: 480, + high: 120, + urgent: 30, +}; +const RESOLUTION_MIN: Record = { + 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; previous?: Record; }; + + 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; diff --git a/packages/helpdesk/src/portals/customer.portal.ts b/packages/helpdesk/src/portals/customer.portal.ts index ef62d8d..7e99efa 100644 --- a/packages/helpdesk/src/portals/customer.portal.ts +++ b/packages/helpdesk/src/portals/customer.portal.ts @@ -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' }, }, ], @@ -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', }, ], diff --git a/packages/helpdesk/src/translations/en.ts b/packages/helpdesk/src/translations/en.ts index c5d26e4..865faa2 100644 --- a/packages/helpdesk/src/translations/en.ts +++ b/packages/helpdesk/src/translations/en.ts @@ -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: { diff --git a/packages/helpdesk/src/translations/zh-CN.ts b/packages/helpdesk/src/translations/zh-CN.ts index ecb2c5b..0dc759e 100644 --- a/packages/helpdesk/src/translations/zh-CN.ts +++ b/packages/helpdesk/src/translations/zh-CN.ts @@ -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: { diff --git a/packages/helpdesk/src/views/helpdesk_ticket.view.ts b/packages/helpdesk/src/views/helpdesk_ticket.view.ts index 07577a6..07a8a73 100644 --- a/packages/helpdesk/src/views/helpdesk_ticket.view.ts +++ b/packages/helpdesk/src/views/helpdesk_ticket.view.ts @@ -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: {