diff --git a/documentation/README.md b/documentation/README.md index 8664415..1fbedde 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -18,6 +18,12 @@ Welcome to the JobOps documentation. This folder contains comprehensive guides f - PDF generation and regeneration - Post-application tracking overview +- **[Ghostwriter](./ghostwriter.md)** - Context-aware per-job chat assistant + - One persistent conversation per job + - Streaming responses, stop, and regenerate + - Markdown rendering and drawer UX behavior + - Writing style settings impact + - **[Post-Application Tracking](./post-application-tracking.md)** - Email-to-job matching - How the Smart Router AI works - Gmail integration setup @@ -58,6 +64,7 @@ JobOps uses specialized extractors to gather jobs from different sources: documentation/ ├── self-hosting.md # Deployment guide ├── orchestrator.md # Core workflow documentation +├── ghostwriter.md # Ghostwriter feature documentation ├── post-application-tracking.md # Email tracking feature └── extractors/ # Job source extractors ├── README.md diff --git a/documentation/ghostwriter.md b/documentation/ghostwriter.md new file mode 100644 index 0000000..1ba8b9c --- /dev/null +++ b/documentation/ghostwriter.md @@ -0,0 +1,60 @@ +# Ghostwriter + +Ghostwriter is the per-job AI chat assistant in JobOps. It is optional to use and is designed for drafting application content with job-specific context already loaded. + +## What Ghostwriter is for + +Ghostwriter is not a generic chat box. For each job, it uses: + +- The current job description and job metadata +- A reduced snapshot of your resume/profile +- Your global Ghostwriter writing style settings + +This makes it useful for: + +- Drafting role-specific answers +- Cover letter and outreach drafts +- Interview prep talking points tied to the current JD +- Rephrasing content to match your preferred style + +## Where it appears + +- Available from job details in `discovered` and `ready` flows +- Opens as a right-side drawer +- One persistent conversation per job + +## Writing style settings impact + +Ghostwriter settings are global and affect new generations: + +- `Tone`: adds a tone instruction in the Ghostwriter system prompt +- `Formality`: adds a formality instruction +- `Constraints`: appended as explicit writing constraints +- `Do-not-use terms`: appended as language to avoid + +Defaults: + +- Tone: `professional` +- Formality: `medium` +- Constraints: empty +- Do-not-use terms: empty + +## Context + safety model + +Ghostwriter context is assembled server-side with size limits and sanitization: + +- Job snapshot is truncated to fit prompt budget +- Profile snapshot includes only relevant slices (summary, skills, projects, experience) +- System prompt enforces read-only assistant behavior +- Logging stores metadata only (not raw full prompt/response dumps) + +## API surface (current) + +Primary per-job endpoints: + +- `GET /api/jobs/:id/chat/messages` +- `POST /api/jobs/:id/chat/messages` (supports streaming) +- `POST /api/jobs/:id/chat/runs/:runId/cancel` +- `POST /api/jobs/:id/chat/messages/:assistantMessageId/regenerate` (supports streaming) + +Compatibility endpoints for thread resources remain present, but UI behavior is one conversation per job. diff --git a/documentation/orchestrator.md b/documentation/orchestrator.md index a73fec3..0619a99 100644 --- a/documentation/orchestrator.md +++ b/documentation/orchestrator.md @@ -30,10 +30,23 @@ There are two main ways a job becomes Ready: Once a job is `ready`, the Ready panel is the "shipping lane": - View/download the PDF. +- Open Ghostwriter for context-aware drafting tied to the current job. - Open the job listing. - Mark Applied (moves to `applied`). - Optional: edit tailoring, edit the JD, or regenerate the PDF. +## Ghostwriter (per-job context chat) + +Ghostwriter is always on and is available in both `discovered` and `ready` job views. + +- It uses the current job context, profile context, and your global writing style settings. +- Conversation state is persistent per job. +- Responses stream in real time and can be cancelled. +- Regenerate is available for the last assistant response. +- Messages render as Markdown. + +For full details and API surface, see [Ghostwriter](./ghostwriter.md). + ## Generating PDFs (first time) The PDF is generated from: diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 35b3b77..049042e 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -15,6 +15,9 @@ import type { BulkPostApplicationActionResponse, DemoInfoResponse, Job, + JobChatMessage, + JobChatStreamEvent, + JobChatThread, JobListItem, JobOutcome, JobSource, @@ -390,6 +393,264 @@ export async function updateJob( }); } +async function streamSseEvents( + endpoint: string, + input: Record, + handlers: { + onEvent: (event: JobChatStreamEvent) => void; + signal?: AbortSignal; + }, +): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (cachedBasicAuthCredentials) { + headers.Authorization = encodeBasicAuth(cachedBasicAuthCredentials); + } + + const response = await fetch(`${API_BASE}${endpoint}`, { + method: "POST", + headers, + body: JSON.stringify(input), + signal: handlers.signal, + }); + + if (!response.ok) { + let errorMessage = `Stream request failed with status ${response.status}`; + try { + const payload = await response.json(); + const parsed = normalizeApiResponse(payload); + if ("ok" in parsed && !parsed.ok) { + errorMessage = parsed.error.message || errorMessage; + } + } catch { + // ignore parse errors; keep status-based message + } + throw new ApiClientError(errorMessage, { + status: response.status, + }); + } + + if (!response.body) { + throw new ApiClientError("Streaming not supported by this browser"); + } + + const decoder = new TextDecoder(); + const reader = response.body.getReader(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + let separatorIndex = buffer.indexOf("\n\n"); + while (separatorIndex !== -1) { + const frame = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + const dataLines = frame + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()) + .filter(Boolean); + + for (const line of dataLines) { + try { + handlers.onEvent(JSON.parse(line) as JobChatStreamEvent); + } catch { + // Ignore malformed events to keep stream resilient + } + } + separatorIndex = buffer.indexOf("\n\n"); + } + } +} + +export async function listJobChatThreads(jobId: string): Promise<{ + threads: JobChatThread[]; +}> { + return fetchApi<{ threads: JobChatThread[] }>(`/jobs/${jobId}/chat/threads`); +} + +export async function listJobGhostwriterMessages( + jobId: string, + options?: { limit?: number; offset?: number }, +): Promise<{ messages: JobChatMessage[] }> { + const params = new URLSearchParams(); + if (typeof options?.limit === "number") { + params.set("limit", String(options.limit)); + } + if (typeof options?.offset === "number") { + params.set("offset", String(options.offset)); + } + const query = params.toString(); + return fetchApi<{ messages: JobChatMessage[] }>( + `/jobs/${jobId}/chat/messages${query ? `?${query}` : ""}`, + ); +} + +export async function createJobChatThread( + jobId: string, + input?: { title?: string | null }, +): Promise<{ thread: JobChatThread }> { + return fetchApi<{ thread: JobChatThread }>(`/jobs/${jobId}/chat/threads`, { + method: "POST", + body: JSON.stringify({ + title: input?.title ?? null, + }), + }); +} + +export async function listJobChatMessages( + jobId: string, + threadId: string, + options?: { limit?: number; offset?: number }, +): Promise<{ messages: JobChatMessage[] }> { + const params = new URLSearchParams(); + if (typeof options?.limit === "number") { + params.set("limit", String(options.limit)); + } + if (typeof options?.offset === "number") { + params.set("offset", String(options.offset)); + } + const query = params.toString(); + return fetchApi<{ messages: JobChatMessage[] }>( + `/jobs/${jobId}/chat/threads/${threadId}/messages${query ? `?${query}` : ""}`, + ); +} + +export async function sendJobChatMessage( + jobId: string, + threadId: string, + input: { content: string }, +): Promise<{ + userMessage: JobChatMessage; + assistantMessage: JobChatMessage | null; + runId: string; +}> { + return fetchApi<{ + userMessage: JobChatMessage; + assistantMessage: JobChatMessage | null; + runId: string; + }>(`/jobs/${jobId}/chat/threads/${threadId}/messages`, { + method: "POST", + body: JSON.stringify(input), + }); +} + +export async function streamJobChatMessage( + jobId: string, + threadId: string, + input: { content: string; signal?: AbortSignal }, + handlers: { + onEvent: (event: JobChatStreamEvent) => void; + }, +): Promise { + return streamSseEvents( + `/jobs/${jobId}/chat/threads/${threadId}/messages`, + { content: input.content, stream: true }, + { + onEvent: handlers.onEvent, + signal: input.signal, + }, + ); +} + +export async function streamJobGhostwriterMessage( + jobId: string, + input: { content: string; signal?: AbortSignal }, + handlers: { + onEvent: (event: JobChatStreamEvent) => void; + }, +): Promise { + return streamSseEvents( + `/jobs/${jobId}/chat/messages`, + { content: input.content, stream: true }, + { + onEvent: handlers.onEvent, + signal: input.signal, + }, + ); +} + +export async function cancelJobChatRun( + jobId: string, + threadId: string, + runId: string, +): Promise<{ cancelled: boolean; alreadyFinished: boolean }> { + return fetchApi<{ cancelled: boolean; alreadyFinished: boolean }>( + `/jobs/${jobId}/chat/threads/${threadId}/runs/${runId}/cancel`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); +} + +export async function cancelJobGhostwriterRun( + jobId: string, + runId: string, +): Promise<{ cancelled: boolean; alreadyFinished: boolean }> { + return fetchApi<{ cancelled: boolean; alreadyFinished: boolean }>( + `/jobs/${jobId}/chat/runs/${runId}/cancel`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); +} + +export async function regenerateJobChatMessage( + jobId: string, + threadId: string, + assistantMessageId: string, +): Promise<{ runId: string; assistantMessage: JobChatMessage | null }> { + return fetchApi<{ runId: string; assistantMessage: JobChatMessage | null }>( + `/jobs/${jobId}/chat/threads/${threadId}/messages/${assistantMessageId}/regenerate`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); +} + +export async function streamRegenerateJobChatMessage( + jobId: string, + threadId: string, + assistantMessageId: string, + input: { signal?: AbortSignal }, + handlers: { + onEvent: (event: JobChatStreamEvent) => void; + }, +): Promise { + return streamSseEvents( + `/jobs/${jobId}/chat/threads/${threadId}/messages/${assistantMessageId}/regenerate`, + { stream: true }, + { + onEvent: handlers.onEvent, + signal: input.signal, + }, + ); +} + +export async function streamRegenerateJobGhostwriterMessage( + jobId: string, + assistantMessageId: string, + input: { signal?: AbortSignal }, + handlers: { + onEvent: (event: JobChatStreamEvent) => void; + }, +): Promise { + return streamSseEvents( + `/jobs/${jobId}/chat/messages/${assistantMessageId}/regenerate`, + { stream: true }, + { + onEvent: handlers.onEvent, + signal: input.signal, + }, + ); +} + export async function processJob( id: string, options?: { force?: boolean }, diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 79b488b..d0066ce 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -50,6 +50,7 @@ import { useProfile } from "../hooks/useProfile"; import { useRescoreJob } from "../hooks/useRescoreJob"; import { FitAssessment, JobHeader, TailoredSummary } from "."; import { TailorMode } from "./discovered-panel/TailorMode"; +import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer"; import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer"; import { KbdHint } from "./KbdHint"; @@ -297,18 +298,10 @@ export const ReadyPanel: React.FC = ({ ───────────────────────────────────────────────────────────────────── */}
- {/* Show PDF - to verify quickly without download */} - + {/* Download PDF - primary artifact action */}