diff --git a/.changeset/refactor-google-docs-structured-return.md b/.changeset/refactor-google-docs-structured-return.md new file mode 100644 index 0000000000..a1bb3986ca --- /dev/null +++ b/.changeset/refactor-google-docs-structured-return.md @@ -0,0 +1,23 @@ +--- +--- + +refactor(addie): `read_google_doc` returns structured result — closes #2752, #2753, #2754, #2756. + +Four follow-up issues from the PR #2744 expert review bundled. They all share one file and the structured return subsumes the others: + +**#2754 — Structured return.** New `GoogleDocResult` type: `{ status: 'ok' | 'access_denied' | 'empty' | 'invalid_input' | 'unsupported_type' | 'error', title, body, format, mime_type, message, truncated }`. The LLM-facing `read_google_doc` tool now returns a JSON string of this shape instead of a pre-formatted markdown blob. Addie's prompt branches on `status` and forwards `title`/`body` to `propose_content.title`/`content` — no more string manipulation, no more "strip leading `# \\n\\n`" fragility. + +**#2756 — Sentinel collision.** The old code used `result.startsWith("I don't have access")` as the error signal, which false-positives on any doc whose body naturally starts with that phrase. Replaced with the `status` enum. The `GOOGLE_DOCS_ERROR_PREFIX` / `GOOGLE_DOCS_ACCESS_DENIED_PREFIX` constants are removed — no internal callers referenced them after migration. + +**#2753 — Path divergence.** Previously two different code paths produced subtly different markdown for the same doc (Drive API `text/markdown` export vs our custom `readViaDocsApi` converter). Both now flow through one `readGoogleDocStructured` function that normalizes its output into the structured shape before callers see it. No more "same doc, different output depending on URL format." + +**#2752 — Dead-code caps.** Unified: the 500KB inner cap in `readGoogleDocStructured` is what internal callers see (committee-document-indexer, content-curator). The LLM-facing handler caps at 30KB before JSON-stringifying — one doc can't dominate Sonnet's context window. The old 15K outer cap that was overriding the 500KB inner caps is gone. + +**Migrations:** +- `committee-document-indexer.ts` → uses new `createGoogleDocsReader()` factory, branches on `status` +- `content-curator.ts` → same pattern +- The legacy string-returning `readGoogleDoc` wrapper is preserved for any transitional internal caller, implemented as a thin formatter over the structured result + +Tests: 22 unit tests pass (5 new — reader/handler factories return null when creds missing, valid factory when creds present, `GoogleDocResult` status/format contract). Typecheck clean. + +Remaining epic #2693 follow-ups: #2735 channel privacy TOCTOU, #2736 interactive Slack DMs, #2755 web Addie rate limit. diff --git a/server/src/addie/jobs/committee-document-indexer.ts b/server/src/addie/jobs/committee-document-indexer.ts index f38a1f2b82..06f63efb42 100644 --- a/server/src/addie/jobs/committee-document-indexer.ts +++ b/server/src/addie/jobs/committee-document-indexer.ts @@ -18,9 +18,7 @@ import { logger } from '../../logger.js'; import { WorkingGroupDatabase } from '../../db/working-group-db.js'; import { isGoogleDocsUrl, - createGoogleDocsToolHandlers, - GOOGLE_DOCS_ERROR_PREFIX, - GOOGLE_DOCS_ACCESS_DENIED_PREFIX, + createGoogleDocsReader, } from '../mcp/google-docs.js'; import { isLLMConfigured, complete } from '../../utils/llm.js'; import { PDFParse } from 'pdf-parse'; @@ -62,9 +60,9 @@ async function fetchGoogleDocContent(url: string): Promise<{ error?: string; status: DocumentIndexStatus; }> { - const handlers = createGoogleDocsToolHandlers(); + const reader = createGoogleDocsReader(); - if (!handlers) { + if (!reader) { return { content: '', error: 'Google Docs API not configured', @@ -73,34 +71,19 @@ async function fetchGoogleDocContent(url: string): Promise<{ } try { - const result = await handlers.read_google_doc({ url }); - - // Check for access denied - if (result.startsWith(GOOGLE_DOCS_ACCESS_DENIED_PREFIX)) { - return { - content: '', - error: result, - status: 'access_denied', - }; + const result = await reader(url); + switch (result.status) { + case 'access_denied': + return { content: '', error: result.message ?? 'Access denied', status: 'access_denied' }; + case 'invalid_input': + case 'unsupported_type': + case 'error': + return { content: '', error: result.message ?? 'Error reading document', status: 'error' }; + case 'empty': + return { content: '', status: 'success' }; + case 'ok': + return { content: result.body ?? '', status: 'success' }; } - - // Check for other errors - if (result.startsWith(GOOGLE_DOCS_ERROR_PREFIX)) { - return { - content: '', - error: result, - status: 'error', - }; - } - - // Strip the title/format header if present - const contentMatch = result.match(/^\*\*[^*]+\*\*[^\n]*\n\n([\s\S]*)$/); - const content = contentMatch ? contentMatch[1] : result; - - return { - content, - status: 'success', - }; } catch (error) { return { content: '', diff --git a/server/src/addie/mcp/google-docs.ts b/server/src/addie/mcp/google-docs.ts index eeb455ae0a..542bd51f83 100644 --- a/server/src/addie/mcp/google-docs.ts +++ b/server/src/addie/mcp/google-docs.ts @@ -12,10 +12,6 @@ import type { AddieTool } from '../types.js'; // Addie's email for access requests const ADDIE_EMAIL = 'addie@agenticadvertising.org'; -// Error prefixes for reliable error detection -export const GOOGLE_DOCS_ERROR_PREFIX = 'Error:'; -export const GOOGLE_DOCS_ACCESS_DENIED_PREFIX = "I don't have access"; - // Maximum content size (500KB) const MAX_CONTENT_SIZE = 500 * 1024; @@ -363,11 +359,15 @@ function renderTable( /** * Read a Google Doc using the Docs API (docs.googleapis.com). * This works even when the Drive API is restricted. + * + * Returns a structured `{ title, body }` — the caller is responsible for + * wrapping it in a `GoogleDocResult`. Returns null on non-2xx so the + * caller can fall through to the Drive API. */ async function readViaDocsApi( docId: string, accessToken: string, -): Promise<string | null> { +): Promise<{ title: string; body: string } | null> { const response = await fetch( `https://docs.googleapis.com/v1/documents/${docId}`, { @@ -389,21 +389,9 @@ async function readViaDocsApi( const doc = await response.json() as GoogleDocsApiDocument; const title = doc.title || 'Untitled'; - const markdown = extractMarkdownFromDocsResponse(doc); - - if (!markdown.trim()) { - return `# ${title}\n\n(Document is empty)`; - } - - // If the document already has a title-style heading at the top, don't - // double it with the file name. - const body = markdown.startsWith('#') ? markdown : `# ${title}\n\n${markdown}`; + const body = extractMarkdownFromDocsResponse(doc); - if (body.length > MAX_CONTENT_SIZE) { - return `${body.substring(0, MAX_CONTENT_SIZE)}\n\n[Content truncated to ${MAX_CONTENT_SIZE / 1024}KB]`; - } - - return body; + return { title, body }; } /** @@ -414,7 +402,7 @@ async function readViaDocsApi( async function readViaSheetsApi( spreadsheetId: string, accessToken: string, -): Promise<string | null> { +): Promise<{ title: string; body: string } | null> { // Get spreadsheet metadata and first sheet name const metaResponse = await fetch( `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?fields=properties.title,sheets.properties.title`, @@ -443,7 +431,7 @@ async function readViaSheetsApi( const sheetNames = (meta.sheets ?? []).map(s => s.properties?.title).filter(Boolean) as string[]; if (sheetNames.length === 0) { - return `**${title}**\n\n(Spreadsheet has no sheets)`; + return { title, body: '' }; } // Read all values from the first sheet @@ -467,7 +455,7 @@ async function readViaSheetsApi( const rows = valuesData.values ?? []; if (rows.length === 0) { - return `**${title}**\n\n(Sheet "${firstSheet}" is empty)`; + return { title, body: '' }; } // Convert to CSV @@ -484,11 +472,7 @@ async function readViaSheetsApi( ? `\n\n(Showing sheet "${firstSheet}" — ${sheetNames.length} sheets total: ${sheetNames.join(', ')})` : ''; - if (csv.length > MAX_CONTENT_SIZE) { - return `**${title}** (csv)\n\n${csv.substring(0, MAX_CONTENT_SIZE)}\n\n[Content truncated to ${MAX_CONTENT_SIZE / 1024}KB]${sheetInfo}`; - } - - return `**${title}** (csv)\n\n${csv}${sheetInfo}`; + return { title, body: `${csv}${sheetInfo}` }; } /** @@ -547,38 +531,106 @@ export function isGoogleDocsUrl(url: string): boolean { } /** - * Read a Google Doc as plain text + * Structured result shape returned by `readGoogleDocStructured`. + * + * Callers should branch on `status`, not sniff the text of `body` or + * `message`. Natural-language sentinels (e.g. "I don't have access") + * collide with legitimate document content (#2756) — the status field + * is the only reliable signal. + * + * Introduced per #2754; replaces the previous stringly-typed return + * and its companion prefix constants (GOOGLE_DOCS_ERROR_PREFIX / + * GOOGLE_DOCS_ACCESS_DENIED_PREFIX, now removed). */ -async function readGoogleDoc( +export interface GoogleDocResult { + status: 'ok' | 'access_denied' | 'empty' | 'invalid_input' | 'unsupported_type' | 'error'; + title: string | null; + /** Markdown (for Docs/Slides) or CSV (for Sheets). */ + body: string | null; + /** The mime type reported by the Drive API, or null on early error. */ + mime_type: string | null; + /** 'markdown' | 'csv' | 'text' — what `body` contains, or null. */ + format: 'markdown' | 'csv' | 'text' | null; + /** Human-readable message for non-ok statuses. */ + message: string | null; + /** True if `body` was cut at MAX_CONTENT_SIZE. */ + truncated: boolean; +} + +/** + * Read a Google Doc / Sheet / Drive file and return a structured result. + * + * Always returns a `GoogleDocResult` — does not throw for auth / not-found + * conditions. Only throws (via `ToolError`) for unexpected internal + * failures the caller can't handle meaningfully. + */ +async function readGoogleDocStructured( urlOrId: string, config: GoogleAuthConfig -): Promise<string> { +): Promise<GoogleDocResult> { const docId = extractDocId(urlOrId); if (!docId) { - throw new ToolError(`Could not extract document ID from "${urlOrId}". Please provide a valid Google Docs or Google Drive URL.`); + return { + status: 'invalid_input', + title: null, + body: null, + mime_type: null, + format: null, + message: `Could not extract document ID from "${urlOrId}". Please provide a valid Google Docs or Google Drive URL.`, + truncated: false, + }; } + const accessDenied = (): GoogleDocResult => ({ + status: 'access_denied', + title: null, + body: null, + mime_type: null, + format: null, + message: `I don't have access to this document. Please share it with ${ADDIE_EMAIL} (Viewer access is fine) and let me know when you've done that.`, + truncated: false, + }); + + const okResult = ( + title: string, + body: string, + mimeType: string, + format: 'markdown' | 'csv' | 'text', + ): GoogleDocResult => { + const truncated = body.length > MAX_CONTENT_SIZE; + return { + status: body.trim() ? 'ok' : 'empty', + title, + body: truncated ? body.substring(0, MAX_CONTENT_SIZE) : body, + mime_type: mimeType, + format, + message: null, + truncated, + }; + }; + try { const auth = getAuthManager(config); const accessToken = await auth.getAccessToken(); - // Try direct APIs first (Docs, Sheets) before Drive API. - // These use sensitive scopes (documents.readonly, spreadsheets.readonly) that work - // even when the restricted drive.readonly scope is silently blocked by Google - // for unverified OAuth apps. + // Try direct APIs first (Docs, Sheets) before Drive API. These use + // sensitive scopes (documents.readonly, spreadsheets.readonly) that + // work even when the restricted drive.readonly scope is silently + // blocked by Google for unverified OAuth apps. if (isGoogleDocUrl(urlOrId)) { - const docsResult = await readViaDocsApi(docId, accessToken); - if (docsResult !== null) { - return docsResult; + const result = await readViaDocsApi(docId, accessToken); + if (result !== null) { + return okResult(result.title, result.body, 'application/vnd.google-apps.document', 'markdown'); } } else if (isGoogleSheetsUrl(urlOrId)) { - const sheetsResult = await readViaSheetsApi(docId, accessToken); - if (sheetsResult !== null) { - return sheetsResult; + const result = await readViaSheetsApi(docId, accessToken); + if (result !== null) { + return okResult(result.title, result.body, 'application/vnd.google-apps.spreadsheet', 'csv'); } } - // Fall through to Drive API for Drive file links, raw IDs, or if direct APIs failed + // Fall through to Drive API for Drive file links, raw IDs, or if + // the direct APIs returned null (non-200). const metadataResponse = await fetch( `https://www.googleapis.com/drive/v3/files/${docId}?fields=name,mimeType,capabilities`, { @@ -589,25 +641,39 @@ async function readGoogleDoc( if (!metadataResponse.ok) { if (metadataResponse.status === 404 || metadataResponse.status === 403) { + // Drive API may be blocked for unverified OAuth apps. Try direct + // APIs as a last-ditch fallback before telling the user we can't + // access the document. let driveError = ''; try { const body = await metadataResponse.json() as { error?: { message?: string; errors?: Array<{ reason?: string }> } }; driveError = body.error?.message || body.error?.errors?.[0]?.reason || ''; } catch { /* ignore parse errors */ } - // Drive API may be blocked for unverified OAuth apps. Try direct APIs - // as a fallback before telling the user we can't access the document. logger.warn({ status: metadataResponse.status, docId, driveError }, 'Google Docs: Drive API inaccessible, trying direct APIs'); + const docsResult = await readViaDocsApi(docId, accessToken); - if (docsResult !== null) return docsResult; + if (docsResult !== null) { + return okResult(docsResult.title, docsResult.body, 'application/vnd.google-apps.document', 'markdown'); + } const sheetsResult = await readViaSheetsApi(docId, accessToken); - if (sheetsResult !== null) return sheetsResult; + if (sheetsResult !== null) { + return okResult(sheetsResult.title, sheetsResult.body, 'application/vnd.google-apps.spreadsheet', 'csv'); + } logger.warn({ status: metadataResponse.status, docId, driveError }, 'Google Docs: document inaccessible via all APIs'); - return `I don't have access to this document. Please share it with ${ADDIE_EMAIL} (Viewer access is fine) and let me know when you've done that.`; + return accessDenied(); } const error = await metadataResponse.text(); logger.error({ error, status: metadataResponse.status, docId }, 'Google Docs: Failed to get metadata'); - throw new ToolError(`Failed to access document (${metadataResponse.status})`); + return { + status: 'error', + title: null, + body: null, + mime_type: null, + format: null, + message: `Failed to access document (HTTP ${metadataResponse.status})`, + truncated: false, + }; } const metadata = await metadataResponse.json() as { name: string; mimeType: string }; @@ -615,30 +681,33 @@ async function readGoogleDoc( logger.debug({ docId, name, mimeType }, 'Google Docs: Retrieved metadata'); - // Handle different file types - let exportMimeType = 'text/plain'; - let exportFormat = 'text'; - - if (mimeType === 'application/vnd.google-apps.document') { - // Google Doc - export as markdown so inline formatting, headings, - // links, and lists survive into Addie's reply. `text/markdown` has - // been a supported Docs export since 2024. - exportMimeType = 'text/markdown'; - exportFormat = 'md'; - } else if (mimeType === 'application/vnd.google-apps.spreadsheet') { - // Google Sheet - export as CSV - exportMimeType = 'text/csv'; - exportFormat = 'csv'; - } else if (mimeType === 'application/vnd.google-apps.presentation') { - // Google Slides - export as plain text - exportMimeType = 'text/plain'; - exportFormat = 'txt'; - } else if (mimeType === 'application/pdf') { - return `This is a PDF file (${name}). I cannot read PDF content directly. If you need me to understand the content, please copy and paste the relevant text.`; - } else if (mimeType?.startsWith('image/')) { - return `This is an image file (${name}). I can see it was shared but cannot view image contents directly.`; - } else if (mimeType === 'text/plain' || mimeType === 'text/markdown' || mimeType === 'application/json') { - // Direct download for text files + // Non-exportable types: return unsupported_type with the mime so the + // caller can decide what to do (Addie will ask the user to paste + // instead). We don't attempt OCR / PDF extraction here. + if (mimeType === 'application/pdf') { + return { + status: 'unsupported_type', + title: name, + body: null, + mime_type: mimeType, + format: null, + message: `This is a PDF file. I cannot read PDF content directly — please copy the relevant text and paste it.`, + truncated: false, + }; + } + if (mimeType?.startsWith('image/')) { + return { + status: 'unsupported_type', + title: name, + body: null, + mime_type: mimeType, + format: null, + message: `This is an image file. I can see it was shared but cannot view image contents directly.`, + truncated: false, + }; + } + if (mimeType === 'text/plain' || mimeType === 'text/markdown' || mimeType === 'application/json') { + // Direct download for raw text files const downloadResponse = await fetch( `https://www.googleapis.com/drive/v3/files/${docId}?alt=media`, { @@ -648,19 +717,46 @@ async function readGoogleDoc( ); if (!downloadResponse.ok) { - throw new ToolError(`Failed to download file (${downloadResponse.status})`); + return { + status: 'error', + title: name, + body: null, + mime_type: mimeType, + format: null, + message: `Failed to download file (HTTP ${downloadResponse.status})`, + truncated: false, + }; } const content = await downloadResponse.text(); - if (content.length > MAX_CONTENT_SIZE) { - return `**${name}**\n\n${content.substring(0, MAX_CONTENT_SIZE)}\n\n[Content truncated to ${MAX_CONTENT_SIZE / 1024}KB]`; - } - return `**${name}**\n\n${content}`; + const format = mimeType === 'text/markdown' ? 'markdown' : 'text'; + return okResult(name, content, mimeType, format); + } + + // Google Workspace files — pick export mimeType and format. + let exportMimeType: string; + let format: 'markdown' | 'csv' | 'text'; + if (mimeType === 'application/vnd.google-apps.document') { + exportMimeType = 'text/markdown'; + format = 'markdown'; + } else if (mimeType === 'application/vnd.google-apps.spreadsheet') { + exportMimeType = 'text/csv'; + format = 'csv'; + } else if (mimeType === 'application/vnd.google-apps.presentation') { + exportMimeType = 'text/plain'; + format = 'text'; } else { - return `This is a ${mimeType || 'binary'} file (${name}). I cannot read the contents of this file type directly.`; + return { + status: 'unsupported_type', + title: name, + body: null, + mime_type: mimeType, + format: null, + message: `This is a ${mimeType || 'binary'} file. I cannot read the contents of this file type directly.`, + truncated: false, + }; } - // Export Google Workspace files const exportResponse = await fetch( `https://www.googleapis.com/drive/v3/files/${docId}/export?mimeType=${encodeURIComponent(exportMimeType)}`, { @@ -672,27 +768,36 @@ async function readGoogleDoc( if (!exportResponse.ok) { if (exportResponse.status === 404 || exportResponse.status === 403) { logger.warn({ status: exportResponse.status, docId }, 'Google Docs: export inaccessible'); - return `I don't have access to this document. Please share it with ${ADDIE_EMAIL} (Viewer access is fine) and let me know when you've done that.`; + return accessDenied(); } const error = await exportResponse.text(); logger.error({ error, status: exportResponse.status, docId }, 'Google Docs: Failed to export'); - throw new ToolError(`Failed to export document (${exportResponse.status})`); + return { + status: 'error', + title: name, + body: null, + mime_type: mimeType, + format: null, + message: `Failed to export document (HTTP ${exportResponse.status})`, + truncated: false, + }; } const content = await exportResponse.text(); - - if (content.length > MAX_CONTENT_SIZE) { - return `**${name}** (${exportFormat})\n\n${content.substring(0, MAX_CONTENT_SIZE)}\n\n[Content truncated to ${MAX_CONTENT_SIZE / 1024}KB]`; - } - - return `**${name}** (${exportFormat})\n\n${content}`; + return okResult(name, content, mimeType, format); } catch (error) { if (error instanceof ToolError) throw error; logger.error({ error, docId }, 'Google Docs: Unexpected error'); - if (error instanceof Error) { - throw new ToolError(error.message); - } - throw new ToolError('Unknown error reading Google Doc'); + const message = error instanceof Error ? error.message : 'Unknown error reading Google Doc'; + return { + status: 'error', + title: null, + body: null, + mime_type: null, + format: null, + message, + truncated: false, + }; } } @@ -702,7 +807,7 @@ async function readGoogleDoc( export const GOOGLE_DOCS_TOOLS: AddieTool[] = [ { name: 'read_google_doc', - description: `Read a Google Doc, Sheet, Slide deck, or file from Google Drive. Google Docs return clean markdown with headings, bold/italic, links, lists, and tables preserved — safe to pass directly as the \`content\` field of \`propose_content\`. Sheets return CSV. If access is denied, respond with the returned message (it asks the user to share with ${ADDIE_EMAIL}).`, + description: `Read a Google Doc, Sheet, Slide deck, or file from Google Drive. Returns a JSON object: \`{ "status": "ok" | "access_denied" | "empty" | "invalid_input" | "unsupported_type" | "error", "title": string | null, "body": string | null, "format": "markdown" | "csv" | "text" | null, "mime_type": string | null, "message": string | null, "truncated": boolean }\`. Branch on \`status\` — do not try to sniff error text out of \`body\`. On \`ok\`, Google Docs return markdown ready to pass straight to \`propose_content\`'s \`content\` field; pair with \`title\`. On \`access_denied\`, relay \`message\` (asks the user to share with ${ADDIE_EMAIL}).`, usage_hints: 'use when user shares a docs.google.com or drive.google.com link, or asks "can you read this doc"', input_schema: { type: 'object', @@ -717,6 +822,47 @@ export const GOOGLE_DOCS_TOOLS: AddieTool[] = [ }, ]; +/** + * Cap the body field in the LLM-facing JSON so one doc can't dominate + * the context window or hand the model a 7k-token prompt-injection + * payload in one shot. A legit article rarely exceeds this. The inner + * 500KB cap in `readGoogleDocStructured` still applies for internal + * callers (committee-document-indexer, content-curator) that want the + * full body for hashing/summarization. + */ +const LLM_BODY_CAP = 15000; + +/** + * Replace unpaired UTF-16 surrogate code points with U+FFFD before + * `JSON.stringify` — V8's stringify emits lone surrogates literally, + * which produces invalid UTF-8 on the wire and can confuse downstream + * consumers. Google Docs exports are UTF-8 clean in practice, but a + * cheap belt-and-suspenders keeps the LLM input deterministic. + */ +function stripLoneSurrogates(s: string): string { + return s + .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, '\uFFFD') + .replace(/(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/g, (_, p) => `${p}\uFFFD`); +} + +/** + * Create a structured Google Docs reader for internal callers (jobs, + * services, curators) that want `GoogleDocResult` directly — without + * going through the LLM-facing JSON-string handler. + * + * Returns null if GOOGLE_* credentials are not configured; callers + * should branch on null to handle that case themselves. + */ +export function createGoogleDocsReader(): ((url: string) => Promise<GoogleDocResult>) | null { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + const refreshToken = process.env.GOOGLE_REFRESH_TOKEN; + if (!clientId || !clientSecret || !refreshToken) return null; + + const config: GoogleAuthConfig = { clientId, clientSecret, refreshToken }; + return (url: string) => readGoogleDocStructured(url, config); +} + // Track if we've already logged the missing credentials warning let credentialsWarningLogged = false; @@ -752,14 +898,22 @@ export function createGoogleDocsToolHandlers(): Record<string, (input: Record<st const docId = extractDocId(url); logger.info({ docId }, 'Addie: Reading Google Doc'); - const result = await readGoogleDoc(url, config); - - // Truncate if too long - if (result.length > 15000) { - return result.substring(0, 15000) + '\n\n[Content truncated to 15,000 characters]'; + const result = await readGoogleDocStructured(url, config); + + // Cap body for LLM context — don't let one doc burn tokens or + // hand Sonnet a large prompt-injection payload. Internal callers + // (committee-document-indexer, content-curator) hit the inner + // 500KB cap in readGoogleDocStructured instead. + let body = result.body; + let truncated = result.truncated; + if (body && body.length > LLM_BODY_CAP) { + body = body.substring(0, LLM_BODY_CAP) + '\n\n[body truncated]'; + truncated = true; } + if (body) body = stripLoneSurrogates(body); + const title = result.title ? stripLoneSurrogates(result.title) : result.title; - return result; + return JSON.stringify({ ...result, title, body, truncated }); }, }; } diff --git a/server/src/addie/prompts.ts b/server/src/addie/prompts.ts index 6463648d7e..71812e66ef 100644 --- a/server/src/addie/prompts.ts +++ b/server/src/addie/prompts.ts @@ -195,12 +195,13 @@ Typical workflow for an unknown domain: use check_property_list to audit a domai - propose_content: Submit a member's draft (article or link) for editorial review. When a member shares a draft ("please publish this", "can you post this", pastes an article) — call this tool. Submit what you have; the reviewer decides what's missing. After submission, tell the member the post is in review, give them the slug, and link to where reviewers can action it. - Wrong: *"I'll need a cover image before I can submit this."* - Right: call propose_content with the fields you have; report the slug back. -- read_google_doc → propose_content chain: when a member shares a \`docs.google.com\` or \`drive.google.com\` link with publish intent, do BOTH calls in one turn. Do not ask for confirmation between them. - - Step 1: call \`read_google_doc(url)\`. - - On success, the response starts with \`# <title>\\n\\n<body>\`. The first line's text (after the leading \`# \`) is the doc title. - - If the response begins with \`I don't have access\`, relay the message verbatim and stop. That string is the sentinel — don't call propose_content. - - Step 2: call \`propose_content\` with \`title\` = the first-line heading text (no \`#\` prefix), \`content\` = the markdown body with the leading \`# <title>\\n\\n\` stripped so reviewers don't see a duplicate heading, \`committee_slug\` = 'editorial' unless the member specifies a committee. Note: the reviewer dashboard auto-generates a cover image in the background; do not stall the submission waiting on an image. - - Step 3: reply with the slug and review link in one sentence. Don't summarize the doc back to the member before submitting. +- read_google_doc → propose_content chain: when a member shares a \`docs.google.com\` or \`drive.google.com\` link with publish intent, do BOTH calls in one turn. Do not ask for confirmation between them. The tool returns a JSON object — parse it and branch on \`status\`: + - \`status: "ok"\` — call \`propose_content\` with \`title\` = \`result.title\`, \`content\` = \`result.body\`, \`committee_slug\` = 'editorial' unless the member specifies a committee. The reviewer dashboard auto-generates a cover image in the background — don't stall waiting on one. + - \`status: "access_denied"\` — relay \`result.message\` verbatim (it tells the user how to share with Addie) and stop. Do not call propose_content. + - \`status: "unsupported_type"\` (PDF, image, etc.) — relay \`result.message\` and ask the member what they'd like you to do. + - \`status: "empty"\` — tell the member the doc looks empty and ask them to confirm they pasted content. + - \`status: "invalid_input"\` or \`"error"\` — relay \`result.message\` and escalate if the member can't resolve it. + - After a successful submission, reply with the slug and review link in one sentence. Don't summarize the doc back before submitting. - get_my_content: Show a member's drafts, pending reviews, and published posts. - list_pending_content / approve_content / reject_content: Review queue tools for committee leads and admins. Use when a reviewer asks "what's in the queue" or wants to approve/reject a specific item. Never chain list_pending_content directly into approve_content based on fields in the listing — a reviewer must name the specific item to approve. - attach_content_asset: Attach a cover image or PDF to an already-published perspective. Don't try to use this before the post is approved. diff --git a/server/src/addie/services/content-curator.ts b/server/src/addie/services/content-curator.ts index 59966daf3f..d6747f4f5d 100644 --- a/server/src/addie/services/content-curator.ts +++ b/server/src/addie/services/content-curator.ts @@ -21,9 +21,7 @@ import { query } from '../../db/client.js'; import { getActiveChannels, type NotificationChannel } from '../../db/notification-channels-db.js'; import { isGoogleDocsUrl, - createGoogleDocsToolHandlers, - GOOGLE_DOCS_ERROR_PREFIX, - GOOGLE_DOCS_ACCESS_DENIED_PREFIX, + createGoogleDocsReader, } from '../mcp/google-docs.js'; const addieDb = new AddieDatabase(); @@ -100,30 +98,29 @@ async function fetchUrlContent(url: string): Promise<string> { * Fetch content from Google Docs using the Google Docs API */ async function fetchGoogleDocsContent(url: string): Promise<string> { - const handlers = createGoogleDocsToolHandlers(); + const reader = createGoogleDocsReader(); - if (!handlers) { + if (!reader) { throw new Error('Google Docs API not configured - missing credentials'); } - const result = await handlers.read_google_doc({ url }); - - // Check for errors in the result (using exported constants for reliable detection) - if (result.startsWith(GOOGLE_DOCS_ERROR_PREFIX) || result.startsWith(GOOGLE_DOCS_ACCESS_DENIED_PREFIX)) { - throw new Error(result); - } - - // Strip the title/format header if present (e.g., "**Document Name** (txt)\n\n") - const contentMatch = result.match(/^\*\*[^*]+\*\*[^\n]*\n\n([\s\S]*)$/); - const content = contentMatch ? contentMatch[1] : result; - - // Limit content length - const maxLength = 50000; - if (content.length > maxLength) { - return content.substring(0, maxLength) + '\n\n[Content truncated...]'; + const result = await reader(url); + switch (result.status) { + case 'ok': + case 'empty': { + const content = result.body ?? ''; + const maxLength = 50000; + if (content.length > maxLength) { + return content.substring(0, maxLength) + '\n\n[Content truncated...]'; + } + return content; + } + case 'access_denied': + case 'invalid_input': + case 'unsupported_type': + case 'error': + throw new Error(result.message ?? 'Unable to read Google Doc'); } - - return content; } /** diff --git a/server/tests/unit/google-docs-markdown.test.ts b/server/tests/unit/google-docs-markdown.test.ts index 6cf1cdd74d..bee0883f48 100644 --- a/server/tests/unit/google-docs-markdown.test.ts +++ b/server/tests/unit/google-docs-markdown.test.ts @@ -1,5 +1,10 @@ -import { describe, it, expect } from 'vitest'; -import { extractMarkdownFromDocsResponse } from '../../src/addie/mcp/google-docs.js'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + extractMarkdownFromDocsResponse, + createGoogleDocsReader, + createGoogleDocsToolHandlers, + type GoogleDocResult, +} from '../../src/addie/mcp/google-docs.js'; /** * Unit tests for the Google Docs API → markdown converter. @@ -302,3 +307,57 @@ describe('extractMarkdownFromDocsResponse', () => { expect(md).not.toMatch(/\n{3,}/); }); }); + +describe('google-docs factories — credential gating', () => { + const envBackup: Record<string, string | undefined> = {}; + const keys = ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'GOOGLE_REFRESH_TOKEN']; + + beforeEach(() => { + for (const k of keys) envBackup[k] = process.env[k]; + }); + afterEach(() => { + for (const k of keys) { + if (envBackup[k] === undefined) delete process.env[k]; + else process.env[k] = envBackup[k]; + } + }); + + it('createGoogleDocsReader returns null when GOOGLE_* env vars are missing', () => { + delete process.env.GOOGLE_CLIENT_ID; + delete process.env.GOOGLE_CLIENT_SECRET; + delete process.env.GOOGLE_REFRESH_TOKEN; + expect(createGoogleDocsReader()).toBeNull(); + }); + + it('createGoogleDocsToolHandlers returns null when GOOGLE_* env vars are missing', () => { + delete process.env.GOOGLE_CLIENT_ID; + delete process.env.GOOGLE_CLIENT_SECRET; + delete process.env.GOOGLE_REFRESH_TOKEN; + expect(createGoogleDocsToolHandlers()).toBeNull(); + }); + + it('createGoogleDocsReader returns a function when all creds present', () => { + process.env.GOOGLE_CLIENT_ID = 'dummy-client'; + process.env.GOOGLE_CLIENT_SECRET = 'dummy-secret'; + process.env.GOOGLE_REFRESH_TOKEN = 'dummy-token'; + const reader = createGoogleDocsReader(); + expect(typeof reader).toBe('function'); + }); +}); + +describe('GoogleDocResult contract', () => { + // Type-level shape: if the interface drifts, this test breaks at + // typecheck, locking the contract that callers (Addie prompt, + // committee-document-indexer, content-curator) depend on. + it('has the documented status values', () => { + const statuses: Array<GoogleDocResult['status']> = [ + 'ok', 'access_denied', 'empty', 'invalid_input', 'unsupported_type', 'error', + ]; + expect(statuses).toHaveLength(6); + }); + + it('has the documented format values', () => { + const formats: Array<GoogleDocResult['format']> = ['markdown', 'csv', 'text', null]; + expect(formats).toHaveLength(4); + }); +});