Skip to content
Open
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
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,74 @@ Creates a reusable engine instance with shared config.

## CLI Preview

Use the CLI to preview title generation with different inputs and platform presets.

### Flags

| Flag | Description | Default |
| --- | --- | --- |
| `--summary` | The support message text | _(interactive prompt)_ |
| `--customer` | Customer or company name | `Unknown Company` |
| `--platform` | Platform preset (`telegram`, `whatsapp`, `discord`) | `telegram` |

When `--summary` is omitted the CLI starts an interactive prompt.

### Examples

**Telegram — login issue**

```bash
bun run preview --summary "I need help with my account login, I can't login for some reason" --customer "Acme Corp" --platform telegram
# Title preview:
# [Telegram] Can't login
```

**WhatsApp — password reset**

```bash
bun run preview --summary "A subset of users cannot complete password reset because reset links intermittently return token expired." --customer "Acme Corp" --platform whatsapp
# Title preview:
# [WhatsApp] Cannot complete password reset
```

**Discord — OTP delivery failure**

```bash
bun run preview --summary "we enabled 2FA for security, but OTP emails are not arriving for two users in our workspace, and both are now locked out of the platform." --customer "TechStart Inc" --platform discord
# Title preview:
# [Discord] OTP emails are not arriving for two users in our workspace
```

**Telegram — noisy message with buried issue**

```bash
bun run preview --summary "Hey there! Hope you're doing well. I just wanted to reach out because our team is unable to upload files larger than 10MB. The upload just times out after a few minutes." --customer "MediaGroup" --platform telegram
# Title preview:
# [Telegram] Unable to upload files larger than 10MB
```

**Discord — verbose bug report**

```bash
bun run preview --summary "I'm experiencing a critical bug where the app crashes whenever I open the settings page on mobile" --customer "MobileFirst" --platform discord
# Title preview:
# [Discord] The app crashes whenever I open the settings page on mobile
```

**WhatsApp — generic help (fallback)**

```bash
bun run preview --summary "help" --customer "Acme Corp" --platform whatsapp
# Title preview:
# [WhatsApp] Acme Corp - Support Request
```

**Telegram — unknown customer (fallback)**

```bash
bun run preview --summary "help" --customer "Unknown" --platform telegram
# Title preview:
# [Telegram] New Support Ticket
```

