feat(KB-225): add AI text refinement feature to dashboard

- Add AI text refinement backend service with OpenAI/Anthropic integration
- Add /api/refine-text API endpoint with error handling
- Add QuickEntryBox AI refine button with style presets menu
- Add NewTaskModal AI refine feature for task description editing
- Add comprehensive tests for backend service, API, and components
- Add changeset for the new feature
This commit is contained in:
gsxdsm
2026-03-31 04:20:37 -07:00
parent 139fbb9f29
commit f1a849b6fa
11 changed files with 1565 additions and 17 deletions

View File

@@ -0,0 +1,285 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
refineText,
validateRefineRequest,
checkRateLimit,
getRateLimitResetTime,
__resetRefineState,
ValidationError,
InvalidTypeError,
AiServiceError,
VALID_REFINEMENT_TYPES,
MIN_TEXT_LENGTH,
MAX_TEXT_LENGTH,
MAX_REQUESTS_PER_HOUR,
RATE_LIMIT_WINDOW_MS,
} from "./ai-refine.js";
describe("ai-refine module", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
__resetRefineState();
});
afterEach(() => {
vi.useRealTimers();
});
describe("VALID_REFINEMENT_TYPES", () => {
it("contains all four refinement types", () => {
expect(VALID_REFINEMENT_TYPES).toEqual([
"clarify",
"add-details",
"expand",
"simplify",
]);
});
});
describe("validateRefineRequest", () => {
it("accepts valid text and 'clarify' type", () => {
const result = validateRefineRequest("Some text", "clarify");
expect(result).toEqual({ text: "Some text", type: "clarify" });
});
it("accepts valid text and 'add-details' type", () => {
const result = validateRefineRequest("Some text", "add-details");
expect(result).toEqual({ text: "Some text", type: "add-details" });
});
it("accepts valid text and 'expand' type", () => {
const result = validateRefineRequest("Some text", "expand");
expect(result).toEqual({ text: "Some text", type: "expand" });
});
it("accepts valid text and 'simplify' type", () => {
const result = validateRefineRequest("Some text", "simplify");
expect(result).toEqual({ text: "Some text", type: "simplify" });
});
it("throws ValidationError for missing text", () => {
expect(() => validateRefineRequest(undefined, "clarify")).toThrow(ValidationError);
expect(() => validateRefineRequest(undefined, "clarify")).toThrow("text is required");
});
it("throws ValidationError for null text", () => {
expect(() => validateRefineRequest(null, "clarify")).toThrow(ValidationError);
expect(() => validateRefineRequest(null, "clarify")).toThrow("text is required");
});
it("throws ValidationError for non-string text", () => {
expect(() => validateRefineRequest(123, "clarify")).toThrow(ValidationError);
expect(() => validateRefineRequest(123, "clarify")).toThrow("text must be a string");
});
it("throws ValidationError for empty text", () => {
expect(() => validateRefineRequest("", "clarify")).toThrow(ValidationError);
expect(() => validateRefineRequest("", "clarify")).toThrow(
`text must be at least ${MIN_TEXT_LENGTH} character`
);
});
it("throws ValidationError for text exceeding MAX_TEXT_LENGTH", () => {
const longText = "a".repeat(MAX_TEXT_LENGTH + 1);
expect(() => validateRefineRequest(longText, "clarify")).toThrow(ValidationError);
expect(() => validateRefineRequest(longText, "clarify")).toThrow(
`text must not exceed ${MAX_TEXT_LENGTH} characters`
);
});
it("accepts text at exactly MAX_TEXT_LENGTH", () => {
const maxText = "a".repeat(MAX_TEXT_LENGTH);
const result = validateRefineRequest(maxText, "clarify");
expect(result.text).toHaveLength(MAX_TEXT_LENGTH);
});
it("accepts text at exactly MIN_TEXT_LENGTH", () => {
const result = validateRefineRequest("a", "clarify");
expect(result.text).toBe("a");
});
it("throws ValidationError for missing type", () => {
expect(() => validateRefineRequest("some text", undefined)).toThrow(ValidationError);
expect(() => validateRefineRequest("some text", undefined)).toThrow("type is required");
});
it("throws ValidationError for null type", () => {
expect(() => validateRefineRequest("some text", null)).toThrow(ValidationError);
expect(() => validateRefineRequest("some text", null)).toThrow("type is required");
});
it("throws InvalidTypeError for invalid type string", () => {
expect(() => validateRefineRequest("some text", "invalid")).toThrow(InvalidTypeError);
expect(() => validateRefineRequest("some text", "invalid")).toThrow(
"type must be one of: clarify, add-details, expand, simplify"
);
});
it("throws InvalidTypeError for numeric type", () => {
expect(() => validateRefineRequest("some text", 123)).toThrow(InvalidTypeError);
});
});
describe("checkRateLimit", () => {
it("allows first request from an IP", () => {
expect(checkRateLimit("192.168.1.1")).toBe(true);
});
it("allows up to MAX_REQUESTS_PER_HOUR requests", () => {
const ip = "192.168.1.1";
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
expect(checkRateLimit(ip)).toBe(true);
}
});
it("blocks request beyond MAX_REQUESTS_PER_HOUR", () => {
const ip = "192.168.1.1";
// Use up the quota
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
checkRateLimit(ip);
}
// 11th request should be blocked
expect(checkRateLimit(ip)).toBe(false);
});
it("tracks different IPs independently", () => {
const ip1 = "192.168.1.1";
const ip2 = "192.168.1.2";
// Use up quota for ip1
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
checkRateLimit(ip1);
}
expect(checkRateLimit(ip1)).toBe(false);
// ip2 should still have full quota
expect(checkRateLimit(ip2)).toBe(true);
});
it("resets rate limit after RATE_LIMIT_WINDOW_MS", () => {
const ip = "192.168.1.1";
// Use up the quota
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
checkRateLimit(ip);
}
expect(checkRateLimit(ip)).toBe(false);
// Advance time by 1 hour + 1ms
vi.advanceTimersByTime(RATE_LIMIT_WINDOW_MS + 1);
// Should be allowed again
expect(checkRateLimit(ip)).toBe(true);
});
it("resets count but tracks new window after expiry", () => {
const ip = "192.168.1.1";
// Make 5 requests
for (let i = 0; i < 5; i++) {
checkRateLimit(ip);
}
// Advance time by 1 hour + 1ms
vi.advanceTimersByTime(RATE_LIMIT_WINDOW_MS + 1);
// First request in new window should work
expect(checkRateLimit(ip)).toBe(true);
// Use up remaining quota in new window
for (let i = 0; i < MAX_REQUESTS_PER_HOUR - 1; i++) {
expect(checkRateLimit(ip)).toBe(true);
}
// Next request should be blocked
expect(checkRateLimit(ip)).toBe(false);
});
});
describe("getRateLimitResetTime", () => {
it("returns null for unknown IP", () => {
expect(getRateLimitResetTime("unknown-ip")).toBeNull();
});
it("returns reset time after a request is made", () => {
const ip = "192.168.1.1";
const beforeRequest = Date.now();
checkRateLimit(ip);
const resetTime = getRateLimitResetTime(ip);
expect(resetTime).not.toBeNull();
expect(resetTime!.getTime()).toBe(beforeRequest + RATE_LIMIT_WINDOW_MS);
});
it("returns updated reset time after window resets", () => {
const ip = "192.168.1.1";
checkRateLimit(ip);
const firstResetTime = getRateLimitResetTime(ip);
// Advance time past the window
vi.advanceTimersByTime(RATE_LIMIT_WINDOW_MS + 1000);
// Make another request
checkRateLimit(ip);
const secondResetTime = getRateLimitResetTime(ip);
// Second reset time should be later than first
expect(secondResetTime!.getTime()).toBeGreaterThan(firstResetTime!.getTime());
});
});
describe("error classes", () => {
it("ValidationError has correct name", () => {
const error = new ValidationError("test");
expect(error.name).toBe("ValidationError");
expect(error.message).toBe("test");
});
it("InvalidTypeError has correct name", () => {
const error = new InvalidTypeError("test");
expect(error.name).toBe("InvalidTypeError");
expect(error.message).toBe("test");
});
it("AiServiceError has correct name", () => {
const error = new AiServiceError("test");
expect(error.name).toBe("AiServiceError");
expect(error.message).toBe("test");
});
});
describe("refineText", () => {
// Note: refineText requires the AI engine which is not available in tests.
// These tests verify error handling when the engine is unavailable.
it("throws AiServiceError when AI engine is not available", async () => {
await expect(refineText("some text", "clarify", "/some/path")).rejects.toThrow(
AiServiceError
);
await expect(refineText("some text", "clarify", "/some/path")).rejects.toThrow(
"AI engine not available"
);
});
});
describe("__resetRefineState", () => {
it("clears all rate limit entries", () => {
const ip = "192.168.1.1";
// Make requests to populate rate limits
for (let i = 0; i < 5; i++) {
checkRateLimit(ip);
}
expect(getRateLimitResetTime(ip)).not.toBeNull();
// Reset state
__resetRefineState();
// Should be like starting fresh
expect(getRateLimitResetTime(ip)).toBeNull();
expect(checkRateLimit(ip)).toBe(true);
});
});
});

