Tasks: 14 — Writing-Style Personalization
Status legend:
[ ]pending •[~]in-progress •[x]done •[-]dropped
Phase 1 — Descriptor contract & schema
-
T01 — Define the privacy-safe style-descriptor schema + structured artifact (#5)
- Files:
packages/ai/src/structured/schemas/writing-style.ts,packages/ai/src/structured/schemas/writing-style.test.ts - Acceptance:
- Exports
writingStyleSummarySchema(Zod):formality/warmth/hedgingnumbers 0–1,avgSentenceLenenumshort|medium|long,jargonLevelenumlow|medium|high,usesMetricsboolean,structurePrefenumbullet|paragraph|mixed; every string.max()-bounded (Article 8.3). - Exports
Artifact<"writing_style", WritingStyleSummary>viadefineArtifact("writing_style", 1, schema)from #5;schemaVersion = 1. - Schema contains no free-form text field capable of holding a raw sample.
- Unit test: valid descriptor object parses; out-of-range number (e.g.
formality: 2) and an unexpectedrawSamplekey both reject.
- Exports
- Estimate: 0.5 day
- Files:
-
T02 — Add
style_fingerprintstable and push it- Files:
packages/db/src/schema/style-fingerprints.ts,packages/db/src/schema/index.ts - Acceptance:
- Table
style_fingerprints:id integer().primaryKey().generatedAlwaysAsIdentity(),userId text().notNull().references(() => users.id, { onDelete: "cascade" }),descriptors jsonb().$type<WritingStyleSummary>(),schemaVersion integer().notNull().default(1),sampleCount integer().notNull().default(0),confidencenumeric/real,createdAt/updatedAt timestamp().notNull().defaultNow(); unique index onuserId(one current fingerprint per user); indexstyle_fingerprints_user_id_idx. - No raw-text column exists on the table (privacy invariant, spec §1/§3).
- Exported from
packages/db/src/schema/index.ts. pnpm db:pushapplies cleanly against the dev database.
- Table
- Estimate: 0.5 day
- Files:
Phase 2 — Extraction & capture (no raw text retained)
-
T03 — Implement deterministic + LLM descriptor extractor
- Files:
packages/ai/src/writing-style/extract.ts,packages/ai/src/writing-style/extract.test.ts - Acceptance:
extractStyleDescriptors(text: string)returns aWritingStyleSummaryonly — never the input text.- Deterministic features (avg sentence length → enum bucket, bullet ratio →
structurePref, hedging-marker count →hedging, metric/number presence →usesMetrics) computed in plain TS. - Tone fields (
formality,warmth,jargonLevel) via a boundedgenerateObject({ schema })pass with a deterministic fallback. - Privacy invariant test: for a sample input, no contiguous ≥8-char substring of the input appears anywhere in
JSON.stringify(output). - Output round-trips through
writingStyleSummarySchema.parse().
- Estimate: 1 day
- Files:
-
T04 — Implement fingerprint merge (EMA fold)
- Files:
packages/ai/src/writing-style/merge.ts,packages/ai/src/writing-style/merge.test.ts - Acceptance:
mergeFingerprint(existing | null, incoming, sampleCount)returns the new descriptors + incrementedsampleCount.- Numeric descriptors fold via EMA; enum/boolean descriptors take the most-recent-weighted value.
- First call (no existing) returns
incomingwithsampleCount = 1. - Unit test: repeated folds of a stable input converge toward that input; a single outlier does not flip a stabilised fingerprint.
- Estimate: 0.5 day
- Files:
-
T05 — Add
updateWritingStyletool (capture on approve/edit, #13 hook)- Files:
packages/ai/src/tools/update-writing-style.ts,packages/ai/src/tools/update-writing-style.test.ts - Acceptance:
tool({ description, inputSchema, execute });inputSchemaaccepts the approved artifact text (.max()-bounded) and anartifactKind;userIdis.optional()and injected server-side, never LLM-supplied.executerunsextractStyleDescriptors→mergeFingerprint(reads existing row) → validates viaassertArtifact(#5) → upserts thestyle_fingerprintsrow viadbfrom@ever-hust/db.- Returns descriptors-only structured result (kind
"updateWritingStyle"); never returns raw text. - Unit test: missing
userIdreturns a not-authenticated result; a valid call upserts a row and the stored row contains no raw input text.
- Estimate: 1 day
- Files:
-
T06 — Export + register the tool in the orchestrator
- Files:
packages/ai/src/tools/index.ts,packages/ai/src/agents/orchestrator.ts,packages/ai/src/agents/orchestrator.test.ts - Acceptance:
updateWritingStyleToolexported frompackages/ai/src/tools/index.ts.- Registered in the
tools: { ... }object insidecreateOrchestratorStream'sstreamTextwith the userId-injection wrapper (matchingsavePreferences/favoriteJob). stopWhen: stepCountIs(5)unchanged.- Orchestrator test asserts
updateWritingStyleis present in the tool set and thatuserIdis injected, not accepted from the model.
- Estimate: 0.5 day
- Files:
Phase 3 — Application to generation (#10 / #17)
-
T07 — Build the style-guidance + load helpers
- Files:
packages/ai/src/writing-style/guidance.ts,packages/ai/src/writing-style/load.ts,packages/ai/src/writing-style/guidance.test.ts - Acceptance:
loadWritingStyle(userId)reads the user'sstyle_fingerprintsrow (ornull).buildStyleGuidance(summary)returns a compact tone-only instruction string (e.g. "short sentences; moderate formality; quantify with metrics where supported") — contains no content claims and no raw text.- Low-confidence fingerprints (small
sampleCount) produce a softer guidance string. - Unit test:
nullsummary yields empty guidance (no behaviour change); a populated summary yields a deterministic, descriptor-derived string.
- Estimate: 0.5 day
- Files:
-
T08 — Apply style guidance to cover-letter generation (#10), additively
- Files:
packages/ai/src/tools/generate-cover-letter.ts,packages/ai/src/tools/generate-cover-letter.test.ts - Acceptance:
- When a fingerprint exists,
loadWritingStyle+buildStyleGuidanceadds astyleGuidancefield to the returnedcontext/instruction. - When no fingerprint exists, the returned object is byte-identical to today (additive only, non-negotiable #9).
- No-invent discipline (#6
assertNoInvented) unaffected; style is tone-only. - Unit test covers both branches (fingerprint present vs absent).
- When a fingerprint exists,
- Estimate: 0.5 day
- Files:
-
T09 — Apply style guidance to outreach drafts (#17)
- Files:
packages/ai/src/tools/outreach draft tool (per #17) + its*.test.ts - Acceptance:
- The #17 outreach tool's context optionally carries the
styleGuidanceblock viabuildStyleGuidancewhen a fingerprint exists; absent fingerprint = unchanged output. - Draft-only / HITL posture from #17 + #6 preserved; nothing is sent.
- Unit test asserts the block is present only when a fingerprint exists.
- If #17's tool is not yet merged, mark this task blocked-on-#17 and ship the shared helper (T07) so wiring is trivial later.
- The #17 outreach tool's context optionally carries the
- Estimate: 0.5 day
- Files:
-
T10 — Document the tool + style-aware behaviour in the system prompt
- Files:
packages/ai/src/prompts.ts,packages/ai/src/prompts.test.ts(Langfuse promptorchestrator-systemupdated separately in Langfuse Cloud) - Acceptance:
DEFAULT_ORCHESTRATOR_PROMPTlistsupdateWritingStyleunder capabilities and adds a short "Writing Style" section: call it after the user approves/edits a generated artifact; style is applied to future letters/outreach; never store or quote raw text.- Prompt test asserts the new tool name and a "writing style" cue are present.
- Note in the file points to updating the Langfuse
orchestrator-systemproduction prompt to match.
- Estimate: 0.5 day
- Files:
Phase 4 — View & tune UI + canvas sync
-
T11 — Canvas card to surface the fingerprint
- Files:
apps/web/components/canvas/writing-style-card.tsx - Acceptance:
- Built from the
apps/web/components/canvas/salary-insights-card.tsxoverlay template; uses@ever-hust/ui/{card,badge}+cn()from@ever-hust/ui/lib/utils. - Renders descriptors as readable chips/bars (formality, sentence length, metrics usage, structure); shows a "still learning" state for low
sampleCount. - Displays no raw text (there is none to display).
- Built from the
- Estimate: 0.5 day
- Files:
-
T12 — Canvas-sync case for the new tool result
- Files:
apps/web/hooks/use-canvas-sync.ts,apps/web/hooks/use-canvas-sync.test.ts - Acceptance:
- New
case "updateWritingStyle"inhandleToolResultstores the descriptors in canvas state and surfaces the writing-style card. - Adds
writingStyletoCanvasState+ aclearWritingStylecallback, mirroring thesalaryInsightspattern. - Unit test: dispatching an
updateWritingStyleresult updates state; unknown tools still hit the default branch.
- New
- Estimate: 0.5 day
- Files:
-
T13 — Settings card to view + tune descriptors
- Files:
apps/web/components/settings/writing-style-card.tsx,apps/web/components/settings/types.ts(if a shared type is needed), settings page registration - Acceptance:
- Card shows current descriptors and plain-language nudges ("more concise", "less formal") per spec §3, persisted as user overrides that win over auto-derived values (#13 two-layer, user-wins).
- No raw-text editor — descriptor tuning only.
- Registered alongside the other cards on the settings page.
- Estimate: 0.5 day
- Files:
-
T14 — API route: GET descriptors + PATCH override nudges
- Files:
apps/web/app/api/user/writing-style/route.ts,apps/web/lib/api-schemas.ts,apps/web/app/api/user/writing-style/route.test.ts(web-lib project) - Acceptance:
- GET returns the user's descriptors (or empty); PATCH applies a bounded override nudge.
- Uses
requireSessionUser(),applyRateLimit(userId, "authenticated"), Zod request schema fromapps/web/lib/api-schemas.ts, errors viaapiBadRequest()/apiError()fromapps/web/lib/api-response.ts. - PATCH validates descriptor bounds (0–1 / enum) and persists override; never accepts raw text.
- Unit test: unauthenticated → 401-style response; valid PATCH persists; invalid descriptor rejects.
- Estimate: 1 day
- Files:
-
T15 — E2E: view fingerprint and tune it
- Files:
tests/e2e/writing-style.spec.ts - Acceptance:
- Playwright (baseURL
http://localhost:8443): authenticated user opens settings, sees the writing-style card, applies a "more concise" nudge, and the persisted descriptor reflects it on reload. - Asserts no raw writing sample is shown anywhere in the UI.
pnpm test:e2epasses.
- Playwright (baseURL
- Estimate: 1 day
- Files:
Notes
- Write tests alongside each implementation task; do not batch testing into a final task.
- Run package tests with
pnpm test -- --selectProjects ai(anddb,web-lib) and E2E withpnpm test:e2e. - Privacy invariant is load-bearing: every task that touches text must keep raw samples out of storage and out of LLM context (spec §1, Article 8).
- Verify zero competitor references before every commit (constitution Article 11).
- CI (lint, type-check, unit, E2E) must be green before merge; work lands on
develop(Article 10.4). - Update
docs/specs/ROADMAP.mdprogress when this epic's tasks complete.