feat(KB-621): add AI title summarization feature
- Add core AI summarization service with model selection hierarchy - Modify task store to support title generation callback pattern - Add POST /api/ai/summarize-title endpoint with rate limiting - Wire up summarization in dashboard task creation flow - Add AI Summarization settings UI section in dashboard - Add comprehensive tests for summarization service and API - Document auto-summarization settings in AGENTS.md
This commit is contained in:
5
.changeset/ai-title-summarization.md
Normal file
5
.changeset/ai-title-summarization.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add AI-powered title summarization feature. When enabled via settings, tasks created without titles but with descriptions longer than 140 characters will automatically receive an AI-generated title (max 60 characters). Includes configurable model selection for the summarization with fallback to planning and default models.
|
||||||
39
AGENTS.md
39
AGENTS.md
@@ -393,6 +393,45 @@ Directory for backup files, relative to the project root. The directory is creat
|
|||||||
- Must be a relative path (no leading `/` or `\`)
|
- Must be a relative path (no leading `/` or `\`)
|
||||||
- Must not contain parent directory traversal (`..`)
|
- Must not contain parent directory traversal (`..`)
|
||||||
|
|
||||||
|
### `autoSummarizeTitles` (default: `false`)
|
||||||
|
|
||||||
|
When enabled, tasks created without titles but with descriptions longer than 140 characters will automatically receive an AI-generated title (max 60 characters).
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- When a task is created without a title and the description exceeds 140 characters, the system calls the AI summarization service
|
||||||
|
- The AI generates a concise title (≤60 characters) that captures the essence of the task
|
||||||
|
- The generated title is stored in the task and appears in the PROMPT.md heading
|
||||||
|
- If the AI service is unavailable or returns an error, the task is still created without a title (no blocking)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"autoSummarizeTitles": true,
|
||||||
|
"titleSummarizerProvider": "anthropic",
|
||||||
|
"titleSummarizerModelId": "claude-sonnet-4-5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `titleSummarizerProvider` (optional)
|
||||||
|
|
||||||
|
AI model provider for title summarization when `autoSummarizeTitles` is enabled. Must be set together with `titleSummarizerModelId`.
|
||||||
|
|
||||||
|
**Model selection hierarchy:**
|
||||||
|
When generating titles, the system uses the first available model from this priority list:
|
||||||
|
1. `titleSummarizerProvider` + `titleSummarizerModelId` (if both configured)
|
||||||
|
2. `planningProvider` + `planningModelId` (if both configured)
|
||||||
|
3. `defaultProvider` + `defaultModelId` (if both configured)
|
||||||
|
4. Automatic model resolution (fallback)
|
||||||
|
|
||||||
|
### `titleSummarizerModelId` (optional)
|
||||||
|
|
||||||
|
AI model ID for title summarization when `autoSummarizeTitles` is enabled. Must be set together with `titleSummarizerProvider`.
|
||||||
|
|
||||||
|
**Rate limiting:**
|
||||||
|
The `/api/ai/summarize-title` endpoint is rate-limited to 10 requests per hour per IP to prevent abuse.
|
||||||
|
|
||||||
### CLI Commands
|
### CLI Commands
|
||||||
|
|
||||||
Manual backup operations are available via the CLI:
|
Manual backup operations are available via the CLI:
|
||||||
|
|||||||
209
packages/core/src/ai-summarize.test.ts
Normal file
209
packages/core/src/ai-summarize.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
summarizeTitle,
|
||||||
|
checkRateLimit,
|
||||||
|
getRateLimitResetTime,
|
||||||
|
validateDescription,
|
||||||
|
SUMMARIZE_SYSTEM_PROMPT,
|
||||||
|
MAX_DESCRIPTION_LENGTH,
|
||||||
|
MIN_DESCRIPTION_LENGTH,
|
||||||
|
MAX_TITLE_LENGTH,
|
||||||
|
MAX_REQUESTS_PER_HOUR,
|
||||||
|
ValidationError,
|
||||||
|
RateLimitError,
|
||||||
|
AiServiceError,
|
||||||
|
__resetSummarizeState,
|
||||||
|
} from "./ai-summarize.js";
|
||||||
|
|
||||||
|
describe("ai-summarize", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetSummarizeState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("constants", () => {
|
||||||
|
it("should have correct system prompt", () => {
|
||||||
|
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("max 60 characters");
|
||||||
|
expect(SUMMARIZE_SYSTEM_PROMPT).toContain("title summarization");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have correct length limits", () => {
|
||||||
|
expect(MIN_DESCRIPTION_LENGTH).toBe(141);
|
||||||
|
expect(MAX_DESCRIPTION_LENGTH).toBe(2000);
|
||||||
|
expect(MAX_TITLE_LENGTH).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have correct rate limit", () => {
|
||||||
|
expect(MAX_REQUESTS_PER_HOUR).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Validation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("validateDescription", () => {
|
||||||
|
it("should accept valid description length", () => {
|
||||||
|
const desc = "a".repeat(200);
|
||||||
|
expect(validateDescription(desc)).toBe(desc);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for null description", () => {
|
||||||
|
expect(() => validateDescription(null)).toThrow(ValidationError);
|
||||||
|
expect(() => validateDescription(null)).toThrow("description is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for undefined description", () => {
|
||||||
|
expect(() => validateDescription(undefined)).toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for non-string description", () => {
|
||||||
|
expect(() => validateDescription(123)).toThrow(ValidationError);
|
||||||
|
expect(() => validateDescription(123)).toThrow("description must be a string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for description too short", () => {
|
||||||
|
const desc = "a".repeat(100);
|
||||||
|
expect(() => validateDescription(desc)).toThrow(ValidationError);
|
||||||
|
expect(() => validateDescription(desc)).toThrow("at least 141 characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw for description too long", () => {
|
||||||
|
const desc = "a".repeat(2001);
|
||||||
|
expect(() => validateDescription(desc)).toThrow(ValidationError);
|
||||||
|
expect(() => validateDescription(desc)).toThrow("not exceed 2000 characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept description at minimum boundary", () => {
|
||||||
|
const desc = "a".repeat(141);
|
||||||
|
expect(validateDescription(desc)).toBe(desc);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept description at maximum boundary", () => {
|
||||||
|
const desc = "a".repeat(2000);
|
||||||
|
expect(validateDescription(desc)).toBe(desc);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Rate Limiting ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("checkRateLimit", () => {
|
||||||
|
it("should allow first request from IP", () => {
|
||||||
|
expect(checkRateLimit("192.168.1.1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should track request count", () => {
|
||||||
|
const ip = "192.168.1.1";
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
expect(checkRateLimit(ip)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(checkRateLimit(ip)).toBe(true); // 6th request
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block after max requests", () => {
|
||||||
|
const ip = "192.168.1.1";
|
||||||
|
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
|
||||||
|
expect(checkRateLimit(ip)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(checkRateLimit(ip)).toBe(false); // 11th request should be blocked
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should track different IPs separately", () => {
|
||||||
|
const ip1 = "192.168.1.1";
|
||||||
|
const ip2 = "192.168.1.2";
|
||||||
|
|
||||||
|
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
|
||||||
|
expect(checkRateLimit(ip1)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(checkRateLimit(ip1)).toBe(false);
|
||||||
|
|
||||||
|
// Different IP should still be allowed
|
||||||
|
expect(checkRateLimit(ip2)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRateLimitResetTime", () => {
|
||||||
|
it("should return null for unknown IP", () => {
|
||||||
|
expect(getRateLimitResetTime("unknown")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return reset time after requests", () => {
|
||||||
|
const ip = "192.168.1.1";
|
||||||
|
checkRateLimit(ip);
|
||||||
|
|
||||||
|
const resetTime = getRateLimitResetTime(ip);
|
||||||
|
expect(resetTime).toBeInstanceOf(Date);
|
||||||
|
expect(resetTime!.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── summarizeTitle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("summarizeTitle", () => {
|
||||||
|
it("should return null for descriptions <= 140 characters", async () => {
|
||||||
|
const result = await summarizeTitle("Short description", "/tmp");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw AiServiceError when engine not available", async () => {
|
||||||
|
// In test environment, the dynamic import fails, so createKbAgent is undefined
|
||||||
|
const longDesc = "a".repeat(200);
|
||||||
|
await expect(summarizeTitle(longDesc, "/tmp")).rejects.toThrow(AiServiceError);
|
||||||
|
await expect(summarizeTitle(longDesc, "/tmp")).rejects.toThrow("AI engine not available");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept optional provider and modelId", async () => {
|
||||||
|
// Since engine isn't available in tests, this will throw
|
||||||
|
const longDesc = "a".repeat(200);
|
||||||
|
await expect(
|
||||||
|
summarizeTitle(longDesc, "/tmp", "anthropic", "claude-sonnet-4-5")
|
||||||
|
).rejects.toThrow(AiServiceError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Error Classes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("error classes", () => {
|
||||||
|
it("ValidationError should have correct name", () => {
|
||||||
|
const err = new ValidationError("test");
|
||||||
|
expect(err.name).toBe("ValidationError");
|
||||||
|
expect(err.message).toBe("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RateLimitError should have correct name and resetTime", () => {
|
||||||
|
const resetTime = new Date();
|
||||||
|
const err = new RateLimitError("rate limited", resetTime);
|
||||||
|
expect(err.name).toBe("RateLimitError");
|
||||||
|
expect(err.message).toBe("rate limited");
|
||||||
|
expect(err.resetTime).toBe(resetTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RateLimitError should allow null resetTime", () => {
|
||||||
|
const err = new RateLimitError("rate limited");
|
||||||
|
expect(err.name).toBe("RateLimitError");
|
||||||
|
expect(err.resetTime).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AiServiceError should have correct name", () => {
|
||||||
|
const err = new AiServiceError("ai failed");
|
||||||
|
expect(err.name).toBe("AiServiceError");
|
||||||
|
expect(err.message).toBe("ai failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── State Reset ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("__resetSummarizeState", () => {
|
||||||
|
it("should clear all rate limit entries", () => {
|
||||||
|
const ip = "192.168.1.1";
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
checkRateLimit(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getRateLimitResetTime(ip)).not.toBeNull();
|
||||||
|
|
||||||
|
__resetSummarizeState();
|
||||||
|
|
||||||
|
expect(getRateLimitResetTime(ip)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
328
packages/core/src/ai-summarize.ts
Normal file
328
packages/core/src/ai-summarize.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* AI Title Summarization Service
|
||||||
|
*
|
||||||
|
* Provides AI-powered title generation from task descriptions.
|
||||||
|
* Automatically generates concise titles (≤60 characters) from descriptions
|
||||||
|
* longer than 140 characters.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Rate limiting per IP (10 requests per hour)
|
||||||
|
* - Dynamic import of @fusion/engine for AI agent creation
|
||||||
|
* - Text length validation (141-2000 characters)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports, @typescript-eslint/no-explicit-any
|
||||||
|
type AgentResult = any;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let createKbAgent: any;
|
||||||
|
|
||||||
|
// Initialize the import (this runs in actual server, mocked in tests)
|
||||||
|
async function initEngine() {
|
||||||
|
if (!createKbAgent) {
|
||||||
|
try {
|
||||||
|
// Use dynamic import with variable to prevent static analysis
|
||||||
|
const engineModule = "@fusion/engine";
|
||||||
|
const engine = await import(/* @vite-ignore */ engineModule);
|
||||||
|
createKbAgent = engine.createKbAgent;
|
||||||
|
} catch {
|
||||||
|
// Allow failure in test environments - agent functionality will be stubbed
|
||||||
|
createKbAgent = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on module load (will be awaited in actual usage)
|
||||||
|
const engineReady = initEngine();
|
||||||
|
|
||||||
|
// ── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** System prompt for title summarization */
|
||||||
|
export const SUMMARIZE_SYSTEM_PROMPT = `You are a title summarization assistant for a task management system.
|
||||||
|
|
||||||
|
Your job is to create a concise title (max 60 characters) that summarizes the given task description.
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
- Create a clear, descriptive title that captures the essence of what the task is about
|
||||||
|
- Return only the title text, no quotes, no markdown, no explanations
|
||||||
|
- The title should be actionable and professional
|
||||||
|
- Maximum 60 characters — be concise but informative
|
||||||
|
- Focus on the main goal or deliverable of the task`;
|
||||||
|
|
||||||
|
/** Maximum description length in characters */
|
||||||
|
export const MAX_DESCRIPTION_LENGTH = 2000;
|
||||||
|
|
||||||
|
/** Minimum description length for summarization in characters */
|
||||||
|
export const MIN_DESCRIPTION_LENGTH = 141;
|
||||||
|
|
||||||
|
/** Maximum title length in characters */
|
||||||
|
export const MAX_TITLE_LENGTH = 60;
|
||||||
|
|
||||||
|
/** Rate limit: max requests per IP per hour */
|
||||||
|
export const MAX_REQUESTS_PER_HOUR = 10;
|
||||||
|
|
||||||
|
/** Rate limit window in milliseconds (1 hour) */
|
||||||
|
export const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Cleanup interval in milliseconds (5 minutes) */
|
||||||
|
export const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// ── Rate Limiting ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
firstRequestAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rate limiting state indexed by IP */
|
||||||
|
const rateLimits = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IP can make a summarization request.
|
||||||
|
* Returns true if allowed, false if rate limited.
|
||||||
|
*/
|
||||||
|
export function checkRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateLimits.get(ip);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
// First request from this IP
|
||||||
|
rateLimits.set(ip, {
|
||||||
|
count: 1,
|
||||||
|
firstRequestAt: new Date(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window has expired
|
||||||
|
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
// Reset window
|
||||||
|
rateLimits.set(ip, {
|
||||||
|
count: 1,
|
||||||
|
firstRequestAt: new Date(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within window - check limit
|
||||||
|
if (entry.count >= MAX_REQUESTS_PER_HOUR) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment count
|
||||||
|
entry.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit reset time for an IP.
|
||||||
|
* Returns null if no rate limit entry exists.
|
||||||
|
*/
|
||||||
|
export function getRateLimitResetTime(ip: string): Date | null {
|
||||||
|
const entry = rateLimits.get(ip);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
return new Date(entry.firstRequestAt.getTime() + RATE_LIMIT_WINDOW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove expired rate limit entries.
|
||||||
|
* Runs periodically via setInterval.
|
||||||
|
*/
|
||||||
|
function cleanupExpiredRateLimits(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
let cleanedRateLimits = 0;
|
||||||
|
|
||||||
|
for (const [ip, entry] of rateLimits) {
|
||||||
|
if (now - entry.firstRequestAt.getTime() > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
rateLimits.delete(ip);
|
||||||
|
cleanedRateLimits++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanedRateLimits > 0) {
|
||||||
|
console.log(`[ai-summarize] Cleanup: removed ${cleanedRateLimits} rate limit entries`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start cleanup interval
|
||||||
|
const cleanupInterval = setInterval(cleanupExpiredRateLimits, CLEANUP_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on("beforeExit", () => {
|
||||||
|
clearInterval(cleanupInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Custom Errors ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class ValidationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ValidationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RateLimitError extends Error {
|
||||||
|
resetTime: Date | null;
|
||||||
|
|
||||||
|
constructor(message: string, resetTime: Date | null = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = "RateLimitError";
|
||||||
|
this.resetTime = resetTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AiServiceError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AiServiceError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate description for summarization.
|
||||||
|
* Throws appropriate errors for invalid input.
|
||||||
|
*/
|
||||||
|
export function validateDescription(description: unknown): string {
|
||||||
|
// Validate description exists
|
||||||
|
if (description === undefined || description === null) {
|
||||||
|
throw new ValidationError("description is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate description is a string
|
||||||
|
if (typeof description !== "string") {
|
||||||
|
throw new ValidationError("description must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate description length
|
||||||
|
if (description.length < MIN_DESCRIPTION_LENGTH) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (description.length > MAX_DESCRIPTION_LENGTH) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`description must not exceed ${MAX_DESCRIPTION_LENGTH} characters`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI Integration ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize a task description into a concise title using AI.
|
||||||
|
* @param description - The task description to summarize (must be 141-2000 chars)
|
||||||
|
* @param rootDir - Project root directory for AI agent context
|
||||||
|
* @param provider - Optional AI model provider (e.g., "anthropic")
|
||||||
|
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")
|
||||||
|
* @returns The generated title (guaranteed ≤60 characters), or null if validation fails
|
||||||
|
*/
|
||||||
|
export async function summarizeTitle(
|
||||||
|
description: string,
|
||||||
|
rootDir: string,
|
||||||
|
provider?: string,
|
||||||
|
modelId?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
// Validate description length first
|
||||||
|
if (description.length <= 140) {
|
||||||
|
return null; // Too short for summarization
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure engine is loaded before using createKbAgent
|
||||||
|
await engineReady;
|
||||||
|
|
||||||
|
if (!createKbAgent) {
|
||||||
|
throw new AiServiceError("AI engine not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentOptions: {
|
||||||
|
cwd: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
tools: "readonly";
|
||||||
|
defaultProvider?: string;
|
||||||
|
defaultModelId?: string;
|
||||||
|
} = {
|
||||||
|
cwd: rootDir,
|
||||||
|
systemPrompt: SUMMARIZE_SYSTEM_PROMPT,
|
||||||
|
tools: "readonly",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add model selection if both provider and modelId are provided
|
||||||
|
if (provider && modelId) {
|
||||||
|
agentOptions.defaultProvider = provider;
|
||||||
|
agentOptions.defaultModelId = modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentResult = await createKbAgent(agentOptions);
|
||||||
|
|
||||||
|
if (!agentResult?.session) {
|
||||||
|
throw new AiServiceError("Failed to initialize AI agent");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send the description to the agent
|
||||||
|
await agentResult.session.prompt(description);
|
||||||
|
|
||||||
|
// Get the response text from the agent's state
|
||||||
|
interface AgentMessage {
|
||||||
|
role: string;
|
||||||
|
content?: string | Array<{ type: string; text: string }>;
|
||||||
|
}
|
||||||
|
const lastMessage = (agentResult.session.state.messages as AgentMessage[])
|
||||||
|
.filter((m: AgentMessage) => m.role === "assistant")
|
||||||
|
.pop();
|
||||||
|
|
||||||
|
let title = "";
|
||||||
|
if (lastMessage?.content) {
|
||||||
|
// Handle both string and array content types
|
||||||
|
if (typeof lastMessage.content === "string") {
|
||||||
|
title = lastMessage.content.trim();
|
||||||
|
} else if (Array.isArray(lastMessage.content)) {
|
||||||
|
// Extract text from content blocks
|
||||||
|
title = lastMessage.content
|
||||||
|
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
|
||||||
|
.map((c: { type: string; text: string }) => c.text)
|
||||||
|
.join("")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
throw new AiServiceError("AI returned empty response");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate to max title length if needed
|
||||||
|
if (title.length > MAX_TITLE_LENGTH) {
|
||||||
|
title = title.slice(0, MAX_TITLE_LENGTH).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return title;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AiServiceError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new AiServiceError(
|
||||||
|
err instanceof Error ? err.message : "AI processing failed"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Ensure session is disposed even on error
|
||||||
|
try {
|
||||||
|
agentResult.session.dispose?.();
|
||||||
|
} catch {
|
||||||
|
// Ignore disposal errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all summarization state. Used for testing only.
|
||||||
|
*/
|
||||||
|
export function __resetSummarizeState(): void {
|
||||||
|
rateLimits.clear();
|
||||||
|
}
|
||||||
@@ -52,6 +52,24 @@ export type {
|
|||||||
ImportResult,
|
ImportResult,
|
||||||
} from "./settings-export.js";
|
} from "./settings-export.js";
|
||||||
|
|
||||||
|
// ── AI Summarization ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export {
|
||||||
|
summarizeTitle,
|
||||||
|
checkRateLimit,
|
||||||
|
getRateLimitResetTime,
|
||||||
|
validateDescription,
|
||||||
|
SUMMARIZE_SYSTEM_PROMPT,
|
||||||
|
MAX_DESCRIPTION_LENGTH,
|
||||||
|
MIN_DESCRIPTION_LENGTH,
|
||||||
|
MAX_TITLE_LENGTH,
|
||||||
|
MAX_REQUESTS_PER_HOUR,
|
||||||
|
ValidationError,
|
||||||
|
RateLimitError,
|
||||||
|
AiServiceError,
|
||||||
|
__resetSummarizeState,
|
||||||
|
} from "./ai-summarize.js";
|
||||||
|
|
||||||
// ── Mission Hierarchy Types ────────────────────────────────────────────
|
// ── Mission Hierarchy Types ────────────────────────────────────────────
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -3733,4 +3733,162 @@ describe("TaskStore", () => {
|
|||||||
expect(task.enabledWorkflowSteps).toBeUndefined();
|
expect(task.enabledWorkflowSteps).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Title Summarization Tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("createTask with title summarization", () => {
|
||||||
|
it("should use generated title when onSummarize returns a title", async () => {
|
||||||
|
const longDescription = "a".repeat(200);
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Generated Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: longDescription },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBe("AI Generated Title");
|
||||||
|
expect(mockOnSummarize).toHaveBeenCalledWith(longDescription);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call onSummarize when title is already provided", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ title: "User Title", description: "a".repeat(200) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBe("User Title");
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call onSummarize when description is too short", async () => {
|
||||||
|
const shortDescription = "a".repeat(100);
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: shortDescription },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call onSummarize when autoSummarizeTitles is false", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(200) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: false } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call onSummarize when no settings provided", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(200) },
|
||||||
|
{ onSummarize: mockOnSummarize }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onSummarize when summarize input flag is true", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(200), summarize: true },
|
||||||
|
{ onSummarize: mockOnSummarize }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBe("AI Title");
|
||||||
|
expect(mockOnSummarize).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle onSummarize returning null", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue(null);
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(200) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle onSummarize throwing error gracefully", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const mockOnSummarize = vi.fn().mockRejectedValue(new Error("AI service failed"));
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(200) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(task.id).toMatch(/^KB-\d+$/); // Task still created
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Title summarization failed"),
|
||||||
|
expect.stringContaining("AI service failed")
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trigger summarization at exactly 141 characters", async () => {
|
||||||
|
const boundaryDescription = "a".repeat(141);
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: boundaryDescription },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockOnSummarize).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not trigger summarization at exactly 140 characters", async () => {
|
||||||
|
const boundaryDescription = "a".repeat(140);
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: boundaryDescription },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prioritize explicit title over summarize flag", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ title: "User Title", description: "a".repeat(200), summarize: true },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBe("User Title");
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include generated title in PROMPT.md heading", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("Generated Task Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(200) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBe("Generated Task Title");
|
||||||
|
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
expect(detail.prompt).toMatch(/^# KB-\d+: Generated Task Title\n/);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -582,7 +582,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return join(this.tasksDir, id);
|
return join(this.tasksDir, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTask(input: TaskCreateInput): Promise<Task> {
|
async createTask(
|
||||||
|
input: TaskCreateInput,
|
||||||
|
options?: {
|
||||||
|
onSummarize?: (description: string) => Promise<string | null>;
|
||||||
|
settings?: { autoSummarizeTitles?: boolean };
|
||||||
|
}
|
||||||
|
): Promise<Task> {
|
||||||
if (!input.description?.trim()) {
|
if (!input.description?.trim()) {
|
||||||
throw new Error("Description is required and cannot be empty");
|
throw new Error("Description is required and cannot be empty");
|
||||||
}
|
}
|
||||||
@@ -592,10 +598,31 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
if (input.dependencies?.includes(id)) {
|
if (input.dependencies?.includes(id)) {
|
||||||
throw new Error(`Task ${id} cannot depend on itself`);
|
throw new Error(`Task ${id} cannot depend on itself`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine if we should try to summarize the title
|
||||||
|
let title = input.title?.trim() || undefined;
|
||||||
|
const shouldSummarize =
|
||||||
|
!title && // Only if no title provided
|
||||||
|
input.description.length > 140 && // Only if description is long enough
|
||||||
|
(input.summarize === true || // Explicit request
|
||||||
|
options?.settings?.autoSummarizeTitles === true); // Auto-enabled
|
||||||
|
|
||||||
|
if (shouldSummarize && options?.onSummarize) {
|
||||||
|
try {
|
||||||
|
const generatedTitle = await options.onSummarize(input.description);
|
||||||
|
if (generatedTitle) {
|
||||||
|
title = generatedTitle;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Log warning but don't block task creation
|
||||||
|
console.warn(`[TaskStore] Title summarization failed for task ${id}:`, err instanceof Error ? err.message : err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const task: Task = {
|
const task: Task = {
|
||||||
id,
|
id,
|
||||||
title: input.title?.trim() || undefined,
|
title,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
column: input.column || "triage",
|
column: input.column || "triage",
|
||||||
dependencies: input.dependencies || [],
|
dependencies: input.dependencies || [],
|
||||||
|
|||||||
@@ -466,6 +466,8 @@ export interface TaskCreateInput {
|
|||||||
validatorModelId?: string;
|
validatorModelId?: string;
|
||||||
/** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */
|
/** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
|
/** When true, trigger AI title summarization if description is long and no title provided */
|
||||||
|
summarize?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings Scope Types ────────────────────────────────────────────────
|
// ── Settings Scope Types ────────────────────────────────────────────────
|
||||||
@@ -641,6 +643,18 @@ export interface ProjectSettings {
|
|||||||
autoBackupRetention?: number;
|
autoBackupRetention?: number;
|
||||||
/** Directory for backup files, relative to project root. Default: ".kb/backups". */
|
/** Directory for backup files, relative to project root. Default: ".kb/backups". */
|
||||||
autoBackupDir?: string;
|
autoBackupDir?: string;
|
||||||
|
/** When true, tasks created without titles but with descriptions longer than 140
|
||||||
|
* characters will automatically receive an AI-generated title (max 60 chars).
|
||||||
|
* Default: false. */
|
||||||
|
autoSummarizeTitles?: boolean;
|
||||||
|
/** AI model provider for title summarization (when autoSummarizeTitles is enabled).
|
||||||
|
* Must be set together with `titleSummarizerModelId`. Falls back to planningProvider,
|
||||||
|
* then defaultProvider if not specified. */
|
||||||
|
titleSummarizerProvider?: string;
|
||||||
|
/** AI model ID for title summarization (when autoSummarizeTitles is enabled).
|
||||||
|
* Must be set together with `titleSummarizerProvider`. Falls back to planningModelId,
|
||||||
|
* then defaultModelId if not specified. */
|
||||||
|
titleSummarizerModelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -701,6 +715,9 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
|||||||
autoBackupSchedule: "0 2 * * *",
|
autoBackupSchedule: "0 2 * * *",
|
||||||
autoBackupRetention: 7,
|
autoBackupRetention: 7,
|
||||||
autoBackupDir: ".kb/backups",
|
autoBackupDir: ".kb/backups",
|
||||||
|
autoSummarizeTitles: false,
|
||||||
|
titleSummarizerProvider: undefined,
|
||||||
|
titleSummarizerModelId: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -758,6 +775,9 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
|||||||
"autoBackupSchedule",
|
"autoBackupSchedule",
|
||||||
"autoBackupRetention",
|
"autoBackupRetention",
|
||||||
"autoBackupDir",
|
"autoBackupDir",
|
||||||
|
"autoSummarizeTitles",
|
||||||
|
"titleSummarizerProvider",
|
||||||
|
"titleSummarizerModelId",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export interface BoardConfig {
|
export interface BoardConfig {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
saveWorkspaceFileContent,
|
saveWorkspaceFileContent,
|
||||||
startPlanningStreaming,
|
startPlanningStreaming,
|
||||||
fetchTasks,
|
fetchTasks,
|
||||||
|
summarizeTitle,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import type { Task, TaskDetail, BatchStatusResponse } from "@fusion/core";
|
import type { Task, TaskDetail, BatchStatusResponse } from "@fusion/core";
|
||||||
|
|
||||||
@@ -1678,3 +1679,124 @@ describe("REFINE_ERROR_MESSAGES", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Summarize Title Tests ---
|
||||||
|
|
||||||
|
describe("summarizeTitle", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns title on successful response", async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Headers({ "content-type": "application/json" }),
|
||||||
|
text: vi.fn().mockResolvedValue(JSON.stringify({ title: "Generated Title" })),
|
||||||
|
});
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
const result = await summarizeTitle("a".repeat(200));
|
||||||
|
|
||||||
|
expect(result).toBe("Generated Title");
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/ai/summarize-title",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ description: "a".repeat(200), provider: undefined, modelId: undefined }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends provider and modelId when provided", async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Headers({ "content-type": "application/json" }),
|
||||||
|
text: vi.fn().mockResolvedValue(JSON.stringify({ title: "Generated Title" })),
|
||||||
|
});
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
await summarizeTitle("a".repeat(200), "anthropic", "claude-sonnet-4-5");
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/ai/summarize-title",
|
||||||
|
expect.objectContaining({
|
||||||
|
body: JSON.stringify({ description: "a".repeat(200), provider: "anthropic", modelId: "claude-sonnet-4-5" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws descriptive error on 400 response", async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
headers: new Headers({ "content-type": "application/json" }),
|
||||||
|
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "Description too short" })),
|
||||||
|
});
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
await expect(summarizeTitle("short")).rejects.toThrow("Invalid request: Description too short");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws descriptive error on 429 response", async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
headers: new Headers({ "content-type": "application/json" }),
|
||||||
|
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "Rate limit exceeded" })),
|
||||||
|
});
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("Rate limit exceeded: Rate limit exceeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws descriptive error on 503 response", async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
headers: new Headers({ "content-type": "application/json" }),
|
||||||
|
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "AI service unavailable" })),
|
||||||
|
});
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("AI service temporarily unavailable: AI service unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws generic error on other failure responses", async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
headers: new Headers({ "content-type": "application/json" }),
|
||||||
|
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "Internal server error" })),
|
||||||
|
});
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error for non-JSON responses", async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Headers({ "content-type": "text/html" }),
|
||||||
|
text: vi.fn().mockResolvedValue("<html>Not JSON</html>"),
|
||||||
|
});
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("API returned non-JSON response");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when response has no title", async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Headers({ "content-type": "application/json" }),
|
||||||
|
text: vi.fn().mockResolvedValue(JSON.stringify({})),
|
||||||
|
});
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("API returned empty title");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1667,3 +1667,58 @@ export function importSettings(
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- AI Summarization API ---
|
||||||
|
|
||||||
|
/** Response from title summarization endpoint */
|
||||||
|
export interface SummarizeTitleResponse {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summarize a task description into a concise title using AI.
|
||||||
|
* @param description - The task description to summarize (must be 141-2000 chars)
|
||||||
|
* @param provider - Optional AI model provider (e.g., "anthropic")
|
||||||
|
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")
|
||||||
|
* @returns The generated title (guaranteed ≤60 characters)
|
||||||
|
* @throws Error with descriptive message for 400/429/503 errors
|
||||||
|
*/
|
||||||
|
export async function summarizeTitle(
|
||||||
|
description: string,
|
||||||
|
provider?: string,
|
||||||
|
modelId?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await fetch("/api/ai/summarize-title", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ description, provider, modelId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
const bodyText = await res.text();
|
||||||
|
const isJson = contentType.includes("application/json");
|
||||||
|
|
||||||
|
if (!isJson) {
|
||||||
|
throw new Error(`API returned non-JSON response: ${bodyText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(bodyText) as { title?: string; error?: string };
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorMessage = data.error || "Request failed";
|
||||||
|
if (res.status === 400) {
|
||||||
|
throw new Error(`Invalid request: ${errorMessage}`);
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
throw new Error(`Rate limit exceeded: ${errorMessage}`);
|
||||||
|
} else if (res.status === 503) {
|
||||||
|
throw new Error(`AI service temporarily unavailable: ${errorMessage}`);
|
||||||
|
} else {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.title) {
|
||||||
|
throw new Error("API returned empty title");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.title;
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: "general", label: "General", scope: "project" as const },
|
{ id: "general", label: "General", scope: "project" as const },
|
||||||
{ id: "model", label: "Model", scope: "global" as const },
|
{ id: "model", label: "Model", scope: "global" as const },
|
||||||
{ id: "model-presets", label: "Model Presets", scope: "project" as const },
|
{ id: "model-presets", label: "Model Presets", scope: "project" as const },
|
||||||
|
{ id: "ai-summarization", label: "AI Summarization", scope: "project" as const },
|
||||||
{ id: "appearance", label: "Appearance", scope: "global" as const },
|
{ id: "appearance", label: "Appearance", scope: "global" as const },
|
||||||
{ id: "scheduling", label: "Scheduling", scope: "project" as const },
|
{ id: "scheduling", label: "Scheduling", scope: "project" as const },
|
||||||
{ id: "worktrees", label: "Worktrees", scope: "project" as const },
|
{ id: "worktrees", label: "Worktrees", scope: "project" as const },
|
||||||
@@ -825,6 +826,112 @@ export function SettingsModal({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case "ai-summarization":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
|
<h4 className="settings-section-heading">AI Summarization</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="autoSummarizeTitles" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="autoSummarizeTitles"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.autoSummarizeTitles || false}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, autoSummarizeTitles: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Auto-summarize long descriptions as titles
|
||||||
|
</label>
|
||||||
|
<small>
|
||||||
|
When enabled, tasks created without a title but with descriptions over 140 characters
|
||||||
|
will automatically get an AI-generated title (max 60 characters).
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(form.autoSummarizeTitles || false) && (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Title summarization model</label>
|
||||||
|
{modelsLoading ? (
|
||||||
|
<small>Loading available models...</small>
|
||||||
|
) : availableModels.length === 0 ? (
|
||||||
|
<small>No models available. Configure authentication first.</small>
|
||||||
|
) : (
|
||||||
|
<CustomModelDropdown
|
||||||
|
id="titleSummarizerModel"
|
||||||
|
label="Title summarization model"
|
||||||
|
models={availableModels}
|
||||||
|
value={
|
||||||
|
form.titleSummarizerProvider && form.titleSummarizerModelId
|
||||||
|
? `${form.titleSummarizerProvider}/${form.titleSummarizerModelId}`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (!val) {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
titleSummarizerProvider: undefined,
|
||||||
|
titleSummarizerModelId: undefined,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const slashIdx = val.indexOf("/");
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
titleSummarizerProvider: val.slice(0, slashIdx),
|
||||||
|
titleSummarizerModelId: val.slice(slashIdx + 1),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="Use fallback model"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<small>
|
||||||
|
{form.titleSummarizerProvider && form.titleSummarizerModelId
|
||||||
|
? "Using explicitly configured model"
|
||||||
|
: form.planningProvider && form.planningModelId
|
||||||
|
? "(using planning model)"
|
||||||
|
: form.defaultProvider && form.defaultModelId
|
||||||
|
? "(using default model)"
|
||||||
|
: "(using automatic model selection)"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="modal-actions" style={{ justifyContent: "flex-start" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
titleSummarizerProvider: f.planningProvider,
|
||||||
|
titleSummarizerModelId: f.planningModelId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!form.planningProvider || !form.planningModelId}
|
||||||
|
>
|
||||||
|
Use planning model
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
titleSummarizerProvider: f.defaultProvider,
|
||||||
|
titleSummarizerModelId: f.defaultModelId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!form.defaultProvider || !form.defaultModelId}
|
||||||
|
>
|
||||||
|
Use default model
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
case "appearance":
|
case "appearance":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1428,19 +1428,54 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = await store.createTask({
|
// Check for summarize flag in request
|
||||||
title,
|
const summarize = req.body.summarize === true;
|
||||||
description,
|
|
||||||
column,
|
// Get settings for auto-summarization
|
||||||
dependencies,
|
const settings = await store.getSettings();
|
||||||
breakIntoSubtasks,
|
|
||||||
enabledWorkflowSteps,
|
// Create onSummarize callback if summarization is enabled
|
||||||
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
|
const onSummarize = (summarize || settings.autoSummarizeTitles)
|
||||||
modelProvider: executorModel.provider,
|
? async (desc: string): Promise<string | null> => {
|
||||||
modelId: executorModel.modelId,
|
try {
|
||||||
validatorModelProvider: validatorModel.provider,
|
const { summarizeTitle } = await import("@fusion/core");
|
||||||
validatorModelId: validatorModel.modelId,
|
|
||||||
});
|
// Resolve model selection hierarchy for summarization
|
||||||
|
const resolvedProvider =
|
||||||
|
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) ||
|
||||||
|
(settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) ||
|
||||||
|
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
|
||||||
|
|
||||||
|
const resolvedModelId =
|
||||||
|
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) ||
|
||||||
|
(settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
|
||||||
|
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
|
||||||
|
|
||||||
|
return await summarizeTitle(desc, store.getRootDir(), resolvedProvider, resolvedModelId);
|
||||||
|
} catch {
|
||||||
|
// Return null on error so task creation continues without title
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
column,
|
||||||
|
dependencies,
|
||||||
|
breakIntoSubtasks,
|
||||||
|
enabledWorkflowSteps,
|
||||||
|
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
|
||||||
|
modelProvider: executorModel.provider,
|
||||||
|
modelId: executorModel.modelId,
|
||||||
|
validatorModelProvider: validatorModel.provider,
|
||||||
|
validatorModelId: validatorModel.modelId,
|
||||||
|
summarize,
|
||||||
|
},
|
||||||
|
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } }
|
||||||
|
);
|
||||||
res.status(201).json(task);
|
res.status(201).json(task);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const status = err.message?.includes("must be a string") ? 400 : 500;
|
const status = err.message?.includes("must be a string") ? 400 : 500;
|
||||||
@@ -4915,6 +4950,108 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/summarize-title
|
||||||
|
* AI-powered title generation from task descriptions.
|
||||||
|
* Body: { description: string, provider?: string, modelId?: string }
|
||||||
|
* Returns: { title: string }
|
||||||
|
*
|
||||||
|
* Generates a concise title (≤60 characters) from descriptions longer than 140 characters.
|
||||||
|
* Rate limited: 10 requests per hour per IP
|
||||||
|
*/
|
||||||
|
router.post("/ai/summarize-title", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { description, provider, modelId } = req.body;
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||||
|
const rootDir = store.getRootDir();
|
||||||
|
|
||||||
|
const {
|
||||||
|
checkRateLimit,
|
||||||
|
getRateLimitResetTime,
|
||||||
|
summarizeTitle,
|
||||||
|
validateDescription,
|
||||||
|
MIN_DESCRIPTION_LENGTH,
|
||||||
|
MAX_DESCRIPTION_LENGTH,
|
||||||
|
RateLimitError,
|
||||||
|
ValidationError,
|
||||||
|
AiServiceError,
|
||||||
|
} = await import("@fusion/core");
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
if (process.env.KB_DEBUG_AI) {
|
||||||
|
console.log(`[ai-summarize] Request from ${ip}, description length: ${description?.length || 0}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit first
|
||||||
|
if (!checkRateLimit(ip)) {
|
||||||
|
const resetTime = getRateLimitResetTime(ip);
|
||||||
|
res.status(429).json({
|
||||||
|
error: `Rate limit exceeded. Maximum 10 summarization requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
try {
|
||||||
|
validateDescription(description);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "ValidationError") {
|
||||||
|
res.status(400).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve model selection hierarchy:
|
||||||
|
// 1. Request body provider+modelId
|
||||||
|
// 2. Settings titleSummarizerProvider + titleSummarizerModelId
|
||||||
|
// 3. Settings planningProvider + planningModelId
|
||||||
|
// 4. Settings defaultProvider + defaultModelId
|
||||||
|
// 5. Automatic model resolution (no explicit model)
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
|
||||||
|
const resolvedProvider =
|
||||||
|
(provider && modelId ? provider : undefined) ||
|
||||||
|
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) ||
|
||||||
|
(settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) ||
|
||||||
|
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
|
||||||
|
|
||||||
|
const resolvedModelId =
|
||||||
|
(provider && modelId ? modelId : undefined) ||
|
||||||
|
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) ||
|
||||||
|
(settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
|
||||||
|
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
|
||||||
|
|
||||||
|
if (process.env.KB_DEBUG_AI) {
|
||||||
|
console.log(`[ai-summarize] Resolved model: ${resolvedProvider || "auto"}/${resolvedModelId || "auto"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process summarization
|
||||||
|
const title = await summarizeTitle(description, rootDir, resolvedProvider, resolvedModelId);
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: `Description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ title });
|
||||||
|
} catch (err: any) {
|
||||||
|
// Check error by name since error classes are from dynamic import
|
||||||
|
if (err?.name === "RateLimitError") {
|
||||||
|
res.status(429).json({ error: err.message });
|
||||||
|
} else if (err?.name === "AiServiceError") {
|
||||||
|
res.status(503).json({ error: err.message || "AI service temporarily unavailable" });
|
||||||
|
} else if (err?.name === "ValidationError") {
|
||||||
|
res.status(400).json({ error: err.message });
|
||||||
|
} else {
|
||||||
|
console.error("[ai-summarize] Unexpected error:", err);
|
||||||
|
res.status(500).json({ error: err?.message || "Failed to generate title" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/usage
|
* GET /api/usage
|
||||||
* Fetch AI provider subscription usage (Claude, Codex, Gemini).
|
* Fetch AI provider subscription usage (Claude, Codex, Gemini).
|
||||||
|
|||||||
Reference in New Issue
Block a user