View File

@@ -0,0 +1,379 @@
/**
* AI Text Refinement Service
*
* Provides AI-powered text refinement for task descriptions.
* Supports multiple refinement types: clarify, add-details, expand, simplify.
*
* Features:
* - Rate limiting per IP (10 requests per hour)
* - Dynamic import of @kb/engine for AI agent creation
* - Text length validation (1-2000 characters)
*/
// Dynamic import for @kb/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 = "@kb/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();
// ── Types ───────────────────────────────────────────────────────────────────
/** Available refinement types */
export type RefinementType = "clarify" | "add-details" | "expand" | "simplify";
/** Valid refinement types for validation */
export const VALID_REFINEMENT_TYPES: RefinementType[] = [
"clarify",
"add-details",
"expand",
"simplify",
];
/** Request body for text refinement */
export interface RefineTextRequest {
text: string;
type: RefinementType;
}
/** Response body for text refinement */
export interface RefineTextResponse {
refined: string;
}
// ── Constants ───────────────────────────────────────────────────────────────
/** System prompt for text refinement */
export const REFINE_SYSTEM_PROMPT = `You are a text refinement assistant for a task management system.
Your job is to refine task descriptions based on the user's selected refinement type.
## Refinement Types
1. **clarify**: Make the description clearer and more specific
- Remove ambiguity
- Add specific details where vague
- Ensure the goal is well-defined
- Keep approximately the same length
2. **add-details**: Add implementation details and context
- Add technical considerations
- Include edge cases to consider
- Mention related files/components if apparent
- Expand moderately (1.5-2x length)
3. **expand**: Expand into a more comprehensive description
- Add background context
- Include acceptance criteria
- List specific sub-tasks or steps
- Significantly expand (2-3x length)
4. **simplify**: Simplify and make more concise
- Remove redundant words
- Use concise language
- Keep core meaning intact
- Reduce length significantly (0.5-0.7x)
## Guidelines
- Maintain the original intent and meaning
- Keep the tone professional and actionable
- Output ONLY the refined text, no markdown formatting, no explanations
- The output should be a direct replacement for the input text`;
/** Maximum text length in characters */
export const MAX_TEXT_LENGTH = 2000;
/** Minimum text length in characters */
export const MIN_TEXT_LENGTH = 1;
/** 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 refinement 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-refine] Cleanup: removed ${cleanedRateLimits} rate limit entries`);
}
}
// Start cleanup interval
const cleanupInterval = setInterval(cleanupExpiredRateLimits, CLEANUP_INTERVAL_MS);
// Handle graceful shutdown
process.on("beforeExit", () => {
clearInterval(cleanupInterval);
});
// ── Validation ──────────────────────────────────────────────────────────────
/**
* Validate refinement request.
* Throws appropriate errors for invalid input.
*/
export function validateRefineRequest(
text: unknown,
type: unknown
): { text: string; type: RefinementType } {
// Validate text exists
if (text === undefined || text === null) {
throw new ValidationError("text is required");
}
// Validate text is a string
if (typeof text !== "string") {
throw new ValidationError("text must be a string");
}
// Validate text length
if (text.length < MIN_TEXT_LENGTH) {
throw new ValidationError(
`text must be at least ${MIN_TEXT_LENGTH} character${MIN_TEXT_LENGTH === 1 ? "" : "s"}`
);
}
if (text.length > MAX_TEXT_LENGTH) {
throw new ValidationError(
`text must not exceed ${MAX_TEXT_LENGTH} characters`
);
}
// Validate type exists
if (type === undefined || type === null) {
throw new ValidationError("type is required");
}
// Validate type is a valid refinement type
if (!VALID_REFINEMENT_TYPES.includes(type as RefinementType)) {
throw new InvalidTypeError(
`type must be one of: ${VALID_REFINEMENT_TYPES.join(", ")}`
);
}
return { text, type: type as RefinementType };
}
// ── AI Integration ───────────────────────────────────────────────────────────
/**
* Refine text using AI agent.
* @param text - The text to refine
* @param type - The type of refinement to apply
* @param rootDir - Project root directory for AI agent context
* @returns The refined text
*/
export async function refineText(
text: string,
type: RefinementType,
rootDir: string
): Promise<string> {
// Ensure engine is loaded before using createKbAgent
await engineReady;
if (!createKbAgent) {
throw new AiServiceError("AI engine not available");
}
const agentResult = await createKbAgent({
cwd: rootDir,
systemPrompt: REFINE_SYSTEM_PROMPT,
tools: "readonly",
});
if (!agentResult?.session) {
throw new AiServiceError("Failed to initialize AI agent");
}
// Build the prompt with type instruction
const prompt = `Refinement type: ${type}\n\nText to refine:\n${text}`;
try {
// Send message to agent and get response
await agentResult.session.prompt(prompt);
// 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 refinedText = "";
if (lastMessage?.content) {
// Handle both string and array content types
if (typeof lastMessage.content === "string") {
refinedText = lastMessage.content.trim();
} else if (Array.isArray(lastMessage.content)) {
// Extract text from content blocks
refinedText = 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 (!refinedText) {
throw new AiServiceError("AI returned empty response");
}
// Dispose the agent session
try {
agentResult.session.dispose?.();
} catch {
// Ignore disposal errors
}
return refinedText;
} catch (err) {
// Ensure session is disposed even on error
try {
agentResult.session.dispose?.();
} catch {
// Ignore disposal errors
}
if (err instanceof AiServiceError) {
throw err;
}
throw new AiServiceError(
err instanceof Error ? err.message : "AI processing failed"
);
}
}
// ── Custom Errors ───────────────────────────────────────────────────────────
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
export class InvalidTypeError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidTypeError";
}
}
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";
}
}
// ── Test Helpers ───────────────────────────────────────────────────────────
/**
* Reset all refinement state. Used for testing only.
*/
export function __resetRefineState(): void {
rateLimits.clear();
}

