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:
@@ -22,6 +22,7 @@ import {
|
||||
saveWorkspaceFileContent,
|
||||
startPlanningStreaming,
|
||||
fetchTasks,
|
||||
summarizeTitle,
|
||||
} from "./api";
|
||||
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: "model", label: "Model", scope: "global" 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: "scheduling", label: "Scheduling", 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":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1428,19 +1428,54 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
// Check for summarize flag in request
|
||||
const summarize = req.body.summarize === true;
|
||||
|
||||
// Get settings for auto-summarization
|
||||
const settings = await store.getSettings();
|
||||
|
||||
// Create onSummarize callback if summarization is enabled
|
||||
const onSummarize = (summarize || settings.autoSummarizeTitles)
|
||||
? async (desc: string): Promise<string | null> => {
|
||||
try {
|
||||
const { summarizeTitle } = await import("@fusion/core");
|
||||
|
||||
// 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);
|
||||
} catch (err: any) {
|
||||
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
|
||||
* Fetch AI provider subscription usage (Claude, Codex, Gemini).
|
||||
|
||||
Reference in New Issue
Block a user