diff --git a/tools/v2/individual/draft-improver/docs/CORE_CONTRACT.md b/tools/v2/individual/draft-improver/docs/CORE_CONTRACT.md new file mode 100644 index 00000000..7877e5da --- /dev/null +++ b/tools/v2/individual/draft-improver/docs/CORE_CONTRACT.md @@ -0,0 +1,75 @@ +# Draft Improver Core Contract + +This document describes the folder-local Draft Improver engine added for the +core feature issue. It is deterministic and does not call an LLM, live mailbox, +remote API, database, wallet, or production service. + +## Inputs + +`improveDraft(request, options)` accepts a plain object with: + +- `id`: stable local request identifier. +- `subject`: optional draft subject. +- `body`: required draft body. +- `goal`: one of `clarity`, `concise`, `friendly`, `professional`, or + `call-to-action`. +- `audience`: optional recipient name used for a safe greeting. +- `senderName`: optional sender name used for a safe signoff. +- `deadline`: optional text used only when adding a call to action. +- `context`: optional reviewer context clipped to the local limit. +- `channel`: optional channel label, defaulting to `email`. + +`options.now` fixes the generated timestamp for deterministic tests. +`options.limits` can override local subject, body, and context clipping limits. + +## Outputs + +Successful calls return: + +- `status: "ready"` +- `isLoading: false` +- `error: null` +- `result.input`: normalized copy of the accepted request. +- `result.output`: improved `subject`, improved `body`, and a short `preview`. +- `result.issues`: issues detected before improvement. +- `result.remainingIssues`: issues still needing reviewer attention. +- `result.changes`: reviewable change records grouped by type. +- `result.warnings`: clipping warnings for oversized local input. +- `result.metrics`: word counts, reduction percentage, reading time, and issue + count. +- `result.reviewRequired`: true when a medium severity issue remains. + +## Loading State + +`createDraftImproverLoadingState(message)` returns the shape future UI work can +render before a local improvement run finishes: + +- `status: "loading"` +- `isLoading: true` +- `error: null` +- `result: null` +- `message`: reviewer-facing progress text. + +## Error State + +Invalid requests do not throw. They return: + +- `status: "error"` +- `isLoading: false` +- `error.code: "draft-improver-error"` +- `error.messages`: validation messages. +- `result: null` +- `requestId`: the local request id when available. + +The validator rejects empty bodies and active markup such as script tags or +`javascript:` URLs. This keeps the core engine reviewable without overlapping +the future app shell, routing, mail rendering, wallet, Stellar, or database +integration work. + +## Local Review Command + +Run from the repository root: + +```bash +node --test tools/v2/individual/draft-improver/tests/draft-improver-core.test.mjs +``` diff --git a/tools/v2/individual/draft-improver/fixtures/sample-core-draft-improvements.json b/tools/v2/individual/draft-improver/fixtures/sample-core-draft-improvements.json new file mode 100644 index 00000000..afc55262 --- /dev/null +++ b/tools/v2/individual/draft-improver/fixtures/sample-core-draft-improvements.json @@ -0,0 +1,62 @@ +{ + "tool": "draft-improver", + "runContext": { + "now": "2026-07-01T00:00:00.000Z", + "source": "synthetic-folder-local-fixture" + }, + "sourceRequests": [ + { + "id": "draft-clarity-001", + "subject": "", + "goal": "clarity", + "audience": "Jordan", + "senderName": "Mira", + "deadline": "Friday", + "channel": "email", + "context": "Follow up on an internal project plan.", + "body": "just wanted to circle back on the migration plan. i think maybe we should approve the staging checklist and share owners for the launch items" + }, + { + "id": "draft-concise-002", + "subject": "Requesting the updated launch copy", + "goal": "concise", + "audience": "Avery", + "senderName": "Noah", + "deadline": "2026-07-05", + "channel": "email", + "context": "Ask for a shorter launch copy update.", + "body": "Hi Avery,\n\nI am really writing in order to ask whether you can very quickly send the updated launch copy. It is very important for the review packet and we really need it by the requested date.\n\nThanks,\nNoah" + }, + { + "id": "draft-friendly-003", + "subject": "Invoice attachment", + "goal": "friendly", + "audience": "Sam", + "senderName": "Riley", + "deadline": "", + "channel": "email", + "context": "Make a direct request warmer.", + "body": "Please send the invoice attachment again. The file did not come through on my side." + } + ], + "expectedOutcomes": [ + { + "id": "draft-clarity-001", + "status": "improved", + "requiredChangeTypes": ["subject", "wording", "structure", "action"], + "expectedSubjectPrefix": "Draft" + }, + { + "id": "draft-concise-002", + "status": "improved", + "requiredChangeTypes": ["concise"], + "expectedSubjectPrefix": "Requesting" + }, + { + "id": "draft-friendly-003", + "status": "improved", + "requiredChangeTypes": ["tone", "structure"], + "expectedSubjectPrefix": "Invoice" + } + ] +} diff --git a/tools/v2/individual/draft-improver/index.mjs b/tools/v2/individual/draft-improver/index.mjs new file mode 100644 index 00000000..ab5f97d7 --- /dev/null +++ b/tools/v2/individual/draft-improver/index.mjs @@ -0,0 +1,8 @@ +export { + createDraftImproverErrorState, + createDraftImproverLoadingState, + draftImproverCore, + improveDraft, + improveDraftBatch, + validateDraftImproverRequest, +} from "./services/draft-improver.service.mjs"; diff --git a/tools/v2/individual/draft-improver/services/draft-improver.service.mjs b/tools/v2/individual/draft-improver/services/draft-improver.service.mjs new file mode 100644 index 00000000..cf9f2105 --- /dev/null +++ b/tools/v2/individual/draft-improver/services/draft-improver.service.mjs @@ -0,0 +1,552 @@ +const SUPPORTED_GOALS = new Set([ + "clarity", + "concise", + "friendly", + "professional", + "call-to-action", +]); + +const DEFAULT_LIMITS = { + maxSubjectChars: 120, + maxBodyChars: 4000, + maxContextChars: 800, +}; + +const FILLER_REPLACEMENTS = [ + [/\bjust wanted to\b/gi, "wanted to"], + [/\bi think maybe\b/gi, "I recommend"], + [/\bkind of\b/gi, ""], + [/\bsort of\b/gi, ""], + [/\bas soon as possible\b/gi, "by the requested date"], + [/\bat your earliest convenience\b/gi, "when you have a moment"], + [/\bcircle back\b/gi, "follow up"], +]; + +const CASUAL_REPLACEMENTS = [ + [/\bhey\b/gi, "Hi"], + [/\bthx\b/gi, "Thank you"], + [/\bu\b/g, "you"], + [/\bur\b/g, "your"], +]; + +const ISSUE_CHECKS = [ + { + code: "empty-subject", + severity: "medium", + test: (request) => request.subject.length === 0, + message: "Subject is empty, so reviewers need a generated subject suggestion.", + }, + { + code: "long-paragraph", + severity: "low", + test: (request) => request.body.split(/\n{2,}/).some((part) => countWords(part) > 70), + message: "One or more paragraphs are long enough to reduce scanability.", + }, + { + code: "missing-call-to-action", + severity: "medium", + test: (request) => !hasCallToAction(request.body), + message: "Draft does not contain a clear next action.", + }, + { + code: "filler-language", + severity: "low", + test: (request) => + FILLER_REPLACEMENTS.some(([pattern]) => { + pattern.lastIndex = 0; + return pattern.test(request.body); + }), + message: "Draft contains filler wording that can be tightened.", + }, +]; + +function countWords(value) { + const matches = String(value) + .trim() + .match(/[A-Za-z0-9']+/g); + return matches ? matches.length : 0; +} + +function clipText(value, limit) { + const text = String(value ?? ""); + if (text.length <= limit) { + return { + text, + clipped: false, + }; + } + + return { + text: text.slice(0, limit).trimEnd(), + clipped: true, + }; +} + +function normalizeWhitespace(value) { + return String(value ?? "") + .replace(/\r\n/g, "\n") + .split(/\n{2,}/) + .map((part) => part.replace(/[ \t]+/g, " ").trim()) + .filter(Boolean) + .join("\n\n"); +} + +function normalizeGoal(goal) { + const normalized = String(goal ?? "clarity") + .trim() + .toLowerCase(); + return SUPPORTED_GOALS.has(normalized) ? normalized : "clarity"; +} + +function titleCase(value) { + return String(value ?? "") + .trim() + .split(/\s+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +function firstName(value) { + const name = String(value ?? "").trim(); + if (!name) { + return ""; + } + return name.split(/\s+/)[0].replace(/[^A-Za-z'-]/g, ""); +} + +function hasGreeting(body) { + return /^(hi|hello|dear|good morning|good afternoon|good evening)\b/i.test(body.trim()); +} + +function hasSignoff(body) { + return /\n\s*(best|thanks|thank you|regards|sincerely),?\s*\n?/i.test(body); +} + +function hasCallToAction(body) { + return /\b(please|could you|can you|reply|confirm|review|let me know)\b/i.test(body); +} + +function sentenceCase(value) { + const text = String(value ?? "").trim(); + if (!text) { + return ""; + } + return text.charAt(0).toUpperCase() + text.slice(1); +} + +function buildSubject(request) { + if (request.subject) { + return request.subject; + } + + const words = request.body + .replace(/[^A-Za-z0-9' ]+/g, " ") + .split(/\s+/) + .filter((word) => word.length > 2) + .slice(0, 7); + + if (request.goal === "call-to-action") { + return `Action requested: ${titleCase(words.join(" ")) || "Draft Follow Up"}`; + } + + return `Draft: ${titleCase(words.join(" ")) || "Follow Up"}`; +} + +function applyPhraseReplacements(body, replacements) { + let updated = body; + const changes = []; + + for (const [pattern, replacement] of replacements) { + const before = updated; + updated = updated.replace(pattern, replacement); + updated = updated.replace(/[ \t]{2,}/g, " "); + + if (updated !== before) { + changes.push({ + type: "wording", + label: `Replaced ${pattern.source}`, + }); + } + } + + return { + body: updated, + changes, + }; +} + +function splitLongParagraphs(body) { + const paragraphs = body.split(/\n{2,}/); + let changed = false; + + const updated = paragraphs.map((paragraph) => { + if (countWords(paragraph) <= 70) { + return paragraph; + } + + const sentences = paragraph.match(/[^.!?]+[.!?]?/g) ?? [paragraph]; + if (sentences.length < 2) { + return paragraph; + } + + changed = true; + const midpoint = Math.ceil(sentences.length / 2); + return `${sentences.slice(0, midpoint).join("").trim()}\n\n${sentences + .slice(midpoint) + .join("") + .trim()}`; + }); + + return { + body: updated.join("\n\n"), + changed, + }; +} + +function addGreeting(body, audience) { + if (hasGreeting(body)) { + return { + body, + changed: false, + }; + } + + const recipient = firstName(audience); + const greeting = recipient ? `Hi ${recipient},` : "Hi,"; + return { + body: `${greeting}\n\n${body}`, + changed: true, + }; +} + +function addSignoff(body, senderName) { + if (!senderName || hasSignoff(body)) { + return { + body, + changed: false, + }; + } + + return { + body: `${body}\n\nBest,\n${senderName}`, + changed: true, + }; +} + +function addCallToAction(body, request) { + if (hasCallToAction(body)) { + return { + body, + changed: false, + }; + } + + const deadline = request.deadline ? ` by ${request.deadline}` : ""; + const action = `Please reply with the next step${deadline}.`; + return { + body: `${body}\n\n${action}`, + changed: true, + }; +} + +function buildIssues(request) { + return ISSUE_CHECKS.filter((check) => check.test(request)).map((check) => ({ + code: check.code, + severity: check.severity, + message: check.message, + })); +} + +function buildPreview(body) { + const compact = body.replace(/\s+/g, " ").trim(); + return compact.length > 180 ? `${compact.slice(0, 177)}...` : compact; +} + +function buildMetrics(originalBody, improvedBody, issues) { + const originalWordCount = countWords(originalBody); + const improvedWordCount = countWords(improvedBody); + const reductionPercent = + originalWordCount === 0 + ? 0 + : Math.round(((originalWordCount - improvedWordCount) / originalWordCount) * 100); + + return { + originalWordCount, + improvedWordCount, + reductionPercent, + readingTimeMinutes: Math.max(1, Math.ceil(improvedWordCount / 225)), + issueCount: issues.length, + }; +} + +function normalizeRequest(request, limits) { + const subject = clipText(normalizeWhitespace(request?.subject), limits.maxSubjectChars); + const body = clipText(normalizeWhitespace(request?.body), limits.maxBodyChars); + const context = clipText(normalizeWhitespace(request?.context), limits.maxContextChars); + + return { + id: String(request?.id ?? "draft-request").trim() || "draft-request", + subject: subject.text, + body: body.text, + goal: normalizeGoal(request?.goal), + audience: normalizeWhitespace(request?.audience), + senderName: normalizeWhitespace(request?.senderName), + deadline: normalizeWhitespace(request?.deadline), + context: context.text, + channel: normalizeWhitespace(request?.channel) || "email", + clipped: { + subject: subject.clipped, + body: body.clipped, + context: context.clipped, + }, + }; +} + +export function validateDraftImproverRequest(request, options = {}) { + const limits = { + ...DEFAULT_LIMITS, + ...options.limits, + }; + const errors = []; + + if (!request || typeof request !== "object") { + errors.push({ + code: "invalid-request", + message: "Request must be an object.", + }); + return { + valid: false, + errors, + }; + } + + const body = String(request.body ?? "").trim(); + if (!body) { + errors.push({ + code: "empty-body", + message: "Draft body is required.", + }); + } + + if (/ limits.maxSubjectChars * 2) { + errors.push({ + code: "subject-too-long", + message: "Subject is too long for the local Draft Improver contract.", + }); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +export function createDraftImproverLoadingState(message = "Improving draft") { + return { + status: "loading", + isLoading: true, + error: null, + result: null, + message, + }; +} + +export function createDraftImproverErrorState(errors, requestId = null) { + const normalizedErrors = Array.isArray(errors) ? errors : [errors]; + + return { + status: "error", + isLoading: false, + error: { + code: "draft-improver-error", + messages: normalizedErrors.map((error) => + typeof error === "string" ? error : error.message || "Unknown draft improver error", + ), + }, + result: null, + requestId, + }; +} + +export function improveDraft(request, options = {}) { + const validation = validateDraftImproverRequest(request, options); + if (!validation.valid) { + return createDraftImproverErrorState(validation.errors, request?.id ?? null); + } + + const generatedAt = options.now ?? "2026-07-01T00:00:00.000Z"; + const limits = { + ...DEFAULT_LIMITS, + ...options.limits, + }; + const normalized = normalizeRequest(request, limits); + const originalBody = normalized.body; + const originalIssues = buildIssues(normalized); + const changes = []; + const warnings = []; + let improvedBody = normalized.body; + let improvedSubject = buildSubject(normalized); + + if (improvedSubject !== normalized.subject) { + changes.push({ + type: "subject", + label: "Generated a reviewable subject line", + before: normalized.subject, + after: improvedSubject, + }); + } + + for (const [key, clipped] of Object.entries(normalized.clipped)) { + if (clipped) { + warnings.push({ + code: `${key}-clipped`, + message: `${key} was clipped to stay within the local review limit.`, + }); + } + } + + const fillerResult = applyPhraseReplacements(improvedBody, FILLER_REPLACEMENTS); + improvedBody = fillerResult.body; + changes.push(...fillerResult.changes); + + if (normalized.goal === "professional") { + const professionalResult = applyPhraseReplacements(improvedBody, CASUAL_REPLACEMENTS); + improvedBody = professionalResult.body; + changes.push(...professionalResult.changes); + } + + if (normalized.goal === "concise") { + const before = improvedBody; + improvedBody = improvedBody + .replace(/\bvery\s+/gi, "") + .replace(/\breally\s+/gi, "") + .replace(/\bin order to\b/gi, "to") + .replace(/[ \t]{2,}/g, " "); + + if (improvedBody !== before) { + changes.push({ + type: "concise", + label: "Removed low-value intensifiers", + }); + } + } + + if (normalized.goal === "friendly") { + const before = improvedBody; + improvedBody = improvedBody.replace(/\bPlease send\b/g, "Could you send"); + if (improvedBody !== before) { + changes.push({ + type: "tone", + label: "Softened a direct request", + }); + } + } + + const paragraphResult = splitLongParagraphs(improvedBody); + improvedBody = paragraphResult.body; + if (paragraphResult.changed) { + changes.push({ + type: "structure", + label: "Split long paragraphs for reviewability", + }); + } + + const greetingResult = addGreeting(improvedBody, normalized.audience); + improvedBody = greetingResult.body; + if (greetingResult.changed) { + changes.push({ + type: "structure", + label: "Added a greeting", + }); + } + + const callToActionResult = addCallToAction(improvedBody, normalized); + improvedBody = callToActionResult.body; + if (callToActionResult.changed) { + changes.push({ + type: "action", + label: "Added a clear next action", + }); + } + + const signoffResult = addSignoff(improvedBody, normalized.senderName); + improvedBody = signoffResult.body; + if (signoffResult.changed) { + changes.push({ + type: "structure", + label: "Added a sender signoff", + }); + } + + improvedBody = sentenceCase(improvedBody); + const remainingIssues = buildIssues({ + ...normalized, + subject: improvedSubject, + body: improvedBody, + }); + const metrics = buildMetrics(originalBody, improvedBody, originalIssues); + const reviewRequired = remainingIssues.some((issue) => issue.severity === "medium"); + + return { + status: "ready", + isLoading: false, + error: null, + result: { + id: normalized.id, + status: reviewRequired ? "needs-review" : "improved", + generatedAt, + input: { + subject: normalized.subject, + body: originalBody, + goal: normalized.goal, + audience: normalized.audience, + senderName: normalized.senderName, + deadline: normalized.deadline, + context: normalized.context, + channel: normalized.channel, + }, + output: { + subject: improvedSubject, + body: improvedBody, + preview: buildPreview(improvedBody), + }, + issues: originalIssues, + remainingIssues, + changes, + warnings, + metrics, + reviewRequired, + }, + }; +} + +export function improveDraftBatch(requests, options = {}) { + const sourceRequests = Array.isArray(requests) ? requests : []; + const results = sourceRequests.map((request) => improveDraft(request, options)); + const readyResults = results.filter((response) => response.status === "ready"); + + return { + status: results.some((response) => response.status === "error") ? "needs-review" : "ready", + totalRequests: sourceRequests.length, + improved: readyResults.filter((response) => response.result.status === "improved").length, + needsReview: readyResults.filter((response) => response.result.status === "needs-review") + .length, + errors: results.filter((response) => response.status === "error").length, + responses: results, + }; +} + +export const draftImproverCore = { + improveDraft, + improveDraftBatch, + validateDraftImproverRequest, + createDraftImproverLoadingState, + createDraftImproverErrorState, +}; diff --git a/tools/v2/individual/draft-improver/tests/draft-improver-core.test.mjs b/tools/v2/individual/draft-improver/tests/draft-improver-core.test.mjs new file mode 100644 index 00000000..6f63270c --- /dev/null +++ b/tools/v2/individual/draft-improver/tests/draft-improver-core.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + createDraftImproverLoadingState, + improveDraft, + improveDraftBatch, + validateDraftImproverRequest, +} from "../index.mjs"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const fixturePath = join(currentDir, "..", "fixtures", "sample-core-draft-improvements.json"); +const validGoals = new Set(["clarity", "concise", "friendly", "professional", "call-to-action"]); + +async function loadFixture() { + const raw = await readFile(fixturePath, "utf8"); + return JSON.parse(raw); +} + +test("sample core draft fixture follows the isolated request contract", async () => { + const fixture = await loadFixture(); + + assert.equal(fixture.tool, "draft-improver"); + assert.match(fixture.runContext.now, /^\d{4}-\d{2}-\d{2}T/); + assert.ok(Array.isArray(fixture.sourceRequests), "sourceRequests must be an array"); + assert.ok(Array.isArray(fixture.expectedOutcomes), "expectedOutcomes must be an array"); + assert.equal(fixture.sourceRequests.length, fixture.expectedOutcomes.length); + + for (const request of fixture.sourceRequests) { + assert.ok(request.id, "request needs a stable id"); + assert.ok(validGoals.has(request.goal), `${request.id} uses a supported goal`); + assert.equal(typeof request.body, "string", `${request.id} body must be a string`); + assert.ok(request.body.length > 0, `${request.id} body must not be empty`); + assert.equal(request.context.includes("@"), false, `${request.id} must not include real email`); + } +}); + +test("improveDraft returns deterministic output with reviewable changes", async () => { + const fixture = await loadFixture(); + + for (const expected of fixture.expectedOutcomes) { + const request = fixture.sourceRequests.find((item) => item.id === expected.id); + const response = improveDraft(request, { now: fixture.runContext.now }); + + assert.equal(response.status, "ready"); + assert.equal(response.isLoading, false); + assert.equal(response.error, null); + assert.equal(response.result.id, expected.id); + assert.equal(response.result.status, expected.status); + assert.equal(response.result.generatedAt, fixture.runContext.now); + assert.ok(response.result.output.subject.startsWith(expected.expectedSubjectPrefix)); + assert.ok(response.result.output.body.length > 0); + assert.notEqual(response.result.output.body, request.body); + assert.ok(response.result.output.preview.length <= 180); + assert.ok(response.result.metrics.originalWordCount > 0); + assert.ok(response.result.metrics.improvedWordCount > 0); + + const changeTypes = new Set(response.result.changes.map((change) => change.type)); + for (const requiredType of expected.requiredChangeTypes) { + assert.ok(changeTypes.has(requiredType), `${expected.id} should include ${requiredType}`); + } + } +}); + +test("validation and state helpers expose loading and error contracts", () => { + const loading = createDraftImproverLoadingState(); + assert.equal(loading.status, "loading"); + assert.equal(loading.isLoading, true); + assert.equal(loading.result, null); + + const validation = validateDraftImproverRequest({ + id: "unsafe-draft", + body: "", + }); + + assert.equal(validation.valid, false); + assert.ok(validation.errors.some((error) => error.code === "active-content")); + + const response = improveDraft({ + id: "empty-draft", + body: "", + }); + + assert.equal(response.status, "error"); + assert.equal(response.isLoading, false); + assert.equal(response.result, null); + assert.ok(response.error.messages.some((message) => message.includes("required"))); +}); + +test("improveDraftBatch summarizes ready and errored requests", async () => { + const fixture = await loadFixture(); + const batch = improveDraftBatch( + [ + ...fixture.sourceRequests, + { + id: "empty-draft", + body: "", + }, + ], + { now: fixture.runContext.now }, + ); + + assert.equal(batch.totalRequests, fixture.sourceRequests.length + 1); + assert.equal(batch.status, "needs-review"); + assert.equal(batch.improved, fixture.sourceRequests.length); + assert.equal(batch.needsReview, 0); + assert.equal(batch.errors, 1); + assert.equal(batch.responses.length, fixture.sourceRequests.length + 1); +});