## Scripts
Expand Down
6 changes: 1 addition & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,7 @@
"bin": {
"summary-engine": "./dist/cli.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"files": ["dist", "README.md", "LICENSE"],
"engines": {
"node": "^22.0.0 || ^24.0.0 || ^26.0.0",
"bun": ">=1.0.0"
Expand Down
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import { buildTitle, extractSummary } from "./engine.js";
import { discordPreset, telegramPreset, whatsappPreset } from "./presets.js";
import type { SummaryEngineConfig } from "./types.js";
Expand Down Expand Up @@ -57,7 +57,8 @@ async function main(): Promise<void> {
const rl = createInterface({ input, output });
const summary = await rl.question("Issue details: ");
const customer = (await rl.question("Customer name (optional): ")).trim() || customerArg;
const platform = (await rl.question("Platform (telegram|whatsapp|discord): ")).trim() || platformArg;
const platform =
(await rl.question("Platform (telegram|whatsapp|discord): ")).trim() || platformArg;
rl.close();

const interactivePreset = resolvePreset(platform);
Expand Down
81 changes: 72 additions & 9 deletions src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const defaultConfig: SummaryEngineConfig = {
"need support",
"please help",
"can you help",
"assistance"
"assistance",
],
issueKeywords: [
"login",
Expand All @@ -42,7 +42,38 @@ export const defaultConfig: SummaryEngineConfig = {
"timeout",
"locked",
"access",
"account"
"account",
"crash",
"broken",
"upload",
"download",
"webhook",
"api",
"integration",
"notification",
"email",
"sso",
"mfa",
"display",
"slow",
"delayed",
"subscription",
"charge",
"refund",
"502",
"403",
"500",
"503",
"404",
"connection",
"database",
"query",
"queries",
"stale",
"missing",
"empty",
"failing",
"expired",
],
rootCausePatterns: [
/\bnot\s+arriv(ing|ed)?\b/i,
Expand All @@ -55,37 +86,69 @@ export const defaultConfig: SummaryEngineConfig = {
/\bfailed\b/i,
/\berror\b/i,
/\btimeout\b/i,
/\binvalid\b/i
/\binvalid\b/i,
/\bnot\s+work(ing|ed|s)?\b/i,
/\bnot\s+render(ing|ed|s)?\b/i,
/\bnot\s+load(ing|ed|s)?\b/i,
/\bnot\s+show(ing|n)?\b/i,
/\bnot\s+display(ing|ed)?\b/i,
/\bnot\s+send(ing)?\b/i,
/\b(time[sd]?|timing)\s+out\b/i,
/\bcrash(es|ed|ing)?\b/i,
/\bbroken\b/i,
/\bincorrect(ly)?\b/i,
/\b(returns?|returning)\s+(no\s+results?|empty|null|404|403|500|502|503)\b/i,
/\bfailing\b/i,
],
impactOnlyPatterns: [
/\blocked\s+out\b/i,
/\bblocked\b/i,
/\bcannot\s+access\b/i,
/\bunable\s+to\s+access\b/i,
/\bfor\s+urgent\s+tasks\b/i,
/\bbusiness\s+impact\b/i
/\bbusiness\s+impact\b/i,
],
contextOnlyPatterns: [
/^we\s+enabled\b/i,
/^we\s+updated\b/i,
/^for\s+security\b/i,
/^after\s+/i,
/^hope\s+you.re\s+doing\s+well\b/i,
/^we.ve\s+been\s+using\b/i,
/^i\s+love\b/i,
/^great\s+product\b/i,
/^this\s+is\s+\w+\s+from\b/i,
],
contextOnlyPatterns: [/^we\s+enabled\b/i, /^we\s+updated\b/i, /^for\s+security\b/i, /^after\s+/i],
leadingFillerPatterns: [
/^(hi|hello|hey)(\s+(team|support|there))?[\s,.:!\-]*/i,
/^(please|pls)\s+/i,
/^(i have an issue with|i have an issue|i have problem with|i have a problem with)\s+/i,
/^(i need help with|i need help|need help with|need help)\s+/i,
/^(issue[:\-]\s*)/i,
/^(problem[:\-]\s*)/i
/^(problem[:\-]\s*)/i,
/^(i\s+just\s+wanted\s+to\s+(reach\s+out|let\s+you\s+know|report|inform\s+you)(\s+(because|that))?)\s*/i,
/^(just\s+wanted\s+to\s+(reach\s+out|let\s+you\s+know|report)(\s+that)?)\s*/i,
/^(i\s+wanted\s+to\s+(report|let\s+you\s+know|reach\s+out|inform\s+you)(\s+that)?)\s*/i,
/^(i('m|\s+am)\s+experiencing)\s+/i,
/^(we('re|\s+are)\s+experiencing)\s+/i,
/^(we('re|\s+are)\s+facing(\s+issues?\s+with)?)\s+/i,
/^(i('m|\s+am)\s+having\s+(trouble|issues?|problems?)\s+with)\s+/i,
/^(we\s+noticed\s+that)\s+/i,
/^(a\s+)?(critical\s+|major\s+|minor\s+|serious\s+)?(bug|issue|problem|error)\s+(where|that)\s+/i,
/^(this\s+is\s+ridiculous|this\s+is\s+unacceptable)[.!,\s]*/i,
],
trailingFillerPatterns: [
/[\s,.-]*(please|pls)\s+help(\s+me)?[\s,.-]*$/i,
/[\s,.-]*(can\s+you\s+help(\s+me)?)[\s,.-]*$/i,
/[\s,.-]*(need\s+help|support\s+please)[\s,.-]*$/i
/[\s,.-]*(need\s+help|support\s+please)[\s,.-]*$/i,
],
conjunctionStripPatterns: [/^\s*(but|and|also)\s+/i],
causalSplitPattern: /\b(because|since|as|while|even though|although|so that)\b/i
causalSplitPattern: /\b(because|since|as|while|even though|although|so that)\b/i,
};

export function withPrefix(config: SummaryEngineConfig, prefix: string): SummaryEngineConfig {
return {
...config,
prefix
prefix,
};
}
49 changes: 37 additions & 12 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,13 @@ function compactSummary(input: string, config: SummaryEngineConfig): string {
let text = input.trim();

text = text
.replace(/^\s*(a|an)\s+subset\s+of\s+users\s+(are|were|have|had)\s+/i, "")
.replace(/^\s*(a|an)\s+subset\s+of\s+users\s+/i, "")
.replace(/^\s*(some|many|several)\s+users\s+(are|were|have|had)\s+/i, "")
.replace(/^\s*(some|many|several)\s+users\s+/i, "")
.replace(/^\s*users\s+/i, "");
.replace(/^\s*users\s+(are|were|have|had)\s+/i, "")
.replace(/^\s*users\s+/i, "")
.replace(/^\s*our\s+team\s+(is|are|was|were|has|have)\s+/i, "");

const causalSplit = text.split(config.causalSplitPattern);
if (causalSplit[0]) {
Expand All @@ -86,7 +90,15 @@ function scoreClause(input: string, config: SummaryEngineConfig): number {
}
}

if (/\b(cannot|can't|unable|failed|error|timeout|locked)\b/.test(lowered)) {
if (
/\b(cannot|can't|unable|fail(ed|ing|s)?|error|timeout|locked|crash(es|ed|ing)?|broken|invalid|incorrect)\b/.test(
lowered,
)
) {
score += 3;
}

if (/\b(time[sd]?|timing)\s+out\b/.test(lowered)) {
score += 3;
}

Expand Down Expand Up @@ -133,7 +145,7 @@ function extractSummaryInternal(summary: string, config: SummaryEngineConfig): s

for (const segment of segments) {
const clauses = segment
.split(/[,|]+|\s+-\s+|\s+and\s+/i)
.split(/[,|]+|\s+-\s+|\s+(?:and|but)\s+/i)
.map((clause) => cleanClause(clause, config))
.filter(Boolean);

Expand All @@ -142,6 +154,19 @@ function extractSummaryInternal(summary: string, config: SummaryEngineConfig): s
} else {
candidates.push(cleanClause(segment, config));
}

const causalParts = segment.split(config.causalSplitPattern);
if (causalParts.length >= 3) {
for (let i = 2; i < causalParts.length; i += 2) {
const tail = causalParts[i]?.trim();
if (tail) {
const cleaned = cleanClause(stripLeadingFiller(tail, config), config);
if (cleaned) {
candidates.push(cleaned);
}
}
}
}
}

const filtered = candidates.filter((candidate) => {
Expand All @@ -156,7 +181,7 @@ function extractSummaryInternal(summary: string, config: SummaryEngineConfig): s
const best = filtered
.map((candidate) => ({
candidate,
score: scoreClause(candidate, config)
score: scoreClause(candidate, config),
}))
.sort((a, b) => b.score - a.score)[0]?.candidate;

Expand All @@ -180,7 +205,7 @@ function normalizeCustomer(customerName: string | undefined): string {
export function createEngine(overrides: Partial<SummaryEngineConfig> = {}): SummaryEngine {
const config: SummaryEngineConfig = {
...defaultConfig,
...overrides
...overrides,
};

return {
Expand All @@ -201,22 +226,22 @@ export function createEngine(overrides: Partial<SummaryEngineConfig> = {}): Summ
if (normalizedCustomer && !isUnknown) {
return truncateAtWordBoundary(
`${config.prefix} ${normalizeText(input.customerName ?? "")} - ${config.fallbackSuffix}`,
config.maxTitleLength
config.maxTitleLength,
);
}

return `${config.prefix} ${config.fallbackNewTicket}`;
}
},
};
}

export function extractSummary(summary: string, config?: Partial<SummaryEngineConfig>): string | null {
export function extractSummary(
summary: string,
config?: Partial<SummaryEngineConfig>,
): string | null {
return createEngine(config).extractSummary(summary);
}

export function buildTitle(
input: BuildTitleInput,
config?: Partial<SummaryEngineConfig>
): string {
export function buildTitle(input: BuildTitleInput, config?: Partial<SummaryEngineConfig>): string {
return createEngine(config).buildTitle(input);
}
14 changes: 7 additions & 7 deletions tests/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe("summary extraction", () => {
"we enabled 2FA for security, but OTP emails are not arriving for two users in our workspace, and both are now locked out of the platform.";

expect(extractSummary(summary, telegramPreset)).toBe(
"OTP emails are not arriving for two users in our workspace"
"OTP emails are not arriving for two users in our workspace",
);
});

Expand All @@ -26,9 +26,9 @@ describe("title building", () => {
{
summary:
"I need help with my account login, I can't login for some reason, please help me.",
customerName: "Acme Corp"
customerName: "Acme Corp",
},
telegramPreset
telegramPreset,
);

expect(title).toBe("[Telegram] Can't login");
Expand All @@ -38,9 +38,9 @@ describe("title building", () => {
const title = buildTitle(
{
summary: "help",
customerName: "Acme Corp"
customerName: "Acme Corp",
},
whatsappPreset
whatsappPreset,
);

expect(title).toBe("[WhatsApp] Acme Corp - Support Request");
Expand All @@ -50,9 +50,9 @@ describe("title building", () => {
const title = buildTitle(
{
summary: "help",
customerName: "Unknown Company"
customerName: "Unknown Company",
},
telegramPreset
telegramPreset,
);

expect(title).toBe("[Telegram] New Support Ticket");
Expand Down
Loading