View File

@@ -3800,6 +3800,72 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* POST /api/ai/refine-text
* AI-powered text refinement for task descriptions.
* Body: { text: string, type: string }
* Returns: { refined: string }
*
* Refinement types: clarify, add-details, expand, simplify
* Rate limited: 10 requests per hour per IP
*/
router.post("/ai/refine-text", async (req, res) => {
try {
const { text, type } = req.body;
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = store.getRootDir();
const {
validateRefineRequest,
checkRateLimit,
getRateLimitResetTime,
refineText,
RateLimitError,
ValidationError,
InvalidTypeError,
AiServiceError,
} = await import("./ai-refine.js");
// Check rate limit first
if (!checkRateLimit(ip)) {
const resetTime = getRateLimitResetTime(ip);
res.status(429).json({
error: `Rate limit exceeded. Maximum 10 refinement requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`,
});
return;
}
// Validate request body
let validated;
try {
validated = validateRefineRequest(text, type);
} catch (err) {
if (err instanceof ValidationError) {
res.status(400).json({ error: err.message });
return;
}
if (err instanceof InvalidTypeError) {
res.status(422).json({ error: err.message });
return;
}
throw err;
}
// Process refinement
const refined = await refineText(validated.text, validated.type, rootDir);
res.json({ refined });
} 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(500).json({ error: err.message || "AI service error" });
} else {
res.status(500).json({ error: err?.message || "Failed to refine text" });
}
}
});
/**
* GET /api/usage
* Fetch AI provider subscription usage (Claude, Codex, Gemini).