From c54cd1279e1cb690b840dccc14570955e524f54f Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 10:41:13 +0800 Subject: [PATCH 01/21] =?UTF-8?q?feat(platform):=20simplify=20file=20tools?= =?UTF-8?q?=20=E2=80=94=20auto=20RAG=20indexing,=20remove=20parse/template?= =?UTF-8?q?=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-trigger RAG indexing when files are uploaded (no more manual trigger) - Add `retrieve` operation to rag_search tool for full document content access - Remove `parse` operations from all file tools (pdf, docx, pptx, text, excel) - Remove `list_templates` and template-based generation from docx/pptx tools - Rewrite pptx tool to use markdown/HTML generation (consistent with pdf/docx) - Add retry-on-empty logic in RAG search service for files still being indexed - Update file tool descriptions to guide AI toward rag_search retrieve - Clean up dead code: template generators, parse helpers, unused validators --- services/platform/convex/_generated/api.d.ts | 16 +- .../convex/agent_tools/files/docx_tool.ts | 194 +------- .../convex/agent_tools/files/excel_tool.ts | 172 ++----- .../agent_tools/files/helpers/analyze_text.ts | 467 ------------------ .../agent_tools/files/helpers/parse_file.ts | 158 ------ .../files/helpers/resolve_file_name.ts | 32 -- .../agent_tools/files/internal_actions.ts | 53 -- .../convex/agent_tools/files/pdf_tool.ts | 131 ++--- .../convex/agent_tools/files/pptx_tool.ts | 283 ++--------- .../convex/agent_tools/files/text_tool.ts | 221 ++------- .../rag/helpers/fetch_document_chunks.ts | 62 +++ .../convex/agent_tools/rag/rag_search_tool.ts | 44 +- .../documents/generate_document_helpers.ts | 11 +- .../documents/generate_docx_from_template.ts | 156 ------ .../convex/documents/generate_pptx.ts | 203 -------- services/platform/convex/documents/helpers.ts | 3 - .../convex/documents/internal_actions.ts | 55 +-- .../convex/documents/internal_queries.ts | 11 - .../documents/list_documents_by_extension.ts | 39 -- services/platform/convex/documents/types.ts | 2 +- .../platform/convex/documents/validators.ts | 9 - .../convex/file_metadata/internal_actions.ts | 48 ++ .../file_metadata/internal_mutations.ts | 10 + .../convex/file_metadata/mutations.ts | 10 + .../platform/convex/lib/action_cache/index.ts | 12 - .../lib/attachments/process_attachments.ts | 193 +------- .../action_defs/rag/rag_action.ts | 57 +-- services/rag/app/services/rag_service.py | 16 + 28 files changed, 402 insertions(+), 2266 deletions(-) delete mode 100644 services/platform/convex/agent_tools/files/helpers/analyze_text.ts delete mode 100644 services/platform/convex/agent_tools/files/helpers/parse_file.ts delete mode 100644 services/platform/convex/agent_tools/files/helpers/resolve_file_name.ts create mode 100644 services/platform/convex/agent_tools/rag/helpers/fetch_document_chunks.ts delete mode 100644 services/platform/convex/documents/generate_docx_from_template.ts delete mode 100644 services/platform/convex/documents/generate_pptx.ts delete mode 100644 services/platform/convex/documents/list_documents_by_extension.ts create mode 100644 services/platform/convex/file_metadata/internal_actions.ts diff --git a/services/platform/convex/_generated/api.d.ts b/services/platform/convex/_generated/api.d.ts index 277ee09f5d..890d2e36d5 100644 --- a/services/platform/convex/_generated/api.d.ts +++ b/services/platform/convex/_generated/api.d.ts @@ -43,11 +43,8 @@ import type * as agent_tools_files_docx_tool from "../agent_tools/files/docx_too import type * as agent_tools_files_excel_tool from "../agent_tools/files/excel_tool.js"; import type * as agent_tools_files_helpers_analyze_image from "../agent_tools/files/helpers/analyze_image.js"; import type * as agent_tools_files_helpers_analyze_image_by_url from "../agent_tools/files/helpers/analyze_image_by_url.js"; -import type * as agent_tools_files_helpers_analyze_text from "../agent_tools/files/helpers/analyze_text.js"; import type * as agent_tools_files_helpers_append_file_part from "../agent_tools/files/helpers/append_file_part.js"; import type * as agent_tools_files_helpers_get_agent_model from "../agent_tools/files/helpers/get_agent_model.js"; -import type * as agent_tools_files_helpers_parse_file from "../agent_tools/files/helpers/parse_file.js"; -import type * as agent_tools_files_helpers_resolve_file_name from "../agent_tools/files/helpers/resolve_file_name.js"; import type * as agent_tools_files_helpers_vision_agent from "../agent_tools/files/helpers/vision_agent.js"; import type * as agent_tools_files_image_tool from "../agent_tools/files/image_tool.js"; import type * as agent_tools_files_internal_actions from "../agent_tools/files/internal_actions.js"; @@ -81,6 +78,7 @@ import type * as agent_tools_products_helpers_read_product_list from "../agent_t import type * as agent_tools_products_helpers_types from "../agent_tools/products/helpers/types.js"; import type * as agent_tools_products_product_read_tool from "../agent_tools/products/product_read_tool.js"; import type * as agent_tools_rag_format_search_results from "../agent_tools/rag/format_search_results.js"; +import type * as agent_tools_rag_helpers_fetch_document_chunks from "../agent_tools/rag/helpers/fetch_document_chunks.js"; import type * as agent_tools_rag_helpers_list_indexed_documents from "../agent_tools/rag/helpers/list_indexed_documents.js"; import type * as agent_tools_rag_parse_search_results from "../agent_tools/rag/parse_search_results.js"; import type * as agent_tools_rag_query_rag_context from "../agent_tools/rag/query_rag_context.js"; @@ -240,8 +238,6 @@ import type * as documents_find_document_by_title from "../documents/find_docume import type * as documents_generate_document from "../documents/generate_document.js"; import type * as documents_generate_document_helpers from "../documents/generate_document_helpers.js"; import type * as documents_generate_docx from "../documents/generate_docx.js"; -import type * as documents_generate_docx_from_template from "../documents/generate_docx_from_template.js"; -import type * as documents_generate_pptx from "../documents/generate_pptx.js"; import type * as documents_generate_signed_url from "../documents/generate_signed_url.js"; import type * as documents_get_accessible_document_ids from "../documents/get_accessible_document_ids.js"; import type * as documents_get_agent_scoped_file_ids from "../documents/get_agent_scoped_file_ids.js"; @@ -256,7 +252,6 @@ import type * as documents_helpers from "../documents/helpers.js"; import type * as documents_internal_actions from "../documents/internal_actions.js"; import type * as documents_internal_mutations from "../documents/internal_mutations.js"; import type * as documents_internal_queries from "../documents/internal_queries.js"; -import type * as documents_list_documents_by_extension from "../documents/list_documents_by_extension.js"; import type * as documents_list_documents_for_agent from "../documents/list_documents_for_agent.js"; import type * as documents_list_documents_paginated from "../documents/list_documents_paginated.js"; import type * as documents_list_indexed_documents_for_agent from "../documents/list_indexed_documents_for_agent.js"; @@ -277,6 +272,7 @@ import type * as documents_validators from "../documents/validators.js"; import type * as feedback_mutations from "../feedback/mutations.js"; import type * as feedback_queries from "../feedback/queries.js"; import type * as file_metadata_helpers from "../file_metadata/helpers.js"; +import type * as file_metadata_internal_actions from "../file_metadata/internal_actions.js"; import type * as file_metadata_internal_mutations from "../file_metadata/internal_mutations.js"; import type * as file_metadata_internal_queries from "../file_metadata/internal_queries.js"; import type * as file_metadata_mutations from "../file_metadata/mutations.js"; @@ -969,11 +965,8 @@ declare const fullApi: ApiFromModules<{ "agent_tools/files/excel_tool": typeof agent_tools_files_excel_tool; "agent_tools/files/helpers/analyze_image": typeof agent_tools_files_helpers_analyze_image; "agent_tools/files/helpers/analyze_image_by_url": typeof agent_tools_files_helpers_analyze_image_by_url; - "agent_tools/files/helpers/analyze_text": typeof agent_tools_files_helpers_analyze_text; "agent_tools/files/helpers/append_file_part": typeof agent_tools_files_helpers_append_file_part; "agent_tools/files/helpers/get_agent_model": typeof agent_tools_files_helpers_get_agent_model; - "agent_tools/files/helpers/parse_file": typeof agent_tools_files_helpers_parse_file; - "agent_tools/files/helpers/resolve_file_name": typeof agent_tools_files_helpers_resolve_file_name; "agent_tools/files/helpers/vision_agent": typeof agent_tools_files_helpers_vision_agent; "agent_tools/files/image_tool": typeof agent_tools_files_image_tool; "agent_tools/files/internal_actions": typeof agent_tools_files_internal_actions; @@ -1007,6 +1000,7 @@ declare const fullApi: ApiFromModules<{ "agent_tools/products/helpers/types": typeof agent_tools_products_helpers_types; "agent_tools/products/product_read_tool": typeof agent_tools_products_product_read_tool; "agent_tools/rag/format_search_results": typeof agent_tools_rag_format_search_results; + "agent_tools/rag/helpers/fetch_document_chunks": typeof agent_tools_rag_helpers_fetch_document_chunks; "agent_tools/rag/helpers/list_indexed_documents": typeof agent_tools_rag_helpers_list_indexed_documents; "agent_tools/rag/parse_search_results": typeof agent_tools_rag_parse_search_results; "agent_tools/rag/query_rag_context": typeof agent_tools_rag_query_rag_context; @@ -1166,8 +1160,6 @@ declare const fullApi: ApiFromModules<{ "documents/generate_document": typeof documents_generate_document; "documents/generate_document_helpers": typeof documents_generate_document_helpers; "documents/generate_docx": typeof documents_generate_docx; - "documents/generate_docx_from_template": typeof documents_generate_docx_from_template; - "documents/generate_pptx": typeof documents_generate_pptx; "documents/generate_signed_url": typeof documents_generate_signed_url; "documents/get_accessible_document_ids": typeof documents_get_accessible_document_ids; "documents/get_agent_scoped_file_ids": typeof documents_get_agent_scoped_file_ids; @@ -1182,7 +1174,6 @@ declare const fullApi: ApiFromModules<{ "documents/internal_actions": typeof documents_internal_actions; "documents/internal_mutations": typeof documents_internal_mutations; "documents/internal_queries": typeof documents_internal_queries; - "documents/list_documents_by_extension": typeof documents_list_documents_by_extension; "documents/list_documents_for_agent": typeof documents_list_documents_for_agent; "documents/list_documents_paginated": typeof documents_list_documents_paginated; "documents/list_indexed_documents_for_agent": typeof documents_list_indexed_documents_for_agent; @@ -1203,6 +1194,7 @@ declare const fullApi: ApiFromModules<{ "feedback/mutations": typeof feedback_mutations; "feedback/queries": typeof feedback_queries; "file_metadata/helpers": typeof file_metadata_helpers; + "file_metadata/internal_actions": typeof file_metadata_internal_actions; "file_metadata/internal_mutations": typeof file_metadata_internal_mutations; "file_metadata/internal_queries": typeof file_metadata_internal_queries; "file_metadata/mutations": typeof file_metadata_mutations; diff --git a/services/platform/convex/agent_tools/files/docx_tool.ts b/services/platform/convex/agent_tools/files/docx_tool.ts index ded9de8122..db0893e4ea 100644 --- a/services/platform/convex/agent_tools/files/docx_tool.ts +++ b/services/platform/convex/agent_tools/files/docx_tool.ts @@ -1,6 +1,5 @@ /** Convex Tool: DOCX - * Generate Word (.docx) documents and work with DOCX templates in the documents schema. - * Parse DOCX documents to extract text content. + * Generate Word (.docx) documents from markdown/HTML or structured sections. */ import type { ToolCtx } from '@convex-dev/agent'; @@ -8,29 +7,13 @@ import { createTool } from '@convex-dev/agent'; import { z } from 'zod/v4'; import { internal } from '../../_generated/api'; -import type { ListDocumentsByExtensionResult } from '../../documents/types'; import { createDebugLog } from '../../lib/debug_log'; -import { toId } from '../../lib/type_cast_helpers'; import type { ToolDefinition } from '../types'; import { appendFilePart } from './helpers/append_file_part'; -import { getAgentModelId } from './helpers/get_agent_model'; -import { parseFile, type ParseFileResult } from './helpers/parse_file'; const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]'); // Result types -interface ListTemplatesResult { - operation: 'list_templates'; - success: boolean; - templates: Array<{ - fileId: string; - title: string; - createdAt: number; - }>; - totalCount: number; - message: string; -} - interface GenerateDocxResult { operation: 'generate'; success: boolean; @@ -41,9 +24,7 @@ interface GenerateDocxResult { size: number; } -type ParseDocxResult = { operation: 'parse' } & ParseFileResult; - -type DocxResult = ListTemplatesResult | GenerateDocxResult | ParseDocxResult; +type DocxResult = GenerateDocxResult; const sectionSchema = z.object({ type: z @@ -80,23 +61,8 @@ const sectionSchema = z.object({ }); const docxArgs = z.discriminatedUnion('operation', [ - z.object({ - operation: z.literal('list_templates'), - limit: z - .number() - .optional() - .describe( - 'Maximum number of DOCX documents/templates to return (default: 50)', - ), - }), z.object({ operation: z.literal('generate'), - templateStorageId: z - .string() - .optional() - .describe( - 'Convex storage ID of a DOCX template. When provided, the template is used as base, preserving headers, footers, fonts, and page setup.', - ), fileName: z .string() .describe('Base name for the DOCX file (without extension)'), @@ -121,41 +87,18 @@ const docxArgs = z.discriminatedUnion('operation', [ 'Markdown or HTML text content. Use with sourceType. This is the fastest way to generate DOCX from the same content used for PDF generation.', ), }), - z.object({ - operation: z.literal('parse'), - fileId: z - .string() - .describe( - "Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", - ), - filename: z - .string() - .optional() - .describe( - "Original filename (e.g., 'document.docx'). Optional — auto-resolved from file metadata if omitted.", - ), - user_input: z - .string() - .describe( - "The user's question or instruction about the document content", - ), - }), ]); export const docxTool = { name: 'docx' as const, tool: createTool({ - description: `Word document (DOCX) tool for listing templates, generating, and parsing documents. + description: `Word document (DOCX) tool for generating documents. IMPORTANT: Only call the "generate" operation when the user explicitly requests creating or exporting a Word/DOCX file. Do NOT proactively generate Word documents unless the user specifically asks for this format. OPERATIONS: -1. list_templates - List all available DOCX templates - Returns all DOCX documents available in the organization. - Returns: { templates, totalCount, message } - -2. generate - Generate a DOCX document +1. generate - Generate a DOCX document TWO MODES: @@ -171,108 +114,28 @@ OPERATIONS: b) From structured sections: Use sections array for fine-grained control over document structure. - Pass templateStorageId to use a template as base. Parameters: - - fileName, title, subtitle, sections, templateStorageId + - fileName, title, subtitle, sections Returns: { success, downloadUrl, fileName, contentType, size } -3. parse - Extract text content from an existing DOCX file - USE THIS when a user uploads a DOCX and you need to read its content. - Parameters: - - fileId: **REQUIRED** - Convex storage ID (e.g., "kg2bazp7fbgt9srq63knfagjrd7yfenj") - - filename: Optional — original filename (e.g., "document.docx"). Auto-resolved from file metadata if omitted. - - user_input: **REQUIRED** - The user's question or instruction about the document - Returns: { success, full_text, paragraph_count, metadata } - EXAMPLES: • From markdown: { "operation": "generate", "fileName": "report", "sourceType": "markdown", "content": "# Report\\n..." } • From HTML: { "operation": "generate", "fileName": "report", "sourceType": "html", "content": "

Report

..." } • From sections: { "operation": "generate", "fileName": "report", "sections": [...] } -• With template: { "operation": "generate", "templateStorageId": "kg...", "fileName": "report", "sections": [...] } -• List templates: { "operation": "list_templates" } -• Parse: { "operation": "parse", "fileId": "kg2bazp7...", "filename": "document.docx", "user_input": "Extract the main points" } AFTER GENERATING: Check the downloadUrl in the result: - If it says "[file card shown in chat]": the file is already visible as a download card. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. - If it contains an actual URL: no download card was shown. You MUST include the URL as a clickable markdown link so the user can download the file. To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. + +TO READ FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: +• To get the full content of a file: use rag_search with operation='retrieve' and the fileId +• To search for specific information across files: use rag_search with operation='search' `, inputSchema: docxArgs, execute: async (ctx: ToolCtx, args): Promise => { const { organizationId } = ctx; - if (args.operation === 'list_templates') { - if (!organizationId) { - return { - operation: 'list_templates', - success: false, - templates: [], - totalCount: 0, - message: - 'No organizationId in context - cannot list DOCX templates. This tool requires organizationId to be set.', - }; - } - - debugLog('tool:docx list_templates start', { - organizationId, - limit: args.limit, - }); - - try { - const documents: ListDocumentsByExtensionResult = await ctx.runQuery( - internal.documents.internal_queries.listDocumentsByExtension, - { - organizationId, - extension: 'docx', - limit: args.limit, - }, - ); - - const templates = documents - .filter( - (doc): doc is typeof doc & { fileId: string } => !!doc.fileId, - ) - .map((doc) => ({ - fileId: doc.fileId, - title: doc.title ?? 'Untitled Document', - createdAt: doc._creationTime, - })); - - debugLog('tool:docx list_templates success', { - totalCount: templates.length, - }); - - return { - operation: 'list_templates', - success: true, - templates, - totalCount: templates.length, - message: - templates.length > 0 - ? `Found ${templates.length} DOCX template(s). Use the fileId when referencing these templates.` - : 'No DOCX templates found. Upload a DOCX file first to use it as a template.', - }; - } catch (error) { - console.error('[tool:docx list_templates] error', { - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } - } - - if (args.operation === 'parse') { - const model = getAgentModelId(ctx); - const result = await parseFile( - ctx, - args.fileId, - args.filename, - 'docx', - args.user_input, - model, - ); - return { operation: 'parse', ...result }; - } - // operation === 'generate' if (!organizationId) { throw new Error('organizationId is required to generate a document'); @@ -363,50 +226,11 @@ To also save the file to a folder in the documents hub, call document_write with debugLog('tool:docx generate start', { fileName: args.fileName, sectionsCount: args.sections.length, - hasTemplate: !!args.templateStorageId, }); try { const sections = args.sections ?? []; - // If templateStorageId is provided, use template-based generation - if (args.templateStorageId) { - const result = await ctx.runAction( - internal.documents.internal_actions.generateDocxFromTemplate, - { - organizationId, - fileName: args.fileName, - content: { - title: args.title, - subtitle: args.subtitle, - sections, - }, - templateStorageId: toId<'_storage'>(args.templateStorageId), - }, - ); - - debugLog('tool:docx generate (from template) success', { - fileName: result.fileName, - fileStorageId: result.fileStorageId, - size: result.size, - }); - - const cardAppended = await appendFilePart(ctx, { - fileName: result.fileName, - mimeType: result.contentType, - downloadUrl: result.downloadUrl, - }); - - return { - operation: 'generate', - ...result, - downloadUrl: cardAppended - ? '[file card shown in chat]' - : result.downloadUrl, - } as GenerateDocxResult; - } - - // Otherwise, generate from scratch const result = await ctx.runAction( internal.documents.internal_actions.generateDocx, { diff --git a/services/platform/convex/agent_tools/files/excel_tool.ts b/services/platform/convex/agent_tools/files/excel_tool.ts index 54a33c8ed7..1ec8edb8b6 100644 --- a/services/platform/convex/agent_tools/files/excel_tool.ts +++ b/services/platform/convex/agent_tools/files/excel_tool.ts @@ -1,6 +1,5 @@ /** Convex Tool: Excel * Generate Excel (.xlsx) files from tabular data. - * Parse Excel files to extract structured content. */ import type { ToolCtx } from '@convex-dev/agent'; @@ -10,10 +9,8 @@ import { z } from 'zod/v4'; import { internal } from '../../_generated/api'; import { createDebugLog } from '../../lib/debug_log'; import { buildDownloadUrl } from '../../lib/helpers/public_storage_url'; -import { toId } from '../../lib/type_cast_helpers'; import type { ToolDefinition } from '../types'; import { appendFilePart } from './helpers/append_file_part'; -import { resolveFileName } from './helpers/resolve_file_name'; const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]'); @@ -29,152 +26,61 @@ interface GenerateExcelResult { error?: string; } -interface ParseExcelResult { - operation: 'parse'; - success: boolean; - fileName: string; - sheets: Array<{ - name: string; - headers: string[]; - rows: Array>; - rowCount: number; - }>; - totalRows: number; - sheetCount: number; - error?: string; -} - -type ExcelResult = GenerateExcelResult | ParseExcelResult; - -const excelArgs = z.discriminatedUnion('operation', [ - z.object({ - operation: z.literal('generate'), - fileName: z - .string() - .describe('Base name for the Excel file (without extension)'), - sheets: z - .array( - z.object({ - name: z.string().describe('Sheet name'), - headers: z - .array(z.string()) - .nonempty() - .describe( - "Column headers for the sheet (must align with each row's columns)", - ), - rows: z - .array( - z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])), - ) - .describe('2D array of cell values (rows x columns)'), - }), - ) - .describe('Sheets to include in the Excel file'), - }), - z.object({ - operation: z.literal('parse'), - fileId: z - .string() - .describe( - "Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", - ), - filename: z - .string() - .optional() - .describe( - "Original filename (e.g., 'report.xlsx'). Optional — auto-resolved from file metadata if omitted.", - ), - }), -]); +const excelArgs = z.object({ + operation: z.literal('generate'), + fileName: z + .string() + .describe('Base name for the Excel file (without extension)'), + sheets: z + .array( + z.object({ + name: z.string().describe('Sheet name'), + headers: z + .array(z.string()) + .nonempty() + .describe( + "Column headers for the sheet (must align with each row's columns)", + ), + rows: z + .array( + z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])), + ) + .describe('2D array of cell values (rows x columns)'), + }), + ) + .describe('Sheets to include in the Excel file'), +}); export const excelTool = { name: 'excel' as const, tool: createTool({ - description: `Excel (.xlsx) tool for generating and parsing spreadsheet files. - -IMPORTANT: Only call the "generate" operation when the user explicitly requests creating or exporting an Excel/spreadsheet file. Do NOT proactively generate Excel files unless the user specifically asks for this format. + description: `Excel (.xlsx) tool for generating spreadsheet files. -OPERATIONS: +IMPORTANT: Only call this tool when the user explicitly requests creating or exporting an Excel/spreadsheet file. Do NOT proactively generate Excel files unless the user specifically asks for this format. -1. generate - Generate an Excel file from structured tabular data - Use this when the user asks for an Excel/Spreadsheet export (e.g. customer lists, product tables, analytics). - Parameters: - - fileName: Base name for the Excel file (without extension) - - sheets: Array of sheets with names, headers, and rows - Returns: { success, downloadUrl, fileName, rowCount, sheetCount } +OPERATION: -2. parse - Extract structured data from an existing Excel file - USE THIS when a user uploads an Excel file and you need to read its content. - Parameters: - - fileId: **REQUIRED** - Convex storage ID (e.g., "kg2bazp7fbgt9srq63knfagjrd7yfenj") - - filename: Optional — original filename (e.g., "report.xlsx"). Auto-resolved from file metadata if omitted. - Returns: { success, sheets (with headers and rows), totalRows, sheetCount } +generate - Generate an Excel file from structured tabular data + Use this when the user asks for an Excel/Spreadsheet export (e.g. customer lists, product tables, analytics). + Parameters: + - fileName: Base name for the Excel file (without extension) + - sheets: Array of sheets with names, headers, and rows + Returns: { success, downloadUrl, fileName, rowCount, sheetCount } -EXAMPLES: +EXAMPLE: • Generate: { "operation": "generate", "fileName": "customers", "sheets": [{ "name": "Sheet1", "headers": ["Name", "Email"], "rows": [["Alice", "alice@example.com"]] }] } -• Parse: { "operation": "parse", "fileId": "kg2bazp7...", "filename": "report.xlsx" } AFTER GENERATING: Check the downloadUrl in the result: - If it says "[file card shown in chat]": the file is already visible as a download card. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. - If it contains an actual URL: no download card was shown. You MUST include the URL as a clickable markdown link so the user can download the file. To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. + +TO READ FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: +• To get the full content of a file: use rag_search with operation='retrieve' and the fileId +• To search for specific information across files: use rag_search with operation='search' `, inputSchema: excelArgs, - execute: async (ctx: ToolCtx, args): Promise => { - if (args.operation === 'parse') { - const resolvedFilename = await resolveFileName( - ctx, - args.fileId, - args.filename, - ); - - debugLog('tool:excel parse start', { - fileId: args.fileId, - filename: resolvedFilename, - }); - - try { - const result = await ctx.runAction( - internal.node_only.documents.internal_actions.parseExcel, - { - storageId: toId<'_storage'>(args.fileId), - }, - ); - - debugLog('tool:excel parse success', { - filename: resolvedFilename, - sheetCount: result.sheetCount, - totalRows: result.totalRows, - }); - - return { - operation: 'parse', - success: true, - fileName: resolvedFilename, - sheets: result.sheets, - totalRows: result.totalRows, - sheetCount: result.sheetCount, - }; - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - console.error('[tool:excel parse] error', { - fileId: args.fileId, - error: message, - }); - return { - operation: 'parse', - success: false, - fileName: resolvedFilename, - sheets: [], - totalRows: 0, - sheetCount: 0, - error: message, - }; - } - } - - // operation === 'generate' + execute: async (ctx: ToolCtx, args): Promise => { debugLog('tool:excel generate start', { fileName: args.fileName, sheetCount: args.sheets.length, diff --git a/services/platform/convex/agent_tools/files/helpers/analyze_text.ts b/services/platform/convex/agent_tools/files/helpers/analyze_text.ts deleted file mode 100644 index 41c0691c02..0000000000 --- a/services/platform/convex/agent_tools/files/helpers/analyze_text.ts +++ /dev/null @@ -1,467 +0,0 @@ -/** - * Helper for analyzing text files using the fast model. - * Handles encoding detection, chunking for large files, and LLM analysis. - * Uses ctx.storage.get() for direct Convex storage access (like analyze_image.ts). - * Uses Agent framework with saveMessages: 'none' to avoid creating visible thread messages. - */ - -import type { LanguageModelV3 } from '@ai-sdk/provider'; -import { Agent } from '@convex-dev/agent'; - -import { components } from '../../../_generated/api'; -import type { ActionCtx } from '../../../_generated/server'; -import { createDebugLog } from '../../../lib/debug_log'; -import { toId } from '../../../lib/type_cast_helpers'; - -const debugLog = createDebugLog('DEBUG_TEXT_ANALYSIS', '[TextAnalysis]'); - -const LLM_CHUNK_SIZE = 80 * 1024; // 80KB chunks for LLM processing -const MAX_TEXT_BYTES = 10 * 1024 * 1024; // 10MB max file size -const MAX_CONCURRENT_CHUNKS = 5; // Limit concurrent LLM requests to avoid rate limiting -const MAX_TOTAL_CHUNK_OUTPUT_CHARS = 30000; // Total chars budget for all chunk outputs combined -const MAX_FINAL_RESPONSE_CHARS = 10000; // Max chars for final aggregated response - -/** - * Process items with controlled concurrency (like p-map). - */ -async function mapWithConcurrency( - items: T[], - fn: (item: T, index: number) => Promise, - concurrency: number, -): Promise { - const results: R[] = new Array(items.length); - let nextIndex = 0; - - async function worker() { - while (nextIndex < items.length) { - const index = nextIndex++; - results[index] = await fn(items[index], index); - } - } - - const workers = Array.from( - { length: Math.min(concurrency, items.length) }, - () => worker(), - ); - await Promise.all(workers); - return results; -} -const SUPPORTED_ENCODINGS = [ - 'utf-8', - 'utf-16le', - 'utf-16be', - 'gbk', - 'gb2312', - 'big5', - 'shift_jis', - 'iso-8859-1', -]; - -export interface AnalyzeTextParams { - fileId: string; - filename: string; - userInput: string; - model: string; - languageModel: LanguageModelV3; -} - -export interface AnalyzeTextUsage { - inputTokens: number; - outputTokens: number; - totalTokens: number; -} - -export interface AnalyzeTextResult { - success: boolean; - result: string; - charCount: number; - lineCount: number; - encoding: string; - chunked: boolean; - chunkCount?: number; - model?: string; - usage?: AnalyzeTextUsage; - error?: string; -} - -function decodeWithEncoding(buffer: ArrayBuffer): { - text: string; - encoding: string; -} { - for (const encoding of SUPPORTED_ENCODINGS) { - try { - const decoder = new TextDecoder(encoding, { fatal: true }); - const text = decoder.decode(buffer); - if (text.length > 0 && !text.includes('\uFFFD')) { - return { text, encoding }; - } - } catch { - continue; - } - } - - const decoder = new TextDecoder('utf-8', { fatal: false }); - return { text: decoder.decode(buffer), encoding: 'utf-8 (fallback)' }; -} - -function isBinaryContent(text: string): boolean { - const sampleSize = Math.min(1000, text.length); - const sample = text.slice(0, sampleSize); - - let nullCount = 0; - let controlCount = 0; - - for (let i = 0; i < sample.length; i++) { - const code = sample.charCodeAt(i); - if (code === 0) nullCount++; - // Control chars (except tab, newline, carriage return) - if (code < 32 && code !== 9 && code !== 10 && code !== 13) controlCount++; - } - - const nullRatio = nullCount / sampleSize; - const controlRatio = controlCount / sampleSize; - - return nullRatio > 0.01 || controlRatio > 0.1; -} - -function splitIntoChunks(text: string, chunkSize: number): string[] { - const chunks: string[] = []; - let start = 0; - - while (start < text.length) { - let end = Math.min(start + chunkSize, text.length); - - // Try to break at a line boundary if not at the end - if (end < text.length) { - const lastNewline = text.lastIndexOf('\n', end); - if (lastNewline > start + chunkSize * 0.5) { - end = lastNewline + 1; - } - } - - chunks.push(text.slice(start, end)); - start = end; - } - - return chunks; -} - -const TEXT_ANALYSIS_INSTRUCTIONS = `You are a text analysis assistant. Your job is to analyze text content and answer the user's question accurately. - -Guidelines: -- Focus on answering the user's specific question -- Extract relevant information from the text -- Be concise but thorough -- If the text doesn't contain relevant information, say so clearly -- For large texts processed in chunks, focus on the most relevant parts`; - -function createTextAnalysisAgent(languageModel: LanguageModelV3): Agent { - const instructions = `${TEXT_ANALYSIS_INSTRUCTIONS}\n\nIf you use any tools, you must always conclude by producing a final assistant message with the answer.`; - - return new Agent(components.agent, { - name: 'text-analyzer', - languageModel, - instructions, - }); -} - -/** - * Generate unique userId for one-off analysis (messages won't be saved). - */ -function generateEphemeralUserId(): string { - return `text-analyzer-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; -} - -interface ChunkResult { - text: string; - usage: AnalyzeTextUsage; -} - -async function analyzeChunk( - ctx: ActionCtx, - agent: Agent, - text: string, - userInput: string, - chunkIndex?: number, - totalChunks?: number, - maxResponseChars?: number, -): Promise { - const chunkInfo = - totalChunks && totalChunks > 1 - ? `\n\n[Processing chunk ${(chunkIndex ?? 0) + 1} of ${totalChunks}]` - : ''; - - // Dynamic limit based on chunk count, or use full budget for single chunk - const charLimit = maxResponseChars ?? MAX_FINAL_RESPONSE_CHARS; - - const prompt = `User Question: ${userInput}${chunkInfo} - -Text Content: ---- -${text} ---- - -Please analyze the text above and answer the user's question. -IMPORTANT: Keep your response under ${charLimit} characters. Be concise and focus on key findings.`; - - const result = await agent.generateText( - ctx, - { userId: generateEphemeralUserId() }, - { prompt }, - { storageOptions: { saveMessages: 'none' } }, - ); - - const inputTokens = result.usage?.inputTokens ?? 0; - const outputTokens = result.usage?.outputTokens ?? 0; - - return { - text: result.text || '', - usage: { - inputTokens, - outputTokens, - totalTokens: inputTokens + outputTokens, - }, - }; -} - -async function aggregateChunkResults( - ctx: ActionCtx, - agent: Agent, - chunkResults: string[], - userInput: string, -): Promise { - if (chunkResults.length <= 1) { - return { - text: chunkResults[0] ?? '', - usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, - }; - } - - const combinedResults = chunkResults - .map((r, i) => `[Chunk ${i + 1} Analysis]\n${r}`) - .join('\n\n---\n\n'); - - const prompt = `The following are analysis results from different parts of a large text file. -User's original question: ${userInput} - -Analysis Results: -${combinedResults} - -Please synthesize these results into a coherent, comprehensive answer to the user's question. -Remove any redundancy and present the key findings clearly. -IMPORTANT: Keep your final response under ${MAX_FINAL_RESPONSE_CHARS} characters. Prioritize the most important information.`; - - try { - const result = await agent.generateText( - ctx, - { userId: generateEphemeralUserId() }, - { prompt }, - { storageOptions: { saveMessages: 'none' } }, - ); - - const inputTokens = result.usage?.inputTokens ?? 0; - const outputTokens = result.usage?.outputTokens ?? 0; - - return { - text: result.text || '', - usage: { - inputTokens, - outputTokens, - totalTokens: inputTokens + outputTokens, - }, - }; - } catch (error) { - debugLog('aggregateChunkResults error', { - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } -} - -/** - * Analyze text file content using fast model. - * Uses ctx.storage.get() for direct Convex storage access (like analyze_image.ts). - * For large files, splits into chunks and processes each with the user's question. - * Uses Agent framework with saveMessages: 'none' to avoid creating visible thread messages. - */ -export async function analyzeTextContent( - ctx: ActionCtx, - params: AnalyzeTextParams, -): Promise { - const { fileId, filename, userInput, model } = params; - - debugLog('analyzeTextContent starting', { - fileId, - filename, - userInput: - userInput.length > 50 ? userInput.slice(0, 50) + '...' : userInput, - }); - - try { - // Get the text file blob from storage (like analyze_image.ts) - const textBlob = await ctx.storage.get(toId<'_storage'>(fileId)); - if (!textBlob) { - throw new Error(`Text file not found in storage: ${fileId}`); - } - - debugLog('analyzeTextContent got blob', { size: textBlob.size }); - - // Check file size limit - if (textBlob.size > MAX_TEXT_BYTES) { - const sizeMB = (textBlob.size / (1024 * 1024)).toFixed(2); - const maxMB = (MAX_TEXT_BYTES / (1024 * 1024)).toFixed(0); - return { - success: false, - result: '', - charCount: 0, - lineCount: 0, - encoding: 'unknown', - chunked: false, - error: `Text file is too large (${sizeMB}MB). Please upload a file smaller than ${maxMB}MB.`, - }; - } - - const buffer = await textBlob.arrayBuffer(); - debugLog('analyzeTextContent loaded', { bytes: buffer.byteLength }); - - const { text, encoding } = decodeWithEncoding(buffer); - - if (isBinaryContent(text)) { - return { - success: false, - result: '', - charCount: 0, - lineCount: 0, - encoding, - chunked: false, - error: - 'The file appears to be binary, not a text-based file. Please upload a valid text file (.txt, .md, .js, .ts, .json, .csv, .log, etc.).', - }; - } - - const charCount = text.length; - const lineCount = text.split('\n').length; - - debugLog('analyzeTextContent decoded', { charCount, lineCount, encoding }); - - const agent = createTextAnalysisAgent(params.languageModel); - - // For smaller content, process in one pass - if (charCount <= LLM_CHUNK_SIZE) { - const chunkResult = await analyzeChunk(ctx, agent, text, userInput); - - return { - success: true, - result: chunkResult.text, - charCount, - lineCount, - encoding, - chunked: false, - model, - usage: chunkResult.usage, - }; - } - - // For larger content, split into chunks and process with controlled concurrency - const chunks = splitIntoChunks(text, LLM_CHUNK_SIZE); - - // Dynamic per-chunk output limit: divide total budget by chunk count - const perChunkMaxChars = Math.floor( - MAX_TOTAL_CHUNK_OUTPUT_CHARS / chunks.length, - ); - - debugLog('analyzeTextContent chunking', { - chunkCount: chunks.length, - chunkSizes: chunks.map((c) => c.length), - perChunkMaxChars, - concurrency: MAX_CONCURRENT_CHUNKS, - }); - - // Process chunks with controlled concurrency to avoid rate limiting - const startTime = Date.now(); - const chunkResults = await mapWithConcurrency( - chunks, - async (chunk, i) => { - debugLog('analyzeTextContent processing chunk', { - chunk: `${i + 1}/${chunks.length}`, - chunkSize: chunk.length, - }); - const result = await analyzeChunk( - ctx, - agent, - chunk, - userInput, - i, - chunks.length, - perChunkMaxChars, - ); - debugLog('analyzeTextContent chunk completed', { - chunk: `${i + 1}/${chunks.length}`, - resultLength: result.text.length, - elapsedMs: Date.now() - startTime, - }); - return result; - }, - MAX_CONCURRENT_CHUNKS, - ); - debugLog('analyzeTextContent all chunks completed', { - chunkCount: chunkResults.length, - totalElapsedMs: Date.now() - startTime, - }); - - // Accumulate usage from all chunks - const totalUsage: AnalyzeTextUsage = { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - }; - for (const cr of chunkResults) { - totalUsage.inputTokens += cr.usage.inputTokens; - totalUsage.outputTokens += cr.usage.outputTokens; - totalUsage.totalTokens += cr.usage.totalTokens; - } - - debugLog('analyzeTextContent aggregating results', { - chunkCount: chunkResults.length, - }); - const aggregationResult = await aggregateChunkResults( - ctx, - agent, - chunkResults.map((cr) => cr.text), - userInput, - ); - debugLog('analyzeTextContent aggregation completed', { - resultLength: aggregationResult.text.length, - }); - - // Add aggregation usage - totalUsage.inputTokens += aggregationResult.usage.inputTokens; - totalUsage.outputTokens += aggregationResult.usage.outputTokens; - totalUsage.totalTokens += aggregationResult.usage.totalTokens; - - return { - success: true, - result: aggregationResult.text, - charCount, - lineCount, - encoding, - chunked: true, - chunkCount: chunks.length, - model, - usage: totalUsage, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - debugLog('analyzeTextContent error', { filename, error: errorMessage }); - - return { - success: false, - result: '', - charCount: 0, - lineCount: 0, - encoding: 'unknown', - chunked: false, - error: errorMessage, - }; - } -} diff --git a/services/platform/convex/agent_tools/files/helpers/parse_file.ts b/services/platform/convex/agent_tools/files/helpers/parse_file.ts deleted file mode 100644 index 62a388c47b..0000000000 --- a/services/platform/convex/agent_tools/files/helpers/parse_file.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Shared file parsing helper for PDF, DOCX, and PPTX tools. - * Gets file from Convex storage and sends it to the crawler service for text extraction. - * Uses ctx.storage.get() for direct Convex storage access (like image_tool and text_tool). - */ - -import { getParseEndpoint } from '../../../../lib/shared/file-types'; -import { fetchJson } from '../../../../lib/utils/type-cast-helpers'; -import type { ActionCtx } from '../../../_generated/server'; -import { createDebugLog } from '../../../lib/debug_log'; -import { toId } from '../../../lib/type_cast_helpers'; -import { getCrawlerServiceUrl } from '../../web/helpers/get_crawler_service_url'; -import { resolveFileName } from './resolve_file_name'; - -const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]'); - -export interface ParseFileResult { - success: boolean; - filename: string; - file_type?: string; - full_text?: string; - page_count?: number; - slide_count?: number; - paragraph_count?: number; - metadata?: { - title?: string; - author?: string; - subject?: string; - }; - usage?: { - inputTokens: number; - outputTokens: number; - totalTokens: number; - durationMs?: number; - model?: string; - }; - error?: string; -} - -/** - * Parse a file by getting it from Convex storage and sending it to the crawler service. - * @param ctx - Action context for storage access - * @param fileId - Convex storage ID of the file - * @param filename - Original filename with extension (optional, resolved from fileMetadata if not provided) - * @param toolName - Name of the calling tool (for logging) - * @param userInput - Optional user question/instruction to guide parsing - * @returns ParseFileResult with extracted text and metadata - */ -export async function parseFile( - ctx: ActionCtx, - fileId: string, - filename: string | undefined, - toolName: string, - userInput?: string, - model?: string, -): Promise { - const resolvedFilename = await resolveFileName(ctx, fileId, filename); - - debugLog(`tool:${toolName} parse start`, { - fileId, - filename: resolvedFilename, - }); - - try { - // Get the file blob from Convex storage (like image_tool and text_tool) - const fileBlob = await ctx.storage.get(toId<'_storage'>(fileId)); - if (!fileBlob) { - throw new Error(`File not found in storage: ${fileId}`); - } - - debugLog(`tool:${toolName} parse got blob`, { - filename: resolvedFilename, - size: fileBlob.size, - type: fileBlob.type, - }); - - const crawlerUrl = getCrawlerServiceUrl(); - const endpointPath = getParseEndpoint(resolvedFilename); - const apiUrl = `${crawlerUrl}${endpointPath}`; - - // Create FormData and upload to crawler service - const formData = new FormData(); - formData.append('file', fileBlob, resolvedFilename); - if (userInput) { - formData.append('user_input', userInput); - } - if (model) { - formData.append('model', model); - } - - debugLog(`tool:${toolName} parse uploading to crawler`, { - filename: resolvedFilename, - size: fileBlob.size, - endpoint: endpointPath, - hasUserInput: !!userInput, - model: model ?? null, - }); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 300_000); - - const response = await fetch(apiUrl, { - method: 'POST', - body: formData, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text().catch(() => ''); - throw new Error(`Crawler service error: ${response.status} ${errorText}`); - } - - interface RawCrawlerUsage { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - duration_ms?: number; - model?: string; - } - - const raw = await fetchJson( - response, - ); - - // Remap snake_case usage from crawler to camelCase - const result: ParseFileResult = { ...raw }; - if (raw.usage) { - result.usage = { - inputTokens: raw.usage.input_tokens ?? 0, - outputTokens: raw.usage.output_tokens ?? 0, - totalTokens: raw.usage.total_tokens ?? 0, - durationMs: raw.usage.duration_ms, - model: raw.usage.model, - }; - } - - debugLog(`tool:${toolName} parse success`, { - filename: result.filename, - success: result.success, - textLength: result.full_text?.length ?? 0, - }); - - return result; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`[tool:${toolName} parse] error`, { - filename: resolvedFilename, - error: message, - }); - return { - success: false, - filename: resolvedFilename, - error: message, - }; - } -} diff --git a/services/platform/convex/agent_tools/files/helpers/resolve_file_name.ts b/services/platform/convex/agent_tools/files/helpers/resolve_file_name.ts deleted file mode 100644 index 1fcd1b3735..0000000000 --- a/services/platform/convex/agent_tools/files/helpers/resolve_file_name.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Resolves a filename for a given storage ID. - * If a filename is provided, returns it directly. - * Otherwise, looks up the filename from the fileMetadata table. - */ - -import { internal } from '../../../_generated/api'; -import type { ActionCtx } from '../../../_generated/server'; -import { toId } from '../../../lib/type_cast_helpers'; - -export async function resolveFileName( - ctx: ActionCtx, - fileId: string, - providedFilename?: string, -): Promise { - if (providedFilename) { - return providedFilename; - } - - const metadata = await ctx.runQuery( - internal.file_metadata.internal_queries.getByStorageId, - { storageId: toId<'_storage'>(fileId) }, - ); - - if (!metadata) { - throw new Error( - `Could not resolve filename for fileId '${fileId}'. No fileMetadata record found. Please provide filename explicitly.`, - ); - } - - return metadata.fileName; -} diff --git a/services/platform/convex/agent_tools/files/internal_actions.ts b/services/platform/convex/agent_tools/files/internal_actions.ts index 9cbc6d0bdd..e14426f6ca 100644 --- a/services/platform/convex/agent_tools/files/internal_actions.ts +++ b/services/platform/convex/agent_tools/files/internal_actions.ts @@ -12,59 +12,6 @@ import { analyzeImage as analyzeImageHelper, type AnalyzeImageResult, } from './helpers/analyze_image'; -import { - parseFile as parseFileHelper, - type ParseFileResult, -} from './helpers/parse_file'; - -/** - * Internal action for parsing files (PDF, DOCX, PPTX). - * Wrapped for caching - same fileId/filename should return same result. - */ -export const parseFileUncached = internalAction({ - args: { - fileId: v.string(), - filename: v.string(), - toolName: v.string(), - model: v.optional(v.string()), - }, - returns: v.object({ - success: v.boolean(), - filename: v.string(), - file_type: v.optional(v.string()), - full_text: v.optional(v.string()), - page_count: v.optional(v.number()), - slide_count: v.optional(v.number()), - paragraph_count: v.optional(v.number()), - metadata: v.optional( - v.object({ - title: v.optional(v.string()), - author: v.optional(v.string()), - subject: v.optional(v.string()), - }), - ), - usage: v.optional( - v.object({ - inputTokens: v.number(), - outputTokens: v.number(), - totalTokens: v.number(), - durationMs: v.optional(v.number()), - model: v.optional(v.string()), - }), - ), - error: v.optional(v.string()), - }), - handler: async (ctx, args): Promise => { - return await parseFileHelper( - ctx, - args.fileId, - args.filename, - args.toolName, - undefined, - args.model, - ); - }, -}); /** * Internal action for analyzing images with vision model. diff --git a/services/platform/convex/agent_tools/files/pdf_tool.ts b/services/platform/convex/agent_tools/files/pdf_tool.ts index 3b4caea6ee..9fd33e6ab4 100644 --- a/services/platform/convex/agent_tools/files/pdf_tool.ts +++ b/services/platform/convex/agent_tools/files/pdf_tool.ts @@ -1,6 +1,5 @@ /** Convex Tool: PDF * Generate PDF documents from Markdown/HTML/URL via the crawler service. - * Parse PDF documents to extract text content. */ import type { ToolCtx } from '@convex-dev/agent'; @@ -11,8 +10,6 @@ import { internal } from '../../_generated/api'; import { createDebugLog } from '../../lib/debug_log'; import type { ToolDefinition } from '../types'; import { appendFilePart } from './helpers/append_file_part'; -import { getAgentModelId } from './helpers/get_agent_model'; -import { parseFile, type ParseFileResult } from './helpers/parse_file'; const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]'); @@ -27,14 +24,10 @@ interface GeneratePdfResult { size: number; } -type ParsePdfResult = { operation: 'parse' } & ParseFileResult; - -type PdfResult = GeneratePdfResult | ParsePdfResult; - export const pdfTool = { name: 'pdf' as const, tool: createTool({ - description: `PDF tool for generating, downloading, and parsing PDF documents. + description: `PDF tool for generating and downloading PDF documents. IMPORTANT: Only call the "generate" operation when the user explicitly requests creating or exporting a PDF file. Do NOT proactively generate PDFs unless the user specifically asks for this format. @@ -58,95 +51,57 @@ OPERATIONS: • Use this to download and store existing PDF files from external URLs • The returned fileStorageId can be passed to document_write to save to a folder in the documents hub -2. parse - Extract text content from an existing PDF file - USE THIS when a user uploads a PDF and you need to read its content. - Parameters: - - fileId: **REQUIRED** - Convex storage ID (e.g., "kg2bazp7fbgt9srq63knfagjrd7yfenj") - - filename: Optional — original filename (e.g., "report.pdf"). Auto-resolved from file metadata if omitted. - - user_input: **REQUIRED** - The user's question or instruction about the PDF - Returns: { success, full_text, page_count, metadata } - EXAMPLES: • Generate: { "operation": "generate", "fileName": "report", "sourceType": "markdown", "content": "# Report\\n..." } • Download existing PDF: { "operation": "generate", "fileName": "report", "sourceType": "url", "content": "https://example.com/report.pdf" } -• Parse: { "operation": "parse", "fileId": "kg2bazp7...", "filename": "report.pdf", "user_input": "Summarize the key findings" } AFTER GENERATING: Check the downloadUrl in the result: - If it says "[file card shown in chat]": the file is already visible as a download card. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. - If it contains an actual URL: no download card was shown. You MUST include the URL as a clickable markdown link so the user can download the file. To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. -`, - inputSchema: z.discriminatedUnion('operation', [ - z.object({ - operation: z.literal('generate'), - fileName: z - .string() - .describe('Base name for the PDF file (without extension)'), - sourceType: z - .enum(['markdown', 'html', 'url']) - .describe('Type of source content'), - content: z - .string() - .describe('Markdown text, HTML content, or URL to capture'), - pdfOptions: z - .object({ - format: z.string().optional(), - landscape: z.boolean().optional(), - marginTop: z.string().optional(), - marginBottom: z.string().optional(), - marginLeft: z.string().optional(), - marginRight: z.string().optional(), - printBackground: z.boolean().optional(), - }) - .optional() - .describe('Advanced PDF options'), - urlOptions: z - .object({ - waitUntil: z - .enum(['load', 'domcontentloaded', 'networkidle', 'commit']) - .optional(), - }) - .optional() - .describe('Options for URL capture'), - extraCss: z.string().optional().describe('Additional CSS to inject'), - wrapInTemplate: z - .boolean() - .optional() - .describe('Whether to wrap in HTML template'), - }), - z.object({ - operation: z.literal('parse'), - fileId: z - .string() - .describe( - "Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", - ), - filename: z - .string() - .optional() - .describe( - "Original filename (e.g., 'report.pdf'). Optional — auto-resolved from file metadata if omitted.", - ), - user_input: z - .string() - .describe("The user's question or instruction about the PDF content"), - }), - ]), - execute: async (ctx: ToolCtx, args): Promise => { - if (args.operation === 'parse') { - const model = getAgentModelId(ctx); - const result = await parseFile( - ctx, - args.fileId, - args.filename, - 'pdf', - args.user_input, - model, - ); - return { operation: 'parse', ...result }; - } - // operation === 'generate' +TO READ FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: +• To get the full content of a file: use rag_search with operation='retrieve' and the fileId +• To search for specific information across files: use rag_search with operation='search' +`, + inputSchema: z.object({ + operation: z.literal('generate'), + fileName: z + .string() + .describe('Base name for the PDF file (without extension)'), + sourceType: z + .enum(['markdown', 'html', 'url']) + .describe('Type of source content'), + content: z + .string() + .describe('Markdown text, HTML content, or URL to capture'), + pdfOptions: z + .object({ + format: z.string().optional(), + landscape: z.boolean().optional(), + marginTop: z.string().optional(), + marginBottom: z.string().optional(), + marginLeft: z.string().optional(), + marginRight: z.string().optional(), + printBackground: z.boolean().optional(), + }) + .optional() + .describe('Advanced PDF options'), + urlOptions: z + .object({ + waitUntil: z + .enum(['load', 'domcontentloaded', 'networkidle', 'commit']) + .optional(), + }) + .optional() + .describe('Options for URL capture'), + extraCss: z.string().optional().describe('Additional CSS to inject'), + wrapInTemplate: z + .boolean() + .optional() + .describe('Whether to wrap in HTML template'), + }), + execute: async (ctx: ToolCtx, args): Promise => { const { organizationId } = ctx; if (!organizationId) { throw new Error('organizationId is required to generate a PDF'); diff --git a/services/platform/convex/agent_tools/files/pptx_tool.ts b/services/platform/convex/agent_tools/files/pptx_tool.ts index 03c4519748..07956a8c28 100644 --- a/services/platform/convex/agent_tools/files/pptx_tool.ts +++ b/services/platform/convex/agent_tools/files/pptx_tool.ts @@ -1,7 +1,7 @@ /** * Convex Tool: PPTX * - * PPTX operations for agents: list templates, generate presentations, and parse existing files. + * Generate PPTX presentations from Markdown or HTML via the crawler service. */ import type { ToolCtx } from '@convex-dev/agent'; @@ -9,279 +9,68 @@ import { createTool } from '@convex-dev/agent'; import { z } from 'zod/v4'; import { internal } from '../../_generated/api'; -import type { ListDocumentsByExtensionResult } from '../../documents/types'; import { createDebugLog } from '../../lib/debug_log'; -import { toId } from '../../lib/type_cast_helpers'; import type { ToolDefinition } from '../types'; import { appendFilePart } from './helpers/append_file_part'; -import { getAgentModelId } from './helpers/get_agent_model'; -import { parseFile, type ParseFileResult } from './helpers/parse_file'; const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]'); -// Table data schema for generation -const tableDataSchema = z.object({ - headers: z.array(z.string()).describe('Column headers'), - rows: z.array(z.array(z.string())).describe('Table data rows'), -}); - -// Slide content schema for generation -const slideContentSchema = z.object({ - title: z.string().optional().describe('Slide title'), - subtitle: z.string().optional().describe('Slide subtitle'), - textContent: z.array(z.string()).optional().describe('Text paragraphs'), - bulletPoints: z.array(z.string()).optional().describe('Bullet point items'), - tables: z - .array(tableDataSchema) - .optional() - .describe('Tables to add to the slide'), -}); - -// Branding schema -const brandingSchema = z.object({ - slideWidth: z.number().optional().describe('Slide width in inches'), - slideHeight: z.number().optional().describe('Slide height in inches'), - titleFontName: z - .string() - .optional() - .describe('Font name for titles (e.g., "Arial")'), - bodyFontName: z - .string() - .optional() - .describe('Font name for body text (e.g., "Calibri")'), - titleFontSize: z - .number() - .optional() - .describe('Font size for titles in points'), - bodyFontSize: z - .number() - .optional() - .describe('Font size for body text in points'), - primaryColor: z - .string() - .optional() - .describe('Primary color as hex (e.g., "#003366")'), - secondaryColor: z.string().optional().describe('Secondary color as hex'), - accentColor: z.string().optional().describe('Accent color as hex'), -}); +interface GeneratePptxResult { + operation: 'generate'; + success: boolean; + fileStorageId: string; + downloadUrl: string; + fileName: string; + contentType: string; + extension: string; + size: number; +} const pptxArgs = z.discriminatedUnion('operation', [ - z.object({ - operation: z.literal('list_templates'), - limit: z - .number() - .optional() - .describe('Maximum number of templates to return (default: 50)'), - }), z.object({ operation: z.literal('generate'), - templateStorageId: z - .string() - .optional() - .describe( - 'Convex storage ID of the PPTX template. The template is used as base, preserving all styling, backgrounds, and decorative elements.', - ), fileName: z .string() .describe('Base name for the PPTX file (without extension)'), - slidesContent: z - .array(slideContentSchema) - .describe('Content for each slide in the presentation'), - branding: brandingSchema - .optional() - .describe('Optional additional branding overrides'), - }), - z.object({ - operation: z.literal('parse'), - fileId: z - .string() - .describe( - "Convex storage ID (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", - ), - filename: z + sourceType: z.enum(['markdown', 'html']).describe('Source content type'), + content: z .string() - .optional() - .describe( - "Original filename (e.g., 'presentation.pptx'). Optional — auto-resolved from file metadata if omitted.", - ), - user_input: z - .string() - .describe( - "The user's question or instruction about the presentation content", - ), + .describe('The Markdown or HTML content to convert to PPTX'), }), ]); -// Result types -interface ListTemplatesResult { - operation: 'list_templates'; - success: boolean; - templates: Array<{ - fileId: string; - title: string; - createdAt: number; - }>; - totalCount: number; - message: string; -} - -interface GenerateResult { - operation: 'generate'; - success: boolean; - fileStorageId: string; - downloadUrl: string; - fileName: string; - contentType: string; - size: number; - error?: string; -} - -type ParsePptxResult = { operation: 'parse' } & ParseFileResult; - -type PptxResult = ListTemplatesResult | GenerateResult | ParsePptxResult; - export const pptxTool: ToolDefinition = { name: 'pptx', tool: createTool({ - description: `PowerPoint (PPTX) tool for listing templates, generating, and parsing presentations. + description: `PowerPoint (PPTX) tool for generating presentations from Markdown or HTML content. IMPORTANT: Only call the "generate" operation when the user explicitly requests creating or exporting a PowerPoint/PPTX file. Do NOT proactively generate presentations unless the user specifically asks for this format. -IMPORTANT WORKFLOW FOR GENERATING PPTX: -1. FIRST call list_templates to check if templates are available -2. If no templates found, tell the user to upload a .pptx template to the Knowledge Base (Documents page) — NOT in the chat. Include the link from the list_templates result. -3. Only call generate after you have a valid templateStorageId from list_templates - OPERATIONS: -1. list_templates - List all available PPTX templates - ALWAYS call this first before generate! - Returns: { templates, totalCount, message } - -2. generate - Generate a PPTX with your content - REQUIRES templateStorageId from list_templates - do NOT call without it! - Pass slidesContent with your content. Each slide can have: - - title, subtitle, textContent, bulletPoints, tables - The backend automatically selects the best layout based on content. - -3. parse - Extract text content from an existing PPTX file - USE THIS when a user uploads a PPTX and you need to read its content. +1. generate - Generate a PPTX from Markdown or HTML Parameters: - - fileId: **REQUIRED** - Convex storage ID (e.g., "kg2bazp7fbgt9srq63knfagjrd7yfenj") - - filename: Optional — original filename (e.g., "presentation.pptx"). Auto-resolved from file metadata if omitted. - - user_input: **REQUIRED** - The user's question or instruction about the presentation - Returns: { success, full_text, slide_count, metadata } + - fileName: Base name for the PPTX (without extension) + - sourceType: "markdown" or "html" + - content: The Markdown or HTML content to convert + Returns: { success, fileStorageId, downloadUrl, fileName, contentType, size } EXAMPLES: -• List templates: { "operation": "list_templates" } -• Generate: { "operation": "generate", "templateStorageId": "kg...", "fileName": "Report", "slidesContent": [...] } -• Parse: { "operation": "parse", "fileId": "kg2bazp7...", "filename": "presentation.pptx", "user_input": "Summarize the key slides" } - -SLIDE CONTENT EXAMPLES: -- Title slide: { "title": "Welcome", "subtitle": "Introduction" } -- Content slide: { "title": "Agenda", "bulletPoints": ["Point 1", "Point 2"] } -- With table: { "title": "Data", "tables": [{"headers": ["A", "B"], "rows": [["1", "2"]]}] } +• Generate from Markdown: { "operation": "generate", "fileName": "Report", "sourceType": "markdown", "content": "# Slide 1\\n\\nBullet points here..." } +• Generate from HTML: { "operation": "generate", "fileName": "Report", "sourceType": "html", "content": "

Slide 1

  • Item
" } AFTER GENERATING: Check the downloadUrl in the result: - If it says "[file card shown in chat]": the file is already visible as a download card. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. - If it contains an actual URL: no download card was shown. You MUST include the URL as a clickable markdown link so the user can download the file. -To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath.`, +To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. + +TO READ FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: +• To get the full content of a file: use rag_search with operation='retrieve' and the fileId +• To search for specific information across files: use rag_search with operation='search' +`, inputSchema: pptxArgs, - execute: async (ctx: ToolCtx, args): Promise => { + execute: async (ctx: ToolCtx, args): Promise => { const { organizationId } = ctx; - - // Handle list_templates operation - if (args.operation === 'list_templates') { - if (!organizationId) { - return { - operation: 'list_templates', - success: false, - templates: [], - totalCount: 0, - message: - 'No organizationId in context - cannot list templates. This tool requires organizationId to be set.', - }; - } - - debugLog('tool:pptx list_templates start', { - organizationId, - limit: args.limit, - }); - - try { - const documents: ListDocumentsByExtensionResult = await ctx.runQuery( - internal.documents.internal_queries.listDocumentsByExtension, - { - organizationId, - extension: 'pptx', - limit: args.limit, - }, - ); - - const templates = documents - .filter( - (doc): doc is typeof doc & { fileId: string } => !!doc.fileId, - ) - .map((doc) => ({ - fileId: doc.fileId, - title: doc.title ?? 'Untitled Template', - createdAt: doc._creationTime, - })); - - debugLog('tool:pptx list_templates success', { - totalCount: templates.length, - }); - - const siteUrl = process.env.SITE_URL || ''; - const basePath = process.env.BASE_PATH || ''; - const knowledgeUrl = `${siteUrl}${basePath}/dashboard/${organizationId}/documents`; - - return { - operation: 'list_templates', - success: true, - templates, - totalCount: templates.length, - message: - templates.length > 0 - ? `Found ${templates.length} PPTX template(s). Use the fileId as templateStorageId for generate operations.` - : `No PPTX templates found. The user must upload a .pptx template file to the Knowledge Base first — uploading in the chat will NOT work as a template. Direct the user to: ${knowledgeUrl} . Do NOT attempt to call generate without a template.`, - }; - } catch (error) { - console.error('[tool:pptx list_templates] error', { - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } - } - - if (args.operation === 'parse') { - const model = getAgentModelId(ctx); - const result = await parseFile( - ctx, - args.fileId, - args.filename, - 'pptx', - args.user_input, - model, - ); - return { operation: 'parse', ...result }; - } - - // operation === 'generate' - if (!args.templateStorageId) { - return { - operation: 'generate', - success: false, - fileStorageId: '', - downloadUrl: '', - fileName: args.fileName, - contentType: '', - size: 0, - error: - 'templateStorageId is required. Call list_templates first to get available templates. If no templates exist, the user must upload a .pptx template to the Knowledge Base (Documents page) — not in chat.', - }; - } - if (!organizationId) { throw new Error( 'organizationId is required to generate a presentation', @@ -290,20 +79,18 @@ To also save the file to a folder in the documents hub, call document_write with debugLog('tool:pptx generate start', { fileName: args.fileName, - slidesCount: args.slidesContent.length, - hasBranding: !!args.branding, - hasTemplate: !!args.templateStorageId, + sourceType: args.sourceType, }); try { const result = await ctx.runAction( - internal.documents.internal_actions.generatePptx, + internal.documents.internal_actions.generateDocument, { organizationId, fileName: args.fileName, - slidesContent: args.slidesContent, - branding: args.branding, - templateStorageId: toId<'_storage'>(args.templateStorageId), + sourceType: args.sourceType, + outputFormat: 'pptx', + content: args.content, }, ); @@ -325,7 +112,7 @@ To also save the file to a folder in the documents hub, call document_write with downloadUrl: cardAppended ? '[file card shown in chat]' : result.downloadUrl, - } as GenerateResult; + } as GeneratePptxResult; } catch (error) { console.error('[tool:pptx generate] error', { fileName: args.fileName, diff --git a/services/platform/convex/agent_tools/files/text_tool.ts b/services/platform/convex/agent_tools/files/text_tool.ts index ebafcb5041..0362c53b34 100644 --- a/services/platform/convex/agent_tools/files/text_tool.ts +++ b/services/platform/convex/agent_tools/files/text_tool.ts @@ -1,8 +1,6 @@ /** Convex Tool: Text - * Parse text-based files and analyze content using fast model. * Generate plain text files from content. * Supports all text formats: .txt, .md, .js, .ts, .json, .csv, .log, code files, and more. - * Handles various encodings and large files via chunked processing. * Uses ctx.storage.get() for direct Convex storage access (like image_tool). */ @@ -14,28 +12,10 @@ import { internal } from '../../_generated/api'; import { createDebugLog } from '../../lib/debug_log'; import { buildDownloadUrl } from '../../lib/helpers/public_storage_url'; import type { ToolDefinition } from '../types'; -import { analyzeTextContent } from './helpers/analyze_text'; import { appendFilePart } from './helpers/append_file_part'; -import { getAgentModelId } from './helpers/get_agent_model'; -import { resolveFileName } from './helpers/resolve_file_name'; const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]'); -interface TextParseResult { - operation: 'parse'; - success: boolean; - result: string; - filename: string; - char_count: number; - line_count: number; - encoding: string; - chunked: boolean; - chunk_count?: number; - model?: string; - usage?: { inputTokens: number; outputTokens: number; totalTokens: number }; - error?: string; -} - interface TextGenerateResult { operation: 'generate'; success: boolean; @@ -47,26 +27,7 @@ interface TextGenerateResult { error?: string; } -type TextResult = TextParseResult | TextGenerateResult; - const textArgs = z.discriminatedUnion('operation', [ - z.object({ - operation: z.literal('parse'), - fileId: z - .string() - .describe( - "Convex storage ID of the file (e.g., 'kg2bazp7fbgt9srq63knfagjrd7yfenj'). Get this from the file attachment context.", - ), - filename: z - .string() - .optional() - .describe( - "Original filename (e.g., 'data.txt', 'script.js'). Optional — auto-resolved from file metadata if omitted.", - ), - user_input: z - .string() - .describe("The user's question or instruction about the file"), - }), z.object({ operation: z.literal('generate'), filename: z @@ -79,23 +40,10 @@ const textArgs = z.discriminatedUnion('operation', [ export const textTool = { name: 'text' as const, tool: createTool({ - description: `Text file tool for parsing, analyzing, and generating text-based files (.txt, .md, .js, .ts, .json, .csv, .log, and any other text format). + description: `Text file tool for generating text-based files (.txt, .md, .js, .ts, .json, .csv, .log, and any other text format). IMPORTANT: Only call the "generate" operation when the user explicitly requests creating or exporting a text file. Do NOT proactively generate text files unless the user specifically asks for this format. -OPERATIONS: -1. **parse** - Parse and analyze an uploaded text-based file -2. **generate** - Create a new text file from content - -**PARSE OPERATION** -Use when a user uploads any text-based file and asks to analyze its content. -Supports all text formats: plain text (.txt), markdown (.md), source code (.js, .ts, .py, etc.), config files (.json, .yaml, .toml), logs (.log), CSV, and more. -Parameters: -- operation: "parse" -- fileId: **REQUIRED** - Convex storage ID (e.g., "kg2bazp7fbgt9srq63knfagjrd7yfenj") -- filename: Optional — original filename (e.g., "notes.txt", "app.js"). Auto-resolved from file metadata if omitted. -- user_input: The user's question or instruction - **GENERATE OPERATION** Use when a user wants to create/export a text file. Parameters: @@ -104,155 +52,86 @@ Parameters: - content: The text content to write EXAMPLES: -• Parse: { "operation": "parse", "fileId": "kg2...", "filename": "error.log", "user_input": "Find all errors" } -• Parse: { "operation": "parse", "fileId": "kg2...", "filename": "app.ts", "user_input": "Explain this code" } • Generate: { "operation": "generate", "filename": "report.md", "content": "# Report\\n\\nContent here..." } -Returns: { success, downloadUrl (for generate), result (for parse), char_count, line_count } +Returns: { success, downloadUrl, filename, char_count, line_count } AFTER GENERATING: Check the downloadUrl in the result: - If it says "[file card shown in chat]": the file is already visible as a download card. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. - If it contains an actual URL: no download card was shown. You MUST include the URL as a clickable markdown link so the user can download the file. To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. + +TO READ FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: +• To get the full content of a file: use rag_search with operation='retrieve' and the fileId +• To search for specific information across files: use rag_search with operation='search' `, inputSchema: textArgs, - execute: async (ctx: ToolCtx, args): Promise => { - if (args.operation === 'generate') { - const { filename, content } = args; - - try { - debugLog('tool:text generate start', { - filename, - contentLength: content.length, - }); - const blob = new Blob([content], { - type: 'text/plain; charset=utf-8', - }); - const fileId = await ctx.storage.store(blob); + execute: async (ctx: ToolCtx, args): Promise => { + const { filename, content } = args; - await ctx.runMutation( - internal.file_metadata.internal_mutations.saveFileMetadata, - { - organizationId: ctx.organizationId ?? 'system', - storageId: fileId, - fileName: filename, - contentType: 'text/plain; charset=utf-8', - size: blob.size, - source: 'agent', - }, - ); - - const url = buildDownloadUrl(fileId, filename); - const lineCount = content.split('\n').length; - - debugLog('tool:text generate success', { - filename, - fileId, - charCount: content.length, - lineCount, - }); + try { + debugLog('tool:text generate start', { + filename, + contentLength: content.length, + }); + const blob = new Blob([content], { + type: 'text/plain; charset=utf-8', + }); + const fileId = await ctx.storage.store(blob); - const cardAppended = await appendFilePart(ctx, { + await ctx.runMutation( + internal.file_metadata.internal_mutations.saveFileMetadata, + { + organizationId: ctx.organizationId ?? 'system', + storageId: fileId, fileName: filename, - mimeType: 'text/plain; charset=utf-8', - downloadUrl: url, - }); - - return { - operation: 'generate', - success: true, - fileStorageId: fileId, - downloadUrl: cardAppended ? '[file card shown in chat]' : url, - filename, - char_count: content.length, - line_count: lineCount, - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - console.error('[tool:text generate] error', { - filename, - error: errorMessage, - }); - - return { - operation: 'generate', - success: false, - fileStorageId: '', - downloadUrl: '', - filename, - char_count: 0, - line_count: 0, - error: errorMessage, - }; - } - } + contentType: 'text/plain; charset=utf-8', + size: blob.size, + source: 'agent', + }, + ); - // operation === 'parse' - const { fileId, filename, user_input } = args; - const model = getAgentModelId(ctx); - const resolvedFilename = await resolveFileName(ctx, fileId, filename); + const url = buildDownloadUrl(fileId, filename); + const lineCount = content.split('\n').length; - debugLog('tool:text parse start', { - fileId, - filename: resolvedFilename, - model, - user_input: - user_input.length > 100 - ? user_input.slice(0, 100) + '...' - : user_input, - }); - - try { - const result = await analyzeTextContent(ctx, { + debugLog('tool:text generate success', { + filename, fileId, - filename: resolvedFilename, - userInput: user_input, - model, - // oxlint-disable-next-line typescript/no-non-null-assertion,typescript/no-unsafe-type-assertion -- ctx.agent is guaranteed non-null inside a tool execute callback - languageModel: ctx.agent!.options - .languageModel as import('@ai-sdk/provider').LanguageModelV3, + charCount: content.length, + lineCount, }); - debugLog('tool:text parse success', { - filename: resolvedFilename, - charCount: result.charCount, - lineCount: result.lineCount, - chunked: result.chunked, + const cardAppended = await appendFilePart(ctx, { + fileName: filename, + mimeType: 'text/plain; charset=utf-8', + downloadUrl: url, }); return { - operation: 'parse', - success: result.success, - result: result.result, - filename: resolvedFilename, - char_count: result.charCount, - line_count: result.lineCount, - encoding: result.encoding, - chunked: result.chunked, - chunk_count: result.chunkCount, - model: result.model, - usage: result.usage, - error: result.error, + operation: 'generate', + success: true, + fileStorageId: fileId, + downloadUrl: cardAppended ? '[file card shown in chat]' : url, + filename, + char_count: content.length, + line_count: lineCount, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error('[tool:text parse] error', { - fileId, - filename: resolvedFilename, + console.error('[tool:text generate] error', { + filename, error: errorMessage, }); return { - operation: 'parse', + operation: 'generate', success: false, - result: '', - filename: resolvedFilename, + fileStorageId: '', + downloadUrl: '', + filename, char_count: 0, line_count: 0, - encoding: 'unknown', - chunked: false, error: errorMessage, }; } diff --git a/services/platform/convex/agent_tools/rag/helpers/fetch_document_chunks.ts b/services/platform/convex/agent_tools/rag/helpers/fetch_document_chunks.ts new file mode 100644 index 0000000000..a5aa761ee9 --- /dev/null +++ b/services/platform/convex/agent_tools/rag/helpers/fetch_document_chunks.ts @@ -0,0 +1,62 @@ +import { fetchJson } from '../../../../lib/utils/type-cast-helpers'; + +const MAX_CHUNK_WINDOW = 200; + +interface DocumentContentResponse { + file_id: string; + title: string | null; + content: string; + chunk_range: { start: number; end: number }; + total_chunks: number; + total_chars: number; + chunks: Array<{ index: number; content: string }> | null; +} + +export interface DocumentChunksResult { + documentId: string; + title: string | null; + chunks: Array<{ index: number; content: string }>; + totalChunks: number; +} + +export async function fetchDocumentChunks( + serviceUrl: string, + fileId: string, +): Promise { + const allChunks: Array<{ index: number; content: string }> = []; + let totalChunks = 0; + let documentId = ''; + let title: string | null = null; + let chunkStart = 1; + + while (true) { + const chunkEnd = chunkStart + MAX_CHUNK_WINDOW - 1; + const url = `${serviceUrl}/api/v1/documents/${encodeURIComponent(fileId)}/content?return_chunks=true&chunk_start=${chunkStart}&chunk_end=${chunkEnd}`; + + const response = await fetch(url); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error( + `RAG get_chunks error (${response.status}): ${errorText || 'Unknown error'}`, + ); + } + + const result = await fetchJson(response); + documentId = result.file_id; + title = result.title; + totalChunks = result.total_chunks; + + if (result.chunks) { + allChunks.push(...result.chunks); + } + + if (result.chunk_range.end >= totalChunks) { + break; + } + + chunkStart = result.chunk_range.end + 1; + } + + return { documentId, title, chunks: allChunks, totalChunks }; +} diff --git a/services/platform/convex/agent_tools/rag/rag_search_tool.ts b/services/platform/convex/agent_tools/rag/rag_search_tool.ts index 792f5e4320..9a4590ae79 100644 --- a/services/platform/convex/agent_tools/rag/rag_search_tool.ts +++ b/services/platform/convex/agent_tools/rag/rag_search_tool.ts @@ -26,6 +26,7 @@ import { formatSearchResults, type SearchResponse, } from './format_search_results'; +import { fetchDocumentChunks } from './helpers/fetch_document_chunks'; import { listIndexedDocuments } from './helpers/list_indexed_documents'; // ToolCtx from @convex-dev/agent does not include our agent knowledge @@ -113,15 +114,24 @@ const ragToolArgs = z.discriminatedUnion('operation', [ 'Pagination cursor from previous response. Pass the exact cursor value returned — do not fabricate.', ), }), + z.object({ + operation: z.literal('retrieve'), + fileId: z + .string() + .describe( + 'File ID of the document to retrieve full content from (e.g., "kg2bazp7fbgt9srq63knfagjrd7yfenj")', + ), + }), ]); export const ragSearchTool = { name: 'rag_search' as const, tool: createTool({ - description: `Knowledge base tool for searching content and listing indexed documents. + description: `Knowledge base tool for searching, retrieving, and listing indexed documents. OPERATIONS: • 'search': Search the knowledge base for relevant document excerpts using hybrid search (BM25 + vector similarity). Returns numbered excerpts with relevance scores. +• 'retrieve': Retrieve the full text content of a document by file ID. Use this whenever you need to read or analyze an uploaded file's content (PDF, DOCX, PPTX, TXT, XLSX, etc.). This is the primary way to read file content. • 'list_indexed': List documents that have been indexed in the knowledge base. Returns file names, file IDs, and modification dates. Use this to see what's available before searching. WHEN TO USE 'search': @@ -129,9 +139,14 @@ WHEN TO USE 'search': • Questions about stored documents and content • Finding information when you don't know exact field values +WHEN TO USE 'retrieve': +• Reading the full content of a specific uploaded file +• When a user uploads a file and asks you to read, summarize, or analyze it +• When you need the complete text of a document (not just search excerpts) + WHEN TO USE 'list_indexed': • See which files are available for RAG search -• Get file IDs for use with the search operation's fileIds parameter +• Get file IDs for use with the search or retrieve operations • Check when files were last modified WHEN NOT TO USE: @@ -155,6 +170,31 @@ RESPONSE (list_indexed): }); } + if (args.operation === 'retrieve') { + debugLog('tool:rag_search retrieve start', { fileId: args.fileId }); + + const ragServiceUrl = getRagConfig().serviceUrl; + const result = await fetchDocumentChunks(ragServiceUrl, args.fileId); + + const fullText = result.chunks + .sort((a, b) => a.index - b.index) + .map((c) => c.content) + .join('\n'); + + debugLog('tool:rag_search retrieve success', { + fileId: args.fileId, + totalChunks: result.totalChunks, + textLength: fullText.length, + }); + + return { + success: true, + response: fullText || 'Document has no text content.', + title: result.title, + totalChunks: result.totalChunks, + }; + } + // operation === 'search' debugLog('tool:rag_search start', { query: args.query, diff --git a/services/platform/convex/documents/generate_document_helpers.ts b/services/platform/convex/documents/generate_document_helpers.ts index 7967cae32b..6f286c1fb4 100644 --- a/services/platform/convex/documents/generate_document_helpers.ts +++ b/services/platform/convex/documents/generate_document_helpers.ts @@ -33,7 +33,9 @@ export function getEndpointPath( ? 'pdf' : outputFormat === 'docx' ? 'docx' - : 'images'; + : outputFormat === 'pptx' + ? 'pptx' + : 'images'; return `/api/v1/${formatPath}/from-${sourceType}`; } @@ -152,6 +154,13 @@ export function getOutputInfo( extension: 'docx', }; } + if (outputFormat === 'pptx') { + return { + contentType: + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + extension: 'pptx', + }; + } const type = imageType ?? 'png'; return { contentType: type === 'png' ? 'image/png' : 'image/jpeg', diff --git a/services/platform/convex/documents/generate_docx_from_template.ts b/services/platform/convex/documents/generate_docx_from_template.ts deleted file mode 100644 index e2f9939025..0000000000 --- a/services/platform/convex/documents/generate_docx_from_template.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Generate a DOCX document from a template via the crawler service. - * - * This is the model-layer helper; Convex actions should call this via a thin - * wrapper in `convex/documents.ts`. - */ - -import { decode as decodeBase64 } from 'base64-arraybuffer'; - -import { fetchJson } from '../../lib/utils/type-cast-helpers'; -import { internal } from '../_generated/api'; -import type { Id } from '../_generated/dataModel'; -import type { ActionCtx } from '../_generated/server'; -import { createDebugLog } from '../lib/debug_log'; -import { buildDownloadUrl, getCrawlerUrl } from './generate_document_helpers'; -import type { DocxContent } from './generate_docx'; - -const debugLog = createDebugLog('DEBUG_DOCUMENTS', '[Documents]'); - -export interface GenerateDocxFromTemplateArgs { - organizationId: string; - fileName: string; - content: DocxContent; - templateStorageId: Id<'_storage'>; -} - -export interface GenerateDocxFromTemplateResult { - success: boolean; - fileStorageId: Id<'_storage'>; - downloadUrl: string; - fileName: string; - contentType: string; - size: number; -} - -/** - * Generate a DOCX from content using a template as the base. - * - * When templateStorageId is provided, uses the template as a base, preserving - * all styling, headers/footers, and document properties. - */ -export async function generateDocxFromTemplate( - ctx: ActionCtx, - args: GenerateDocxFromTemplateArgs, -): Promise { - const crawlerUrl = getCrawlerUrl(); - const apiUrl = `${crawlerUrl}/api/v1/docx/from-template`; - - // Prepare content as JSON string - const contentJson = JSON.stringify(args.content); - - debugLog('documents.generateDocxFromTemplate start', { - fileName: args.fileName, - sectionsCount: args.content.sections.length, - templateStorageId: args.templateStorageId, - }); - - // Create FormData with content - const formData = new FormData(); - formData.append('content', contentJson); - - // Download template and add to form data - const templateUrl = await ctx.storage.getUrl(args.templateStorageId); - if (!templateUrl) { - throw new Error('Template file not found in storage'); - } - - debugLog('documents.generateDocxFromTemplate downloading template', { - templateStorageId: args.templateStorageId, - }); - - const templateResponse = await fetch(templateUrl); - if (!templateResponse.ok) { - throw new Error(`Failed to download template: ${templateResponse.status}`); - } - - const templateBlob = await templateResponse.blob(); - formData.append('template_file', templateBlob, 'template.docx'); - - const response = await fetch(apiUrl, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => ''); - console.error('[documents.generateDocxFromTemplate] crawler error', { - status: response.status, - errorText, - }); - throw new Error( - `Crawler generateDocxFromTemplate failed: ${response.status}`, - ); - } - - const result = await response.json(); - - if (!result.success || !result.file_base64) { - throw new Error(result.error || 'Failed to generate DOCX from template'); - } - - // Decode base64 and upload to Convex storage - const docxArrayBuffer = decodeBase64(result.file_base64); - const docxBytes = new Uint8Array(docxArrayBuffer); - const contentType = - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; - - const uploadUrl = await ctx.storage.generateUploadUrl(); - const uploadResponse = await fetch(uploadUrl, { - method: 'POST', - headers: { 'Content-Type': contentType }, - body: docxBytes, - }); - - if (!uploadResponse.ok) { - throw new Error(`Failed to upload DOCX: ${uploadResponse.status}`); - } - - const { storageId } = await fetchJson<{ storageId: Id<'_storage'> }>( - uploadResponse, - ); - - const finalFileName = args.fileName.toLowerCase().endsWith('.docx') - ? args.fileName - : `${args.fileName}.docx`; - - await ctx.runMutation( - internal.file_metadata.internal_mutations.saveFileMetadata, - { - organizationId: args.organizationId, - storageId, - fileName: finalFileName, - contentType, - size: docxBytes.length, - source: 'agent', - }, - ); - - // Build download URL using our custom HTTP endpoint - const downloadUrl = buildDownloadUrl(storageId, finalFileName); - - debugLog('documents.generateDocxFromTemplate success', { - fileName: finalFileName, - storageId, - size: docxBytes.length, - }); - - return { - success: true, - fileStorageId: storageId, - downloadUrl, - fileName: finalFileName, - contentType, - size: docxBytes.length, - }; -} diff --git a/services/platform/convex/documents/generate_pptx.ts b/services/platform/convex/documents/generate_pptx.ts deleted file mode 100644 index 9895119b68..0000000000 --- a/services/platform/convex/documents/generate_pptx.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Generate a PPTX document via the crawler service and store it in Convex storage. - * - * This is the model-layer helper; Convex actions should call this via a thin - * wrapper in `convex/documents.ts`. - */ - -import { decode as decodeBase64 } from 'base64-arraybuffer'; - -import { fetchJson } from '../../lib/utils/type-cast-helpers'; -import { internal } from '../_generated/api'; -import type { Id } from '../_generated/dataModel'; -import type { ActionCtx } from '../_generated/server'; -import { createDebugLog } from '../lib/debug_log'; -import { buildDownloadUrl, getCrawlerUrl } from './generate_document_helpers'; - -const debugLog = createDebugLog('DEBUG_DOCUMENTS', '[Documents]'); - -/** - * Table data for PPTX generation. - */ -export interface TableData { - headers: string[]; - rows: string[][]; -} - -/** - * Content for a single slide in the PPTX. - * Backend automatically selects the best layout based on content fields. - */ -export interface SlideContentData { - title?: string; - subtitle?: string; - textContent?: string[]; - bulletPoints?: string[]; - tables?: TableData[]; -} - -/** - * Branding/styling information for the PPTX. - */ -export interface PptxBrandingData { - slideWidth?: number; - slideHeight?: number; - titleFontName?: string; - bodyFontName?: string; - titleFontSize?: number; - bodyFontSize?: number; - primaryColor?: string; - secondaryColor?: string; - accentColor?: string; -} - -export interface GeneratePptxArgs { - organizationId: string; - fileName: string; - slidesContent: SlideContentData[]; - branding?: PptxBrandingData; - /** Template storage ID - uses template as base preserving styling */ - templateStorageId: Id<'_storage'>; -} - -export interface GeneratePptxResult { - success: boolean; - fileStorageId: Id<'_storage'>; - downloadUrl: string; - fileName: string; - contentType: string; - size: number; -} - -/** - * Generate a PPTX from content using the crawler service. - * - * When templateStorageId is provided, uses the template as a base, preserving - * all styling, backgrounds, and decorative elements. - * - * When no template is provided, creates a new blank presentation. - */ -export async function generatePptx( - ctx: ActionCtx, - args: GeneratePptxArgs, -): Promise { - const crawlerUrl = getCrawlerUrl(); - const apiUrl = `${crawlerUrl}/api/v1/pptx`; - - // Prepare slide content as JSON string - const slidesContentJson = JSON.stringify(args.slidesContent); - - debugLog('documents.generatePptx start', { - fileName: args.fileName, - slidesCount: args.slidesContent.length, - hasBranding: !!args.branding, - templateStorageId: args.templateStorageId, - }); - - // Create FormData with slides content and optional branding - const formData = new FormData(); - formData.append('slides_content', slidesContentJson); - if (args.branding) { - formData.append('branding', JSON.stringify(args.branding)); - } - - // Download template and add to form data - const templateUrl = await ctx.storage.getUrl(args.templateStorageId); - if (!templateUrl) { - throw new Error('Template file not found in storage'); - } - - debugLog('documents.generatePptx downloading template', { - templateStorageId: args.templateStorageId, - }); - - const templateResponse = await fetch(templateUrl); - if (!templateResponse.ok) { - throw new Error(`Failed to download template: ${templateResponse.status}`); - } - - const templateBlob = await templateResponse.blob(); - formData.append('template_file', templateBlob, 'template.pptx'); - - const response = await fetch(apiUrl, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => ''); - console.error('[documents.generatePptx] crawler error', { - status: response.status, - errorText, - }); - // Include detailed error in message for AI to see - const errorDetail = errorText ? `: ${errorText}` : ''; - throw new Error( - `PPTX generation failed (HTTP ${response.status})${errorDetail}`, - ); - } - - const result = await response.json(); - - if (!result.success || !result.file_base64) { - // Pass through detailed error from crawler service - const errorMsg = result.error || 'Unknown error during PPTX generation'; - throw new Error(`PPTX generation failed: ${errorMsg}`); - } - - // Decode base64 and upload to Convex storage - const pptxArrayBuffer = decodeBase64(result.file_base64); - const pptxBytes = new Uint8Array(pptxArrayBuffer); - const contentType = - 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; - - const uploadUrl = await ctx.storage.generateUploadUrl(); - const uploadResponse = await fetch(uploadUrl, { - method: 'POST', - headers: { 'Content-Type': contentType }, - body: pptxBytes, - }); - - if (!uploadResponse.ok) { - throw new Error(`Failed to upload PPTX: ${uploadResponse.status}`); - } - - const { storageId } = await fetchJson<{ storageId: Id<'_storage'> }>( - uploadResponse, - ); - - const finalFileName = args.fileName.toLowerCase().endsWith('.pptx') - ? args.fileName - : `${args.fileName}.pptx`; - - await ctx.runMutation( - internal.file_metadata.internal_mutations.saveFileMetadata, - { - organizationId: args.organizationId, - storageId, - fileName: finalFileName, - contentType, - size: pptxBytes.length, - source: 'agent', - }, - ); - - // Build download URL using our custom HTTP endpoint that sets Content-Disposition - // This ensures the downloaded file has the correct filename instead of the storage ID - const downloadUrl = buildDownloadUrl(storageId, finalFileName); - - debugLog('documents.generatePptx success', { - fileName: finalFileName, - storageId, - size: pptxBytes.length, - }); - - return { - success: true, - fileStorageId: storageId, - downloadUrl, - fileName: finalFileName, - contentType, - size: pptxBytes.length, - }; -} diff --git a/services/platform/convex/documents/helpers.ts b/services/platform/convex/documents/helpers.ts index 7614754725..09938cf69f 100644 --- a/services/platform/convex/documents/helpers.ts +++ b/services/platform/convex/documents/helpers.ts @@ -30,11 +30,8 @@ export * from './get_onedrive_sync_configs'; export * from './upload_base64_to_storage'; export * from './read_file_base64_from_storage'; export * from './generate_document'; -export * from './generate_pptx'; export * from './generate_docx'; -export * from './generate_docx_from_template'; export * from './extract_extension'; -export * from './list_documents_by_extension'; export * from './find_document_by_title'; export * from './find_document_by_external_id'; export * from './find_document_by_file_id'; diff --git a/services/platform/convex/documents/internal_actions.ts b/services/platform/convex/documents/internal_actions.ts index c459420465..a0ae09ef14 100644 --- a/services/platform/convex/documents/internal_actions.ts +++ b/services/platform/convex/documents/internal_actions.ts @@ -15,8 +15,6 @@ import { getRagConfig } from '../lib/helpers/rag_config'; import { ragAction } from '../workflow_engine/action_defs/rag/rag_action'; import { getCrawlerUrl } from './generate_document_helpers'; import type { GenerateDocxResult } from './generate_docx'; -import type { GenerateDocxFromTemplateResult } from './generate_docx_from_template'; -import type { GeneratePptxResult } from './generate_pptx'; import * as DocumentsHelpers from './helpers'; import type { GenerateDocumentResult } from './types'; @@ -39,6 +37,7 @@ const documentOutputFormatValidator = v.union( v.literal('pdf'), v.literal('image'), v.literal('docx'), + v.literal('pptx'), ); const pdfOptionsValidator = v.optional( @@ -77,33 +76,6 @@ const urlOptionsValidator = v.optional( }), ); -const tableDataValidator = v.object({ - headers: v.array(v.string()), - rows: v.array(v.array(v.string())), -}); - -const slideContentValidator = v.object({ - title: v.optional(v.string()), - subtitle: v.optional(v.string()), - textContent: v.optional(v.array(v.string())), - bulletPoints: v.optional(v.array(v.string())), - tables: v.optional(v.array(tableDataValidator)), -}); - -const pptxBrandingValidator = v.optional( - v.object({ - slideWidth: v.optional(v.number()), - slideHeight: v.optional(v.number()), - titleFontName: v.optional(v.string()), - bodyFontName: v.optional(v.string()), - titleFontSize: v.optional(v.number()), - bodyFontSize: v.optional(v.number()), - primaryColor: v.optional(v.string()), - secondaryColor: v.optional(v.string()), - accentColor: v.optional(v.string()), - }), -); - const docxSectionValidator = v.object({ type: v.union( v.literal('heading'), @@ -145,19 +117,6 @@ export const generateDocument = internalAction({ }, }); -export const generatePptx = internalAction({ - args: { - organizationId: v.string(), - fileName: v.string(), - slidesContent: v.array(slideContentValidator), - branding: pptxBrandingValidator, - templateStorageId: v.id('_storage'), - }, - handler: async (ctx, args): Promise => { - return await DocumentsHelpers.generatePptx(ctx, args); - }, -}); - export const generateDocx = internalAction({ args: { organizationId: v.string(), @@ -169,18 +128,6 @@ export const generateDocx = internalAction({ }, }); -export const generateDocxFromTemplate = internalAction({ - args: { - organizationId: v.string(), - fileName: v.string(), - content: docxContentValidator, - templateStorageId: v.id('_storage'), - }, - handler: async (ctx, args): Promise => { - return await DocumentsHelpers.generateDocxFromTemplate(ctx, args); - }, -}); - /** * Progressive intervals to cover ~24 hours with 50 attempts: * - Attempts 1-30: 2 minutes each (~60 minutes total) diff --git a/services/platform/convex/documents/internal_queries.ts b/services/platform/convex/documents/internal_queries.ts index 5343832804..61b6541d80 100644 --- a/services/platform/convex/documents/internal_queries.ts +++ b/services/platform/convex/documents/internal_queries.ts @@ -19,17 +19,6 @@ export const getDocumentByIdRaw = internalQuery({ }, }); -export const listDocumentsByExtension = internalQuery({ - args: { - organizationId: v.string(), - extension: v.string(), - limit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - return await DocumentsHelpers.listDocumentsByExtension(ctx, args); - }, -}); - export const queryDocuments = internalQuery({ args: { organizationId: v.string(), diff --git a/services/platform/convex/documents/list_documents_by_extension.ts b/services/platform/convex/documents/list_documents_by_extension.ts deleted file mode 100644 index 8baef23f65..0000000000 --- a/services/platform/convex/documents/list_documents_by_extension.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * List documents by file extension - * - * Uses the by_organizationId_and_extension index to efficiently query - * documents of a specific type (e.g., 'pptx', 'pdf', 'docx'). - */ - -import type { QueryCtx } from '../_generated/server'; -import type { - ListDocumentsByExtensionArgs, - ListDocumentsByExtensionResult, -} from './types'; - -export async function listDocumentsByExtension( - ctx: QueryCtx, - args: ListDocumentsByExtensionArgs, -): Promise { - const limit = args.limit ?? 50; - - const documents = await ctx.db - .query('documents') - .withIndex('by_organizationId_and_extension', (q) => - q - .eq('organizationId', args.organizationId) - .eq('extension', args.extension), - ) - .order('desc') - .take(limit); - - return documents.map((doc) => ({ - _id: doc._id, - _creationTime: doc._creationTime, - title: doc.title, - fileId: doc.fileId, - mimeType: doc.mimeType, - extension: doc.extension, - metadata: doc.metadata, - })); -} diff --git a/services/platform/convex/documents/types.ts b/services/platform/convex/documents/types.ts index 6f1914527f..265dc9ec69 100644 --- a/services/platform/convex/documents/types.ts +++ b/services/platform/convex/documents/types.ts @@ -121,7 +121,7 @@ export type ListDocumentsByExtensionResult = Array<{ export type DocumentSourceType = 'markdown' | 'html' | 'url'; -export type DocumentOutputFormat = 'pdf' | 'image' | 'docx'; +export type DocumentOutputFormat = 'pdf' | 'image' | 'docx' | 'pptx'; export interface GenerateDocumentPdfOptions { format?: string; // A4, Letter, Legal, etc. diff --git a/services/platform/convex/documents/validators.ts b/services/platform/convex/documents/validators.ts index 9f128c1388..4e00158b41 100644 --- a/services/platform/convex/documents/validators.ts +++ b/services/platform/convex/documents/validators.ts @@ -93,15 +93,6 @@ export const generateDocumentResponseValidator = v.object({ size: v.number(), }); -export const generatePptxResponseValidator = v.object({ - success: v.boolean(), - fileStorageId: v.string(), - downloadUrl: v.string(), - fileName: v.string(), - contentType: v.string(), - size: v.number(), -}); - export const generateDocxResponseValidator = v.object({ success: v.boolean(), fileStorageId: v.string(), diff --git a/services/platform/convex/file_metadata/internal_actions.ts b/services/platform/convex/file_metadata/internal_actions.ts new file mode 100644 index 0000000000..ece7d1478b --- /dev/null +++ b/services/platform/convex/file_metadata/internal_actions.ts @@ -0,0 +1,48 @@ +'use node'; + +import { v } from 'convex/values'; + +import { internalAction } from '../_generated/server'; +import { getRagConfig } from '../lib/helpers/rag_config'; +import { ragAction } from '../workflow_engine/action_defs/rag/rag_action'; + +/** + * Upload a file to the RAG service for indexing. + * + * This is a lightweight action triggered by saveFileMetadata on new inserts. + * Unlike uploadDocumentToRag (which tracks status on a document record), + * this simply fires-and-forgets the RAG upload. + */ +export const uploadFileToRag = internalAction({ + args: { + storageId: v.string(), + fileName: v.string(), + contentType: v.string(), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + const ragConfig = getRagConfig(); + if (!ragConfig.serviceUrl) { + return null; + } + + try { + await ragAction.execute( + ctx, + { + operation: 'upload_document', + fileId: args.storageId, + fileName: args.fileName, + contentType: args.contentType, + }, + {}, + ); + } catch (error) { + console.error( + `[uploadFileToRag] Failed to upload file ${args.storageId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + return null; + }, +}); diff --git a/services/platform/convex/file_metadata/internal_mutations.ts b/services/platform/convex/file_metadata/internal_mutations.ts index 96ad1bc5b2..e7222e464b 100644 --- a/services/platform/convex/file_metadata/internal_mutations.ts +++ b/services/platform/convex/file_metadata/internal_mutations.ts @@ -49,6 +49,16 @@ export const saveFileMetadata = internalMutation({ ...(args.source !== undefined && { source: args.source }), }); + await ctx.scheduler.runAfter( + 0, + internal.file_metadata.internal_actions.uploadFileToRag, + { + storageId: args.storageId, + fileName: args.fileName, + contentType: args.contentType, + }, + ); + try { await checkOrganizationRateLimit( ctx, diff --git a/services/platform/convex/file_metadata/mutations.ts b/services/platform/convex/file_metadata/mutations.ts index 0e7e4a2189..07fb32650e 100644 --- a/services/platform/convex/file_metadata/mutations.ts +++ b/services/platform/convex/file_metadata/mutations.ts @@ -55,6 +55,16 @@ export const saveFileMetadata = mutation({ ...(args.source !== undefined && { source: args.source }), }); + await ctx.scheduler.runAfter( + 0, + internal.file_metadata.internal_actions.uploadFileToRag, + { + storageId: args.storageId, + fileName: args.fileName, + contentType: args.contentType, + }, + ); + try { await checkOrganizationRateLimit( ctx, diff --git a/services/platform/convex/lib/action_cache/index.ts b/services/platform/convex/lib/action_cache/index.ts index df165ae053..9191ad3f18 100644 --- a/services/platform/convex/lib/action_cache/index.ts +++ b/services/platform/convex/lib/action_cache/index.ts @@ -30,18 +30,6 @@ export const TTL = { // File Processing Caches // ============================================ -/** - * Cache for file parsing results. - * File content is immutable per storage ID. - */ -export const parseFileCache: ActionCache< - FunctionReference<'action', 'internal'> -> = new ActionCache(components.actionCache, { - action: internal.agent_tools.files.internal_actions.parseFileUncached, - name: `parse_file_${CACHE_VERSION}`, - ttl: TTL.INDEFINITE, -}); - /** * Cache for image analysis results. * Same image + question produces same analysis. diff --git a/services/platform/convex/lib/attachments/process_attachments.ts b/services/platform/convex/lib/attachments/process_attachments.ts index 7239fd7f29..aa466aea60 100644 --- a/services/platform/convex/lib/attachments/process_attachments.ts +++ b/services/platform/convex/lib/attachments/process_attachments.ts @@ -5,21 +5,11 @@ * including document parsing and image metadata extraction. */ -import type { LanguageModelV3 } from '@ai-sdk/provider'; - -import { - isImage, - isSpreadsheet, - isTextFile, -} from '../../../lib/shared/file-types'; +import { isImage, isSpreadsheet } from '../../../lib/shared/file-types'; import { internal } from '../../_generated/api'; -import type { Id } from '../../_generated/dataModel'; import type { ActionCtx } from '../../_generated/server'; import { analyzeImageCached } from '../../agent_tools/files/helpers/analyze_image'; -import { analyzeTextContent } from '../../agent_tools/files/helpers/analyze_text'; -import { parseFile } from '../../agent_tools/files/helpers/parse_file'; import { toId } from '../../lib/type_cast_helpers'; -import { resolveLanguageModel } from '../../providers/resolve_model'; import { registerFilesWithAgent } from './register_files'; import type { FileAttachment, MessageContentPart } from './types'; @@ -37,16 +27,16 @@ export interface ParsedDocument { */ export interface ImageInfo { fileName: string; - fileId: Id<'_storage'>; + fileId: string; url: string | undefined; } /** - * Text file info for the txt tool's parse operation + * Text file info */ export interface TextFileInfo { fileName: string; - fileId: Id<'_storage'>; + fileId: string; fileSize: number; } @@ -70,19 +60,17 @@ export interface ProcessAttachmentsConfig { debugLog?: (message: string, data?: Record) => void; toolName?: string; model?: string; - languageModel?: LanguageModelV3; } -const DEFAULT_MAX_DOCUMENT_LENGTH = 50000; - /** * Process file attachments for an AI agent. * * This function: - * 1. Separates images from documents - * 2. Parses documents to extract text content - * 3. Prepares image metadata for the image tool - * 4. Builds multi-modal prompt content + * 1. Separates images from other files + * 2. Analyzes images with vision model + * 3. Parses spreadsheets for structured data + * 4. Lists documents and text files for the agent to retrieve via rag_search + * 5. Builds multi-modal prompt content * * @param ctx - Action context for storage access * @param attachments - Array of file attachments to process @@ -95,9 +83,7 @@ export async function processAttachments( userText: string | undefined, config: ProcessAttachmentsConfig & { model: string }, ): Promise { - const maxDocLength = config?.maxDocumentLength ?? DEFAULT_MAX_DOCUMENT_LENGTH; const debugLog = config?.debugLog ?? (() => {}); - const toolName = config?.toolName ?? 'agent'; if (!attachments || attachments.length === 0) { return { @@ -113,67 +99,15 @@ export async function processAttachments( files: attachments.map((a) => ({ name: a.fileName, type: a.fileType })), }); - // Separate images, text files, spreadsheets, and other documents + // Separate images, spreadsheets, and other files (documents + text) const imageAttachments = attachments.filter((a) => isImage(a.fileType)); const spreadsheetAttachments = attachments.filter( (a) => !isImage(a.fileType) && isSpreadsheet(a.fileName), ); - const textFileAttachments = attachments.filter( - (a) => - !isImage(a.fileType) && - !isSpreadsheet(a.fileName) && - isTextFile(a.fileType, a.fileName), - ); - const documentAttachments = attachments.filter( - (a) => - !isImage(a.fileType) && - !isSpreadsheet(a.fileName) && - !isTextFile(a.fileType, a.fileName), + const fileAttachments = attachments.filter( + (a) => !isImage(a.fileType) && !isSpreadsheet(a.fileName), ); - // Parse document files to extract their text content (in parallel) - const parseResults = await Promise.all( - documentAttachments.map(async (attachment) => { - try { - const parseResult = await parseFile( - ctx, - attachment.fileId, - attachment.fileName, - toolName, - userText, - ); - return { attachment, parseResult }; - } catch (error) { - debugLog('Error parsing document', { - fileName: attachment.fileName, - error: String(error), - }); - return null; - } - }), - ); - - const parsedDocuments: ParsedDocument[] = []; - - for (const result of parseResults) { - if (result?.parseResult.success && result.parseResult.full_text) { - parsedDocuments.push({ - fileId: result.attachment.fileId, - fileName: result.attachment.fileName, - content: result.parseResult.full_text, - }); - debugLog('Parsed document', { - fileName: result.attachment.fileName, - textLength: result.parseResult.full_text.length, - }); - } else if (result) { - debugLog('Failed to parse document', { - fileName: result.attachment.fileName, - error: result.parseResult.error, - }); - } - } - // Parse spreadsheet files using the xlsx library (in parallel) const spreadsheetResults = await Promise.all( spreadsheetAttachments.map(async (attachment) => { @@ -238,66 +172,9 @@ export async function processAttachments( (r): r is { fileName: string; analysis: string } => r !== null, ); - // Resolve language model for text analysis if not provided - let resolvedLanguageModelV3 = config.languageModel; - if (!resolvedLanguageModelV3 && textFileAttachments.length > 0) { - const resolved = await resolveLanguageModel(ctx, { tag: 'chat' }); - resolvedLanguageModelV3 = resolved.languageModel; - } - - // Analyze text files with LLM (in parallel) - const textAnalysisResults = await Promise.all( - textFileAttachments.map(async (attachment) => { - try { - const result = await analyzeTextContent(ctx, { - fileId: attachment.fileId, - filename: attachment.fileName, - userInput: userText || 'Analyze this file', - model: config.model, - // resolvedLanguageModelV3 is guaranteed set: either from config or resolved above - // oxlint-disable-next-line typescript/no-non-null-assertion -- guard above ensures non-null - languageModel: resolvedLanguageModelV3!, - }); - - if (result.success) { - return { - fileName: attachment.fileName, - analysis: result.result, - charCount: result.charCount, - lineCount: result.lineCount, - }; - } else { - debugLog('Text file analysis failed', { - fileName: attachment.fileName, - error: result.error, - }); - return null; - } - } catch (error) { - debugLog('Error analyzing text file', { - fileName: attachment.fileName, - error: String(error), - }); - return null; - } - }), - ); - - const analyzedTextFiles = textAnalysisResults.filter( - ( - r, - ): r is { - fileName: string; - analysis: string; - charCount: number; - lineCount: number; - } => r !== null, - ); - - // Register files with the agent component for tracking (documents + spreadsheets) - // Images and text files are handled via their respective tools, not inline + // Register files with the agent component for tracking await registerFilesWithAgent(ctx, [ - ...documentAttachments, + ...fileAttachments, ...spreadsheetAttachments, ]); @@ -306,10 +183,7 @@ export async function processAttachments( const contentParts: MessageContentPart[] = [{ type: 'text', text }]; const hasAnalyzedContent = - parsedDocuments.length > 0 || - parsedSpreadsheets.length > 0 || - analyzedImages.length > 0 || - analyzedTextFiles.length > 0; + parsedSpreadsheets.length > 0 || analyzedImages.length > 0; if (hasAnalyzedContent) { contentParts.push({ @@ -317,19 +191,6 @@ export async function processAttachments( text: '\n\n[PRE-ANALYZED CONTENT BELOW - This is the attachment from the CURRENT message. It takes priority over any previous context. Answer directly from this content without delegating to document tools.]', }); - for (const doc of parsedDocuments) { - const truncatedContent = - doc.content.length > maxDocLength - ? doc.content.slice(0, maxDocLength) + - '\n\n[... Document truncated due to length ...]' - : doc.content; - - contentParts.push({ - type: 'text', - text: `\n\n---\n**Document: ${doc.fileName}** (fileId: ${doc.fileId})\n\n${truncatedContent}\n---\n`, - }); - } - for (const { attachment, result } of parsedSpreadsheets) { const sheetTexts = result.sheets.map((sheet) => { const headerRow = sheet.headers.join(' | '); @@ -356,41 +217,27 @@ export async function processAttachments( text: `\n\n---\n**Image: ${img.fileName}**\n\n${img.analysis}\n---\n`, }); } - - for (const txt of analyzedTextFiles) { - contentParts.push({ - type: 'text', - text: `\n\n---\n**Text File: ${txt.fileName}** (${txt.charCount} chars, ${txt.lineCount} lines)\n\n${txt.analysis}\n---\n`, - }); - } } - // Collect attachments that failed pre-analysis — include their references - // so the agent can use its tools (docx, pdf, image, etc.) to process them - const failedDocuments = documentAttachments.filter( - (a) => !parsedDocuments.some((d) => d.fileName === a.fileName), - ); + // List documents, text files, and failed attachments for the agent to process + // via rag_search tool (retrieve operation) const failedImages = imageAttachments.filter( (a) => !analyzedImages.some((d) => d.fileName === a.fileName), ); - const failedTextFiles = textFileAttachments.filter( - (a) => !analyzedTextFiles.some((d) => d.fileName === a.fileName), - ); const failedSpreadsheets = spreadsheetAttachments.filter( (a) => !parsedSpreadsheets.some((d) => d.attachment.fileName === a.fileName), ); const unprocessedAttachments = [ - ...failedDocuments, + ...fileAttachments, ...failedImages, - ...failedTextFiles, ...failedSpreadsheets, ]; if (unprocessedAttachments.length > 0) { contentParts.push({ type: 'text', - text: '\n\n[ATTACHED FILES - Pre-analysis was not available. Use your tools to process these files.]', + text: '\n\n[ATTACHED FILES - Use rag_search tool with operation="retrieve" and the fileId to read these files.]', }); for (const attachment of unprocessedAttachments) { @@ -409,7 +256,7 @@ export async function processAttachments( : undefined; return { - parsedDocuments, + parsedDocuments: [], imageInfoList: [], textFileInfoList: [], promptContent, diff --git a/services/platform/convex/workflow_engine/action_defs/rag/rag_action.ts b/services/platform/convex/workflow_engine/action_defs/rag/rag_action.ts index 26c4d57929..8defe1fdc5 100644 --- a/services/platform/convex/workflow_engine/action_defs/rag/rag_action.ts +++ b/services/platform/convex/workflow_engine/action_defs/rag/rag_action.ts @@ -2,14 +2,14 @@ import { v } from 'convex/values'; import { fetchJson } from '../../../../lib/utils/type-cast-helpers'; import type { SearchResponse } from '../../../agent_tools/rag/format_search_results'; +import { fetchDocumentChunks } from '../../../agent_tools/rag/helpers/fetch_document_chunks'; import type { ActionDefinition } from '../../helpers/nodes/action/types'; import { deleteDocumentById } from './helpers/delete_document'; import { getRagConfig } from './helpers/get_rag_config'; -import type { RagActionParams, RagChunkResult } from './helpers/types'; +import type { RagActionParams } from './helpers/types'; import { uploadDocument } from './helpers/upload_document'; const SEARCH_TIMEOUT_MS = 30_000; -const MAX_CHUNK_WINDOW = 200; export const ragAction: ActionDefinition = { type: 'rag', @@ -129,59 +129,6 @@ export const ragAction: ActionDefinition = { }, }; -interface DocumentContentResponse { - file_id: string; - title: string | null; - content: string; - chunk_range: { start: number; end: number }; - total_chunks: number; - total_chars: number; - chunks: Array<{ index: number; content: string }> | null; -} - -async function fetchDocumentChunks( - serviceUrl: string, - fileId: string, -): Promise { - const allChunks: Array<{ index: number; content: string }> = []; - let totalChunks = 0; - let documentId = ''; - let title: string | null = null; - let chunkStart = 1; - - // Paginate through all chunks in MAX_CHUNK_WINDOW batches - while (true) { - const chunkEnd = chunkStart + MAX_CHUNK_WINDOW - 1; - const url = `${serviceUrl}/api/v1/documents/${encodeURIComponent(fileId)}/content?return_chunks=true&chunk_start=${chunkStart}&chunk_end=${chunkEnd}`; - - const response = await fetch(url); - - if (!response.ok) { - const errorText = await response.text().catch(() => ''); - throw new Error( - `RAG get_chunks error (${response.status}): ${errorText || 'Unknown error'}`, - ); - } - - const result = await fetchJson(response); - documentId = result.file_id; - title = result.title; - totalChunks = result.total_chunks; - - if (result.chunks) { - allChunks.push(...result.chunks); - } - - if (result.chunk_range.end >= totalChunks) { - break; - } - - chunkStart = result.chunk_range.end + 1; - } - - return { documentId, title, chunks: allChunks, totalChunks }; -} - /** * Backward compatibility: map old param names (recordId, documentIds) * to new names (fileId, fileIds) for user-created workflows stored in DB. diff --git a/services/rag/app/services/rag_service.py b/services/rag/app/services/rag_service.py index d2aa444689..57d4d4b574 100644 --- a/services/rag/app/services/rag_service.py +++ b/services/rag/app/services/rag_service.py @@ -303,6 +303,22 @@ async def search( if threshold > 0: results = [r for r in results if r.get("score", 0) >= threshold] + # If no results and some files are still indexing, wait and retry once + if not results and file_ids: + statuses = await self.get_document_statuses(file_ids) + has_processing = any(s is not None and s.get("status") == "processing" for s in statuses.values()) + if has_processing: + logger.info("No results and some files still indexing, retrying in 3s") + await asyncio.sleep(3) + results = await self._search_service.search( + query, + file_ids=file_ids, + top_k=effective_top_k, + ) + self.last_search_usage = getattr(self._search_service, "last_search_usage", None) + if threshold > 0: + results = [r for r in results if r.get("score", 0) >= threshold] + return results async def generate( From 4139717319c862ce78cdcd5f7f8f92f1da84c52e Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 11:03:02 +0800 Subject: [PATCH 02/21] =?UTF-8?q?fix:=20resolve=20CI=20failures=20?= =?UTF-8?q?=E2=80=94=20remove=20unused=20exports=20and=20fix=20RAG=20test?= =?UTF-8?q?=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused `isTextFile` and `getParseEndpoint` exports from file-types.ts (Knip) - Mock `get_document_statuses` in test_passes_file_ids to handle retry logic (RAG test) --- .../__tests__/internal_mutations.test.ts | 1 + .../file_metadata/__tests__/mutations.test.ts | 1 + services/platform/lib/shared/file-types.ts | 23 ------------------- services/rag/tests/test_rag_service.py | 1 + 4 files changed, 3 insertions(+), 23 deletions(-) diff --git a/services/platform/convex/file_metadata/__tests__/internal_mutations.test.ts b/services/platform/convex/file_metadata/__tests__/internal_mutations.test.ts index 2ea106ae08..a27f8ac2db 100644 --- a/services/platform/convex/file_metadata/__tests__/internal_mutations.test.ts +++ b/services/platform/convex/file_metadata/__tests__/internal_mutations.test.ts @@ -34,6 +34,7 @@ vi.mock('../../lib/rate_limiter/helpers', () => ({ vi.mock('../../_generated/api', () => ({ internal: { governance: { retention_cleanup: { runRetentionCleanup: 'mock' } }, + file_metadata: { internal_actions: { uploadFileToRag: 'mock' } }, }, })); diff --git a/services/platform/convex/file_metadata/__tests__/mutations.test.ts b/services/platform/convex/file_metadata/__tests__/mutations.test.ts index a0f117ea13..18d0b03ffa 100644 --- a/services/platform/convex/file_metadata/__tests__/mutations.test.ts +++ b/services/platform/convex/file_metadata/__tests__/mutations.test.ts @@ -34,6 +34,7 @@ vi.mock('../../lib/rate_limiter/helpers', () => ({ vi.mock('../../_generated/api', () => ({ internal: { governance: { retention_cleanup: { runRetentionCleanup: 'mock' } }, + file_metadata: { internal_actions: { uploadFileToRag: 'mock' } }, }, })); diff --git a/services/platform/lib/shared/file-types.ts b/services/platform/lib/shared/file-types.ts index 60dd54c95b..a6f814f337 100644 --- a/services/platform/lib/shared/file-types.ts +++ b/services/platform/lib/shared/file-types.ts @@ -76,11 +76,6 @@ export function isImage(mimeType: string): boolean { return mimeType.startsWith('image/'); } -export function isTextFile(mimeType: string, fileName?: string): boolean { - if (!fileName) return mimeType.startsWith('text/plain'); - return isTextBasedFile(fileName, mimeType); -} - export function isSpreadsheet(fileName: string): boolean { const lower = fileName.toLowerCase(); return ( @@ -437,24 +432,6 @@ export function hasFileTools(toolNames: readonly string[]): boolean { } // --------------------------------------------------------------------------- -// Parse endpoint routing -// --------------------------------------------------------------------------- - -const PARSE_ENDPOINTS: Record = { - pdf: '/api/v1/pdf/parse', - docx: '/api/v1/docx/parse', - pptx: '/api/v1/pptx/parse', -}; - -/** - * Get the crawler service parse endpoint for a given filename. - * Falls back to PDF parser for unknown extensions. - */ -export function getParseEndpoint(filename: string): string { - const ext = extractExtension(filename); - return (ext && PARSE_ENDPOINTS[ext]) || PARSE_ENDPOINTS.pdf; -} - // --------------------------------------------------------------------------- // MIME → display label key (for i18n) // --------------------------------------------------------------------------- diff --git a/services/rag/tests/test_rag_service.py b/services/rag/tests/test_rag_service.py index 4f70ed7492..655bf72e56 100644 --- a/services/rag/tests/test_rag_service.py +++ b/services/rag/tests/test_rag_service.py @@ -231,6 +231,7 @@ async def test_zero_threshold_returns_all(self): async def test_passes_file_ids(self): service = _make_service() service._search_service.search = AsyncMock(return_value=[]) + service.get_document_statuses = AsyncMock(return_value={"doc-1": None, "doc-2": None}) with patch("app.services.rag_service.settings") as mock_settings: mock_settings.top_k = 10 From 9ef91806325b3486ebfab6cc18ff649014dd3bbf Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 11:43:59 +0800 Subject: [PATCH 03/21] feat(platform): track RAG indexing status for chat file attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat file uploads were fire-and-forget — users could send messages before files finished indexing, causing the agent to fail reading file content. - Add ragStatus/ragError fields to fileMetadata schema - Track indexing lifecycle: queued → running → completed/failed - Poll RAG service with fast intervals (5-15s) for chat UX - Block send button while any attachment is still indexing - Show per-file indexing spinner/error in chat input - Raise chat attachment size limit from 10MB to 100MB --- .../features/chat/components/chat-input.tsx | 55 ++++- .../chat/components/chat-interface.tsx | 6 + .../chat/hooks/use-file-indexing-status.ts | 57 +++++ .../convex/file_metadata/internal_actions.ts | 204 +++++++++++++++++- .../file_metadata/internal_mutations.ts | 26 +++ .../convex/file_metadata/mutations.ts | 1 + .../platform/convex/file_metadata/queries.ts | 11 + .../platform/convex/file_metadata/schema.ts | 9 + services/platform/lib/shared/file-types.ts | 8 +- services/platform/messages/de-CH.json | 4 +- services/platform/messages/de.json | 6 +- services/platform/messages/en.json | 6 +- 12 files changed, 370 insertions(+), 23 deletions(-) create mode 100644 services/platform/app/features/chat/hooks/use-file-indexing-status.ts diff --git a/services/platform/app/features/chat/components/chat-input.tsx b/services/platform/app/features/chat/components/chat-input.tsx index cf08e087d9..c6a0872d26 100644 --- a/services/platform/app/features/chat/components/chat-input.tsx +++ b/services/platform/app/features/chat/components/chat-input.tsx @@ -43,6 +43,8 @@ interface ChatInputProps extends Omit< uploadFiles: (files: File[]) => Promise; removeAttachment: (fileId: Id<'_storage'>) => void; clearAttachments: () => FileAttachment[]; + isIndexing?: boolean; + indexingStatuses?: Map, { status?: string; error?: string }>; } export function ChatInput({ @@ -60,6 +62,8 @@ export function ChatInput({ uploadFiles, removeAttachment, clearAttachments, + isIndexing = false, + indexingStatuses, ...restProps }: ChatInputProps) { const { t: tChat } = useT('chat'); @@ -84,7 +88,8 @@ export function ChatInput({ (!value.trim() && attachments.length === 0) || isLoading || disabled || - isUploading + isUploading || + isIndexing ) return; @@ -224,13 +229,44 @@ export function ChatInput({ {middleEllipsis(attachment.fileName, 28)} - - {formatFileSize(attachment.fileSize)} - + {(() => { + const info = indexingStatuses?.get(attachment.fileId); + const ragStatus = info?.status; + if (ragStatus === 'queued' || ragStatus === 'running') { + return ( + + + + {tChat('indexing')} + + + ); + } + if (ragStatus === 'failed') { + return ( + + {tChat('indexingFailed')} + + ); + } + return ( + + {formatFileSize(attachment.fileSize)} + + ); + })()} + + ); +} + +interface SourceDetailDialogProps { + source: SourceGroup | null; + onClose: () => void; +} + +/** + * Normalize chunk content for display: + * - Convert literal `\n` sequences to real newlines + * - Collapse 3+ consecutive blank lines into 2 + */ +function normalizeContent(raw: string): string { + return raw + .replace(/\\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function SourceDetailDialog({ source, onClose }: SourceDetailDialogProps) { + const { t } = useT('chat'); + if (!source) return null; + + const title = + source.filename ?? + (source.url + ? getDomain(source.url) + : t('citations.source', { number: String(source.number) })); + + const chunkCount = source.chunks.length; return ( - + ); } @@ -70,74 +187,37 @@ interface SourceCardsProps { function SourceCardsComponent({ citations }: SourceCardsProps) { const { t } = useT('chat'); - const organizationId = useOrganizationId(); const [isExpanded, setIsExpanded] = useState(false); - const [previewDocId, setPreviewDocId] = useState(); - const [previewFileName, setPreviewFileName] = useState(); - - const citationList = getUniqueCitations(citations); - - // Collect all RAG fileIds to batch-query file metadata - const ragFileIds = useMemo(() => { - const ids: Id<'_storage'>[] = []; - for (const c of citationList) { - if (c.type === 'rag' && c.fileId) { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- fileId from RAG metadata is a Convex storage ID string - ids.push(c.fileId as Id<'_storage'>); - } - } - return ids; - }, [citationList]); - - const { data: fileMetadataList } = useConvexQuery( - api.file_metadata.queries.getByStorageIds, - ragFileIds.length > 0 ? { storageIds: ragFileIds } : 'skip', + const [selectedSource, setSelectedSource] = useState( + null, ); - // Map storageId → documentId for quick lookup - const storageToDocId = useMemo(() => { - const map = new Map(); - if (fileMetadataList) { - for (const meta of fileMetadataList) { - if (meta.documentId) { - map.set(meta.storageId, meta.documentId); - } - } + const sourceList = getUniqueSources(citations); + + const handleCardClick = useCallback((source: SourceGroup) => { + if (source.type === 'web' && source.url) { + window.open(source.url, '_blank', 'noopener,noreferrer'); + } else { + setSelectedSource(source); } - return map; - }, [fileMetadataList]); - - const handleCardClick = useCallback( - (citation: CitationInfo) => { - if (citation.type === 'web' && citation.url) { - window.open(citation.url, '_blank', 'noopener,noreferrer'); - } else if (citation.type === 'rag' && citation.fileId) { - const docId = storageToDocId.get(citation.fileId); - if (docId) { - setPreviewDocId(docId); - setPreviewFileName(citation.filename); - } - } - }, - [storageToDocId], - ); + }, []); - if (citationList.length === 0) return null; + if (sourceList.length === 0) return null; - const needsCollapse = citationList.length > COLLAPSED_LIMIT; - const visibleCitations = + const needsCollapse = sourceList.length > COLLAPSED_LIMIT; + const visibleSources = needsCollapse && !isExpanded - ? citationList.slice(0, COLLAPSED_LIMIT) - : citationList; + ? sourceList.slice(0, COLLAPSED_LIMIT) + : sourceList; return (
- {visibleCitations.map((citation) => ( + {visibleSources.map((source) => ( handleCardClick(citation)} + key={source.number} + source={source} + onClick={() => handleCardClick(source)} /> ))}
@@ -156,24 +236,17 @@ function SourceCardsComponent({ citations }: SourceCardsProps) { <> {t('citations.showAllSources', { - count: String(citationList.length), + count: String(sourceList.length), })} )} )} - {organizationId && ( - { - if (!open) setPreviewDocId(undefined); - }} - organizationId={organizationId} - documentId={previewDocId} - fileName={previewFileName} - /> - )} + setSelectedSource(null)} + />
); } diff --git a/services/platform/app/features/chat/hooks/use-citations.ts b/services/platform/app/features/chat/hooks/use-citations.ts index 09026ef51f..adec9aa232 100644 --- a/services/platform/app/features/chat/hooks/use-citations.ts +++ b/services/platform/app/features/chat/hooks/use-citations.ts @@ -8,6 +8,8 @@ export interface CitationInfo { relevance?: number; url?: string; type: 'rag' | 'web'; + /** Chunk text content extracted from tool output. */ + content?: string; } interface ToolUsageInput { @@ -22,14 +24,35 @@ const WEB_CITATION_PATTERN = /\[(\d+)\]\s*\(Relevance:\s*([\d.]+)%\)(?:\s*\[Source:\s*([^\]]+)\])?(?:\s*\[URL:\s*([^\]]+)\])?/g; /** - * Parse citation metadata from RAG search result text. + * Parse citation metadata and chunk content from RAG search result text. + * + * The RAG output format is: + * ``` + * [1] (Relevance: 87.3%) [Source: report.pdf] [Page: 5] [FileID: abc] + * + * + * --- + * + * [2] (Relevance: 72.1%) [Source: memo.docx] [FileID: def] + * + * ``` */ export function parseRagCitations(text: string): Map { const citations = new Map(); - let match; - RAG_CITATION_PATTERN.lastIndex = 0; - while ((match = RAG_CITATION_PATTERN.exec(text)) !== null) { + + // Split by chunk separator to get individual chunks + const chunks = text.split(/\n\n---\n\n/); + + for (const chunk of chunks) { + RAG_CITATION_PATTERN.lastIndex = 0; + const match = RAG_CITATION_PATTERN.exec(chunk); + if (!match) continue; + const num = parseInt(match[1], 10); + // Content is everything after the metadata line + const metadataEnd = (match.index ?? 0) + match[0].length; + const content = chunk.slice(metadataEnd).trim() || undefined; + citations.set(num, { number: num, relevance: parseFloat(match[2]), @@ -37,6 +60,7 @@ export function parseRagCitations(text: string): Map { page: match[4] ? parseInt(match[4], 10) : undefined, fileId: match[5] || undefined, type: 'rag', + content, }); } return citations; @@ -146,28 +170,82 @@ function deduplicateCitations( return deduped; } +export interface ChunkDetail { + number: number; + page?: number; + relevance?: number; + content?: string; +} + +export interface SourceGroup { + /** The first citation number (used for ordering and as key). */ + number: number; + filename?: string; + fileId?: string; + url?: string; + type: 'rag' | 'web'; + /** All inline citation numbers that reference this source. */ + chunkNumbers: number[]; + /** All distinct page numbers referenced (RAG only). */ + pages: number[]; + /** Highest relevance score among the grouped citations. */ + relevance?: number; + /** Individual chunk details with content for display. */ + chunks: ChunkDetail[]; +} + /** - * Get unique citations for display in source cards (no duplicates). + * Group citations by source (fileId for RAG, url for web) for display + * in source cards. Same file with different pages/chunks is merged + * into a single entry. */ -export function getUniqueCitations( +export function getUniqueSources( citations: Map, -): CitationInfo[] { - const seen = new Set(); - const unique: CitationInfo[] = []; +): SourceGroup[] { + const groups = new Map(); for (const citation of citations.values()) { const sourceKey = citation.type === 'rag' - ? `rag:${citation.fileId ?? ''}:${citation.page ?? ''}` + ? `rag:${citation.fileId ?? ''}` : `web:${citation.url ?? ''}`; - if (!seen.has(sourceKey)) { - seen.add(sourceKey); - unique.push(citation); + const chunk: ChunkDetail = { + number: citation.number, + page: citation.page, + relevance: citation.relevance, + content: citation.content, + }; + + const existing = groups.get(sourceKey); + if (existing) { + existing.chunkNumbers.push(citation.number); + existing.chunks.push(chunk); + if (citation.page != null && !existing.pages.includes(citation.page)) { + existing.pages.push(citation.page); + } + if ( + citation.relevance != null && + (existing.relevance == null || citation.relevance > existing.relevance) + ) { + existing.relevance = citation.relevance; + } + } else { + groups.set(sourceKey, { + number: citation.number, + filename: citation.filename, + fileId: citation.fileId, + url: citation.url, + type: citation.type, + chunkNumbers: [citation.number], + pages: citation.page != null ? [citation.page] : [], + relevance: citation.relevance, + chunks: [chunk], + }); } } - return unique.sort((a, b) => a.number - b.number); + return Array.from(groups.values()).sort((a, b) => a.number - b.number); } /** diff --git a/services/platform/app/features/chat/hooks/use-file-indexing-status.ts b/services/platform/app/features/chat/hooks/use-file-indexing-status.ts index 7a72557fb8..472dc63d73 100644 --- a/services/platform/app/features/chat/hooks/use-file-indexing-status.ts +++ b/services/platform/app/features/chat/hooks/use-file-indexing-status.ts @@ -1,7 +1,8 @@ 'use client'; +import { useAction } from 'convex/react'; import { useQuery } from 'convex/react'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { api } from '@/convex/_generated/api'; import type { Id } from '@/convex/_generated/dataModel'; @@ -16,11 +17,15 @@ interface FileIndexingInfo { progress?: string; } +const POLL_INTERVAL_MS = 3_000; + /** * Query RAG indexing status for non-image file attachments. * - * Uses a reactive Convex query so the UI updates automatically - * as files transition through queued → running → completed/failed. + * - Reactive Convex query for instant UI updates when status changes. + * - Client-side polling: calls checkFileRagStatuses action every 3s + * while any file is in queued/running state. Polling stops automatically + * when the user leaves the page or all files finish indexing. */ export function useFileIndexingStatus(attachments: FileAttachment[]) { const fileIds = useMemo( @@ -49,8 +54,6 @@ export function useFileIndexingStatus(attachments: FileAttachment[]) { return map; }, [metadata]); - // Only block send for actively indexing files. - // undefined (legacy records) and failed are not blocking. const isIndexing = useMemo(() => { if (!metadata || fileIds.length === 0) return false; return metadata.some( @@ -58,5 +61,38 @@ export function useFileIndexingStatus(attachments: FileAttachment[]) { ); }, [metadata, fileIds.length]); + // IDs of files that still need polling + const pendingIds = useMemo(() => { + if (!metadata) return []; + return metadata + .filter((m) => m.ragStatus === 'queued' || m.ragStatus === 'running') + .map((m) => m.storageId); + }, [metadata]); + + // Client-side polling: call the action periodically while files are pending + const checkStatuses = useAction( + api.file_metadata.actions.checkFileRagStatuses, + ); + const pollingRef = useRef(false); + + useEffect(() => { + if (pendingIds.length === 0) return undefined; + + pollingRef.current = true; + + // Trigger immediately, then poll on interval + checkStatuses({ storageIds: pendingIds }).catch(() => {}); + + const timer = setInterval(() => { + if (!pollingRef.current) return; + checkStatuses({ storageIds: pendingIds }).catch(() => {}); + }, POLL_INTERVAL_MS); + + return () => { + pollingRef.current = false; + clearInterval(timer); + }; + }, [pendingIds, checkStatuses]); + return { isIndexing, statusMap }; } diff --git a/services/platform/convex/_generated/api.d.ts b/services/platform/convex/_generated/api.d.ts index 890d2e36d5..f241ec6b2f 100644 --- a/services/platform/convex/_generated/api.d.ts +++ b/services/platform/convex/_generated/api.d.ts @@ -271,6 +271,7 @@ import type * as documents_upload_base64_to_storage from "../documents/upload_ba import type * as documents_validators from "../documents/validators.js"; import type * as feedback_mutations from "../feedback/mutations.js"; import type * as feedback_queries from "../feedback/queries.js"; +import type * as file_metadata_actions from "../file_metadata/actions.js"; import type * as file_metadata_helpers from "../file_metadata/helpers.js"; import type * as file_metadata_internal_actions from "../file_metadata/internal_actions.js"; import type * as file_metadata_internal_mutations from "../file_metadata/internal_mutations.js"; @@ -1193,6 +1194,7 @@ declare const fullApi: ApiFromModules<{ "documents/validators": typeof documents_validators; "feedback/mutations": typeof feedback_mutations; "feedback/queries": typeof feedback_queries; + "file_metadata/actions": typeof file_metadata_actions; "file_metadata/helpers": typeof file_metadata_helpers; "file_metadata/internal_actions": typeof file_metadata_internal_actions; "file_metadata/internal_mutations": typeof file_metadata_internal_mutations; diff --git a/services/platform/convex/file_metadata/actions.ts b/services/platform/convex/file_metadata/actions.ts new file mode 100644 index 0000000000..a12c17f662 --- /dev/null +++ b/services/platform/convex/file_metadata/actions.ts @@ -0,0 +1,94 @@ +'use node'; + +import { v } from 'convex/values'; + +import { isRecord, getString } from '../../lib/utils/type-guards'; +import { internal } from '../_generated/api'; +import { action } from '../_generated/server'; +import { getRagConfig } from '../lib/helpers/rag_config'; + +/** + * Check RAG indexing status for a list of files and update fileMetadata. + * + * Called by the frontend on an interval while files are in queued/running + * state. Stops being called when the user leaves the page — no wasted + * server-side scheduled actions. + */ +export const checkFileRagStatuses = action({ + args: { + storageIds: v.array(v.id('_storage')), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + if (args.storageIds.length === 0) return null; + + const ragUrl = getRagConfig().serviceUrl; + if (!ragUrl) return null; + + const url = `${ragUrl}/api/v1/documents/statuses`; + + let body: unknown; + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_ids: args.storageIds }), + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + console.warn(`[checkFileRagStatuses] RAG returned ${response.status}`); + return null; + } + + body = await response.json(); + } catch (error) { + console.warn('[checkFileRagStatuses] Failed to fetch statuses:', error); + return null; + } + + if (!isRecord(body) || !isRecord(body.statuses)) { + return null; + } + + const statuses = body.statuses; + + for (const storageId of args.storageIds) { + const docStatus = statuses[storageId]; + if (!isRecord(docStatus)) continue; + + const status = getString(docStatus, 'status'); + const error = getString(docStatus, 'error'); + const progressPhase = getString(docStatus, 'progress_phase'); + const progressDetail = getString(docStatus, 'progress_detail'); + + const ragProgress = + progressPhase && progressDetail + ? `${progressPhase} ${progressDetail}` + : progressPhase || undefined; + + if (status === 'completed') { + await ctx.runMutation( + internal.file_metadata.internal_mutations.updateFileRagStatus, + { storageId, ragStatus: 'completed' }, + ); + } else if (status === 'failed') { + await ctx.runMutation( + internal.file_metadata.internal_mutations.updateFileRagStatus, + { + storageId, + ragStatus: 'failed', + ragError: error || 'Unknown error', + }, + ); + } else if (status === 'processing') { + await ctx.runMutation( + internal.file_metadata.internal_mutations.updateFileRagStatus, + { storageId, ragStatus: 'running', ragProgress }, + ); + } + } + + return null; + }, +}); diff --git a/services/platform/convex/file_metadata/internal_actions.ts b/services/platform/convex/file_metadata/internal_actions.ts index ed03a1983e..38366ef723 100644 --- a/services/platform/convex/file_metadata/internal_actions.ts +++ b/services/platform/convex/file_metadata/internal_actions.ts @@ -2,38 +2,16 @@ import { v } from 'convex/values'; -import { isRecord, getString } from '../../lib/utils/type-guards'; import { internal } from '../_generated/api'; import { internalAction } from '../_generated/server'; import { getRagConfig } from '../lib/helpers/rag_config'; import { ragAction } from '../workflow_engine/action_defs/rag/rag_action'; -const INITIAL_POLLING_DELAY_MS = 5_000; -const MAX_ATTEMPTS = 120; - -/** - * Polling interval for chat file RAG status checks. - * Fast initial polling for quick feedback, then backs off for large files. - * - Attempts 1-12: every 5s (~1 min) — small files complete here - * - Attempts 13-24: every 10s (~2 min) — medium files - * - Attempts 25-40: every 30s (~8 min) — large PDFs with images - * - Attempts 41-60: every 60s (~20 min) — very large files - * - Attempts 61-120: every 120s (~2 hours) — extremely large files (100MB+ PDFs) - * Total coverage: ~2.5 hours - */ -function getFilePollingInterval(attempt: number): number { - if (attempt <= 12) return 5_000; - if (attempt <= 24) return 10_000; - if (attempt <= 40) return 30_000; - if (attempt <= 60) return 60_000; - return 120_000; -} - /** * Upload a file to the RAG service for indexing. * - * Triggered by saveFileMetadata on new inserts. Tracks indexing status - * on the fileMetadata record and schedules polling for completion. + * Triggered by saveFileMetadata on new inserts. Only uploads — status + * polling is driven by the client via checkFileRagStatus. */ export const uploadFileToRag = internalAction({ args: { @@ -59,12 +37,6 @@ export const uploadFileToRag = internalAction({ }, {}, ); - - await ctx.scheduler.runAfter( - INITIAL_POLLING_DELAY_MS, - internal.file_metadata.internal_actions.checkFileRagStatus, - { storageId: args.storageId, attempt: 1 }, - ); } catch (error) { console.error( `[uploadFileToRag] Failed to upload file ${args.storageId}: ${error instanceof Error ? error.message : String(error)}`, @@ -82,186 +54,3 @@ export const uploadFileToRag = internalAction({ return null; }, }); - -/** - * Poll the RAG service for file indexing status. - * - * Modeled on documents/internal_actions.ts:checkRagDocumentStatus but with - * shorter intervals for fast chat UX feedback. - */ -export const checkFileRagStatus = internalAction({ - args: { - storageId: v.id('_storage'), - attempt: v.number(), - }, - returns: v.null(), - handler: async (ctx, args): Promise => { - const metadata = await ctx.runQuery( - internal.file_metadata.internal_queries.getByStorageId, - { storageId: args.storageId }, - ); - - if (!metadata) { - return null; - } - - if (metadata.ragStatus === 'completed' || metadata.ragStatus === 'failed') { - return null; - } - - if (args.attempt > MAX_ATTEMPTS) { - console.warn( - `[checkFileRagStatus] Max attempts (${MAX_ATTEMPTS}) reached for file ${args.storageId}`, - ); - await ctx.runMutation( - internal.file_metadata.internal_mutations.updateFileRagStatus, - { - storageId: args.storageId, - ragStatus: 'failed', - ragError: `Status check timed out after ${MAX_ATTEMPTS} attempts`, - }, - ); - return null; - } - - const ragUrl = getRagConfig().serviceUrl; - if (!ragUrl) { - return null; - } - - const url = `${ragUrl}/api/v1/documents/statuses`; - - try { - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ file_ids: [args.storageId] }), - signal: AbortSignal.timeout(10000), - }); - - if (response.status === 429) { - console.warn( - `[checkFileRagStatus] Rate limited (attempt ${args.attempt}/${MAX_ATTEMPTS})`, - ); - await ctx.scheduler.runAfter( - getFilePollingInterval(args.attempt), - internal.file_metadata.internal_actions.checkFileRagStatus, - { storageId: args.storageId, attempt: args.attempt + 1 }, - ); - return null; - } - - if (response.status >= 400 && response.status < 500) { - console.error( - `[checkFileRagStatus] RAG returned ${response.status} for ${args.storageId}, not retrying`, - ); - await ctx.runMutation( - internal.file_metadata.internal_mutations.updateFileRagStatus, - { - storageId: args.storageId, - ragStatus: 'failed', - ragError: `RAG service returned ${response.status}`, - }, - ); - return null; - } - - if (!response.ok) { - console.warn( - `[checkFileRagStatus] RAG returned ${response.status} (attempt ${args.attempt}/${MAX_ATTEMPTS})`, - ); - await ctx.scheduler.runAfter( - getFilePollingInterval(args.attempt), - internal.file_metadata.internal_actions.checkFileRagStatus, - { storageId: args.storageId, attempt: args.attempt + 1 }, - ); - return null; - } - - let body: unknown; - try { - body = await response.json(); - } catch { - throw new Error('RAG returned non-JSON response'); - } - - if (!isRecord(body)) { - throw new Error('Invalid response shape from RAG statuses endpoint'); - } - - const statuses = body.statuses; - if (!isRecord(statuses)) { - throw new Error('Invalid statuses field in RAG response'); - } - - const docStatus = statuses[args.storageId]; - const status = isRecord(docStatus) - ? getString(docStatus, 'status') - : null; - const error = isRecord(docStatus) - ? getString(docStatus, 'error') - : undefined; - const progressPhase = isRecord(docStatus) - ? getString(docStatus, 'progress_phase') - : undefined; - const progressDetail = isRecord(docStatus) - ? getString(docStatus, 'progress_detail') - : undefined; - - // Build a human-readable progress string, e.g. "extracting 12/50" - const ragProgress = - progressPhase && progressDetail - ? `${progressPhase} ${progressDetail}` - : progressPhase || undefined; - - if (status === 'completed') { - await ctx.runMutation( - internal.file_metadata.internal_mutations.updateFileRagStatus, - { storageId: args.storageId, ragStatus: 'completed' }, - ); - return null; - } - - if (status === 'failed') { - await ctx.runMutation( - internal.file_metadata.internal_mutations.updateFileRagStatus, - { - storageId: args.storageId, - ragStatus: 'failed', - ragError: error || 'Unknown error', - }, - ); - return null; - } - - if (status === 'processing') { - await ctx.runMutation( - internal.file_metadata.internal_mutations.updateFileRagStatus, - { - storageId: args.storageId, - ragStatus: 'running', - ragProgress, - }, - ); - } - - await ctx.scheduler.runAfter( - getFilePollingInterval(args.attempt), - internal.file_metadata.internal_actions.checkFileRagStatus, - { storageId: args.storageId, attempt: args.attempt + 1 }, - ); - } catch (error) { - console.error( - `[checkFileRagStatus] Error (attempt ${args.attempt}/${MAX_ATTEMPTS}):`, - error, - ); - await ctx.scheduler.runAfter( - getFilePollingInterval(args.attempt), - internal.file_metadata.internal_actions.checkFileRagStatus, - { storageId: args.storageId, attempt: args.attempt + 1 }, - ); - } - - return null; - }, -}); diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index 92c21de92b..7cecfc247f 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -2739,7 +2739,9 @@ "viewDocument": "View in document", "visitPage": "Visit page", "showAllSources": "Show all {count} sources", - "hideSources": "Hide sources" + "hideSources": "Hide sources", + "chunkCount": "{count, plural, one {# chunk} other {# chunks}}", + "noContent": "Content not available" }, "feedback": { "helpful": "Helpful", From da68b558f8819540915adb515eb7803c05dc352f Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 13:24:38 +0800 Subject: [PATCH 15/21] refactor(platform): improve citation handling for file retrieve operations and tool descriptions Rework citation parsing to correctly handle rag_search retrieve results, offset citation numbers across successive tool calls to prevent collisions, and improve deduplication to distinguish different chunks from the same file. Move "read file" guidance to the top of each file tool description for better agent visibility. Return fileId and filename from retrieve operations to enable proper source card attribution. --- .../features/chat/components/source-cards.tsx | 3 - .../app/features/chat/hooks/use-citations.ts | 222 ++++++++++++++---- .../convex/agent_tools/files/docx_tool.ts | 8 +- .../convex/agent_tools/files/excel_tool.ts | 8 +- .../convex/agent_tools/files/pdf_tool.ts | 8 +- .../convex/agent_tools/files/pptx_tool.ts | 8 +- .../convex/agent_tools/files/text_tool.ts | 8 +- .../convex/agent_tools/rag/rag_search_tool.ts | 7 +- 8 files changed, 206 insertions(+), 66 deletions(-) diff --git a/services/platform/app/features/chat/components/source-cards.tsx b/services/platform/app/features/chat/components/source-cards.tsx index 62ae2f2a93..0468219968 100644 --- a/services/platform/app/features/chat/components/source-cards.tsx +++ b/services/platform/app/features/chat/components/source-cards.tsx @@ -69,9 +69,6 @@ function SourceCard({ source, onClick }: SourceCardProps) { )} - - [{source.chunkNumbers.join(', ')}] - ); diff --git a/services/platform/app/features/chat/hooks/use-citations.ts b/services/platform/app/features/chat/hooks/use-citations.ts index adec9aa232..7f7d197caa 100644 --- a/services/platform/app/features/chat/hooks/use-citations.ts +++ b/services/platform/app/features/chat/hooks/use-citations.ts @@ -14,6 +14,7 @@ export interface CitationInfo { interface ToolUsageInput { toolName: string; + input?: string; output?: string; } @@ -86,45 +87,152 @@ export function parseWebCitations(text: string): Map { return citations; } +/** + * Try to unwrap safeStringify'd output — handles both JSON-wrapped + * strings and nested objects with a `response` or `output` field. + */ +function unwrapOutput(raw: string): string { + let output = raw; + + // Unwrap JSON-wrapped string: "\"...\"" + if (output.startsWith('"') && output.endsWith('"')) { + try { + const parsed: unknown = JSON.parse(output); + if (typeof parsed === 'string') { + output = parsed; + } + } catch { + // use as-is + } + } + + return output; +} + +function isPlainObject(val: unknown): val is Record { + return val !== null && typeof val === 'object' && !Array.isArray(val); +} + +interface JsonFieldsResult { + response?: string; + filename?: string; + fileId?: string; +} + +/** + * Extract metadata fields from a JSON tool output string. + * Handles both direct objects and nested `{ value: { ... } }` wrappers. + */ +function extractJsonFields(output: string): JsonFieldsResult | undefined { + try { + const parsed: unknown = JSON.parse(output); + if (!isPlainObject(parsed)) return undefined; + + // Check nested value wrapper (tool-result shape) + const obj = isPlainObject(parsed.value) ? parsed.value : parsed; + + const response = + typeof obj.response === 'string' + ? obj.response + : typeof obj.output === 'string' + ? obj.output + : undefined; + // filename field (retrieve), or title as fallback + const filename = + typeof obj.filename === 'string' + ? obj.filename + : typeof obj.title === 'string' + ? obj.title + : undefined; + const fileId = typeof obj.fileId === 'string' ? obj.fileId : undefined; + + return response || filename || fileId + ? { response, filename, fileId } + : undefined; + } catch { + // not JSON + } + return undefined; +} + +/** + * Detect whether a rag_search tool call is a 'retrieve' operation + * by examining its input. Returns parsed input data if it is. + */ +function parseRetrieveInput( + inputStr: string | undefined, +): { fileId: string } | undefined { + if (!inputStr) return undefined; + try { + const parsed: unknown = JSON.parse(inputStr); + if ( + isPlainObject(parsed) && + parsed.operation === 'retrieve' && + typeof parsed.fileId === 'string' + ) { + return { fileId: parsed.fileId }; + } + } catch { + // not JSON + } + return undefined; +} + /** * Parse citations from tool usage records. * - * Processes RAG and web tool outputs in order, offsetting web citation - * numbers by the max RAG citation number to avoid collisions. + * Processes RAG search and retrieve operations plus web tool outputs, + * offsetting citation numbers between successive calls to avoid collisions. */ export function parseCitationsFromToolsUsage( toolsUsage: ToolUsageInput[], ): Map { const allCitations = new Map(); - let maxNumber = 0; + let nextNumber = 1; for (const usage of toolsUsage) { if (!usage.output) continue; - // toolsUsage.output is safeStringify'd — unwrap if it's a JSON string - let output = usage.output; - if (output.startsWith('"') && output.endsWith('"')) { - try { - const parsed: unknown = JSON.parse(output); - if (typeof parsed === 'string') { - output = parsed; - } - } catch { - // use as-is - } - } + const output = unwrapOutput(usage.output); if (usage.toolName === 'rag_search') { - const ragCitations = parseRagCitations(output); - for (const [num, citation] of ragCitations) { - allCitations.set(num, citation); - if (num > maxNumber) maxNumber = num; + const fields = extractJsonFields(output); + // First try to parse as formatted search results ([N] Relevance: ...) + const responseText = fields?.response ?? output; + const ragCitations = parseRagCitations(responseText); + + if (ragCitations.size > 0) { + // Offset all numbers so successive rag_search calls don't collide + const offset = nextNumber - 1; + for (const [, citation] of ragCitations) { + const newNum = citation.number + offset; + allCitations.set(newNum, { ...citation, number: newNum }); + if (newNum >= nextNumber) nextNumber = newNum + 1; + } + } else { + // No formatted citations — could be a retrieve operation + const retrieveInput = parseRetrieveInput(usage.input); + if (retrieveInput) { + const content = fields?.response ?? output; + if (content && content !== 'Document has no text content.') { + allCitations.set(nextNumber, { + number: nextNumber, + fileId: fields?.fileId ?? retrieveInput.fileId, + filename: fields?.filename ?? undefined, + type: 'rag', + content, + }); + nextNumber++; + } + } } } else if (usage.toolName === 'web') { const webCitations = parseWebCitations(output); - for (const [originalNum, citation] of webCitations) { - const offsetNum = originalNum + maxNumber; - allCitations.set(offsetNum, { ...citation, number: offsetNum }); + const offset = nextNumber - 1; + for (const [, citation] of webCitations) { + const newNum = citation.number + offset; + allCitations.set(newNum, { ...citation, number: newNum }); + if (newNum >= nextNumber) nextNumber = newNum + 1; } } } @@ -151,9 +259,12 @@ function deduplicateCitations( const deduped = new Map(); for (const [num, citation] of citations) { + // Include a content fingerprint so different chunks from the same + // file/page are kept as separate entries. + const contentKey = citation.content?.slice(0, 80) ?? ''; const sourceKey = citation.type === 'rag' - ? `rag:${citation.fileId ?? ''}:${citation.page ?? ''}` + ? `rag:${citation.fileId ?? ''}:${citation.page ?? ''}:${contentKey}` : `web:${citation.url ?? ''}`; const existingNum = seen.get(sourceKey); @@ -203,44 +314,71 @@ export function getUniqueSources( citations: Map, ): SourceGroup[] { const groups = new Map(); + // Track which original citation numbers we've already added as chunks + // to avoid duplicating content when deduplicateCitations maps multiple + // keys to the same citation object. + const addedChunkIds = new Map>(); - for (const citation of citations.values()) { + for (const [mapKey, citation] of citations) { const sourceKey = citation.type === 'rag' ? `rag:${citation.fileId ?? ''}` : `web:${citation.url ?? ''}`; - const chunk: ChunkDetail = { - number: citation.number, - page: citation.page, - relevance: citation.relevance, - content: citation.content, - }; + // Use the Map key as the inline reference number (the [N] in the text), + // since deduplicateCitations may remap multiple keys to the same citation. + const inlineNumber = mapKey; const existing = groups.get(sourceKey); if (existing) { - existing.chunkNumbers.push(citation.number); - existing.chunks.push(chunk); - if (citation.page != null && !existing.pages.includes(citation.page)) { - existing.pages.push(citation.page); + existing.chunkNumbers.push(inlineNumber); + + // Only add chunk detail if this is a genuinely different chunk + // (not a remapped duplicate pointing to the same original citation) + let chunkSet = addedChunkIds.get(sourceKey); + if (!chunkSet) { + chunkSet = new Set(); + addedChunkIds.set(sourceKey, chunkSet); } - if ( - citation.relevance != null && - (existing.relevance == null || citation.relevance > existing.relevance) - ) { - existing.relevance = citation.relevance; + if (!chunkSet.has(citation.number)) { + chunkSet.add(citation.number); + existing.chunks.push({ + number: citation.number, + page: citation.page, + relevance: citation.relevance, + content: citation.content, + }); + if (citation.page != null && !existing.pages.includes(citation.page)) { + existing.pages.push(citation.page); + } + if ( + citation.relevance != null && + (existing.relevance == null || + citation.relevance > existing.relevance) + ) { + existing.relevance = citation.relevance; + } } } else { + const chunkSet = new Set([citation.number]); + addedChunkIds.set(sourceKey, chunkSet); groups.set(sourceKey, { - number: citation.number, + number: inlineNumber, filename: citation.filename, fileId: citation.fileId, url: citation.url, type: citation.type, - chunkNumbers: [citation.number], + chunkNumbers: [inlineNumber], pages: citation.page != null ? [citation.page] : [], relevance: citation.relevance, - chunks: [chunk], + chunks: [ + { + number: citation.number, + page: citation.page, + relevance: citation.relevance, + content: citation.content, + }, + ], }); } } diff --git a/services/platform/convex/agent_tools/files/docx_tool.ts b/services/platform/convex/agent_tools/files/docx_tool.ts index db0893e4ea..53e15c8931 100644 --- a/services/platform/convex/agent_tools/files/docx_tool.ts +++ b/services/platform/convex/agent_tools/files/docx_tool.ts @@ -96,6 +96,10 @@ export const docxTool = { IMPORTANT: Only call the "generate" operation when the user explicitly requests creating or exporting a Word/DOCX file. Do NOT proactively generate Word documents unless the user specifically asks for this format. +TO READ WORD/DOCX FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: +• To get the full content of a DOCX file: use rag_search with operation='retrieve' and the fileId +• To search for specific information across DOCX files: use rag_search with operation='search' + OPERATIONS: 1. generate - Generate a DOCX document @@ -127,10 +131,6 @@ AFTER GENERATING: Check the downloadUrl in the result: - If it says "[file card shown in chat]": the file is already visible as a download card. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. - If it contains an actual URL: no download card was shown. You MUST include the URL as a clickable markdown link so the user can download the file. To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. - -TO READ FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: -• To get the full content of a file: use rag_search with operation='retrieve' and the fileId -• To search for specific information across files: use rag_search with operation='search' `, inputSchema: docxArgs, execute: async (ctx: ToolCtx, args): Promise => { diff --git a/services/platform/convex/agent_tools/files/excel_tool.ts b/services/platform/convex/agent_tools/files/excel_tool.ts index 1ec8edb8b6..dd87aaa96b 100644 --- a/services/platform/convex/agent_tools/files/excel_tool.ts +++ b/services/platform/convex/agent_tools/files/excel_tool.ts @@ -58,6 +58,10 @@ export const excelTool = { IMPORTANT: Only call this tool when the user explicitly requests creating or exporting an Excel/spreadsheet file. Do NOT proactively generate Excel files unless the user specifically asks for this format. +TO READ EXCEL FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: +• To get the full content of an Excel file: use rag_search with operation='retrieve' and the fileId +• To search for specific information across Excel files: use rag_search with operation='search' + OPERATION: generate - Generate an Excel file from structured tabular data @@ -74,10 +78,6 @@ AFTER GENERATING: Check the downloadUrl in the result: - If it says "[file card shown in chat]": the file is already visible as a download card. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. - If it contains an actual URL: no download card was shown. You MUST include the URL as a clickable markdown link so the user can download the file. To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. - -TO READ FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: -• To get the full content of a file: use rag_search with operation='retrieve' and the fileId -• To search for specific information across files: use rag_search with operation='search' `, inputSchema: excelArgs, execute: async (ctx: ToolCtx, args): Promise => { diff --git a/services/platform/convex/agent_tools/files/pdf_tool.ts b/services/platform/convex/agent_tools/files/pdf_tool.ts index 9fd33e6ab4..dda8765aba 100644 --- a/services/platform/convex/agent_tools/files/pdf_tool.ts +++ b/services/platform/convex/agent_tools/files/pdf_tool.ts @@ -31,6 +31,10 @@ export const pdfTool = { IMPORTANT: Only call the "generate" operation when the user explicitly requests creating or exporting a PDF file. Do NOT proactively generate PDFs unless the user specifically asks for this format. +TO READ PDF FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: +• To get the full content of a PDF file: use rag_search with operation='retrieve' and the fileId +• To search for specific information across PDF files: use rag_search with operation='search' + OPERATIONS: 1. generate - Generate a PDF from Markdown/HTML, or download/capture a PDF from a URL @@ -59,10 +63,6 @@ AFTER GENERATING: Check the downloadUrl in the result: - If it says "[file card shown in chat]": the file is already visible as a download card. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. - If it contains an actual URL: no download card was shown. You MUST include the URL as a clickable markdown link so the user can download the file. To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. - -TO READ FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: -• To get the full content of a file: use rag_search with operation='retrieve' and the fileId -• To search for specific information across files: use rag_search with operation='search' `, inputSchema: z.object({ operation: z.literal('generate'), diff --git a/services/platform/convex/agent_tools/files/pptx_tool.ts b/services/platform/convex/agent_tools/files/pptx_tool.ts index 07956a8c28..64e146c129 100644 --- a/services/platform/convex/agent_tools/files/pptx_tool.ts +++ b/services/platform/convex/agent_tools/files/pptx_tool.ts @@ -46,6 +46,10 @@ export const pptxTool: ToolDefinition = { IMPORTANT: Only call the "generate" operation when the user explicitly requests creating or exporting a PowerPoint/PPTX file. Do NOT proactively generate presentations unless the user specifically asks for this format. +TO READ PPTX FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: +• To get the full content of a PPTX file: use rag_search with operation='retrieve' and the fileId +• To search for specific information across PPTX files: use rag_search with operation='search' + OPERATIONS: 1. generate - Generate a PPTX from Markdown or HTML @@ -63,10 +67,6 @@ AFTER GENERATING: Check the downloadUrl in the result: - If it says "[file card shown in chat]": the file is already visible as a download card. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. - If it contains an actual URL: no download card was shown. You MUST include the URL as a clickable markdown link so the user can download the file. To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. - -TO READ FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: -• To get the full content of a file: use rag_search with operation='retrieve' and the fileId -• To search for specific information across files: use rag_search with operation='search' `, inputSchema: pptxArgs, execute: async (ctx: ToolCtx, args): Promise => { diff --git a/services/platform/convex/agent_tools/files/text_tool.ts b/services/platform/convex/agent_tools/files/text_tool.ts index 0362c53b34..1753e70ea5 100644 --- a/services/platform/convex/agent_tools/files/text_tool.ts +++ b/services/platform/convex/agent_tools/files/text_tool.ts @@ -44,6 +44,10 @@ export const textTool = { IMPORTANT: Only call the "generate" operation when the user explicitly requests creating or exporting a text file. Do NOT proactively generate text files unless the user specifically asks for this format. +TO READ TEXT FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: +• To get the full content of a text file: use rag_search with operation='retrieve' and the fileId +• To search for specific information across text files: use rag_search with operation='search' + **GENERATE OPERATION** Use when a user wants to create/export a text file. Parameters: @@ -60,10 +64,6 @@ AFTER GENERATING: Check the downloadUrl in the result: - If it says "[file card shown in chat]": the file is already visible as a download card. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. - If it contains an actual URL: no download card was shown. You MUST include the URL as a clickable markdown link so the user can download the file. To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. - -TO READ FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: -• To get the full content of a file: use rag_search with operation='retrieve' and the fileId -• To search for specific information across files: use rag_search with operation='search' `, inputSchema: textArgs, execute: async (ctx: ToolCtx, args): Promise => { diff --git a/services/platform/convex/agent_tools/rag/rag_search_tool.ts b/services/platform/convex/agent_tools/rag/rag_search_tool.ts index fe66ad882a..6ffbb942e9 100644 --- a/services/platform/convex/agent_tools/rag/rag_search_tool.ts +++ b/services/platform/convex/agent_tools/rag/rag_search_tool.ts @@ -211,6 +211,8 @@ RESPONSE (list_indexed): total_chars: number; chunk_range: { start: number; end: number }; chunks: Array<{ index: number; content: string }> | null; + source_created_at: string | null; + source_modified_at: string | null; } const result = await fetchJson(response); @@ -232,7 +234,10 @@ RESPONSE (list_indexed): return { success: true, response: text || 'Document has no text content.', - title: result.title, + fileId: result.file_id, + filename: result.title, + sourceCreatedAt: result.source_created_at, + sourceModifiedAt: result.source_modified_at, totalChunks: result.total_chunks, chunkRange: result.chunk_range, hasMore, From c9da9dd46ab783bc06e188ac92c243361f51d2dc Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 13:29:33 +0800 Subject: [PATCH 16/21] fix(platform): improve RAG tool instructions for file ID prioritization Guide the agent to pass user-provided file IDs (from chat uploads) via the fileIds parameter first, falling back to broader search when needed. Clarify that list_indexed only covers Document Hub files. --- .../convex/agent_tools/rag/rag_search_tool.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/services/platform/convex/agent_tools/rag/rag_search_tool.ts b/services/platform/convex/agent_tools/rag/rag_search_tool.ts index 6ffbb942e9..045108383c 100644 --- a/services/platform/convex/agent_tools/rag/rag_search_tool.ts +++ b/services/platform/convex/agent_tools/rag/rag_search_tool.ts @@ -87,7 +87,7 @@ const ragToolArgs = z.discriminatedUnion('operation', [ .array(z.string()) .optional() .describe( - 'Specific file IDs to search within. When provided, only these files are searched (skips automatic file resolution). Use this when you know exactly which files to search.', + 'Specific file IDs to search within. When provided, only these files are searched (skips automatic file resolution). IMPORTANT: If the user message contains file IDs (from uploaded attachments), pass them here first to prioritize those files. Retry without fileIds for a broader search if no relevant results are found.', ), topK: z .number() @@ -143,22 +143,26 @@ export const ragSearchTool = { OPERATIONS: • 'search': Search the knowledge base for relevant document excerpts using hybrid search (BM25 + vector similarity). Returns numbered excerpts with relevance scores. • 'retrieve': Retrieve document content by file ID in paginated chunks (default 10 chunks per call). Use chunkStart/chunkEnd to paginate. Returns chunk range and totalChunks so you can fetch more. Use this to read uploaded files (PDF, DOCX, PPTX, TXT, XLSX, etc.). -• 'list_indexed': List documents that have been indexed in the knowledge base. Returns file names, file IDs, and modification dates. Use this to see what's available before searching. +• 'list_indexed': List documents indexed in the Document Hub (does NOT include files uploaded in chat). Returns file names, file IDs, and modification dates. WHEN TO USE 'search': • Knowledge base lookups: policies, procedures, documentation • Questions about stored documents and content • Finding information when you don't know exact field values +SEARCH STRATEGY — file ID priority: +When the user's message contains file IDs (e.g. from uploaded attachments), ALWAYS pass those IDs in the 'fileIds' parameter first to search within those specific files. If that returns no relevant results, retry WITHOUT fileIds to perform a broader knowledge base search. This ensures uploaded/referenced files are prioritized while still falling back to the full knowledge base when needed. + WHEN TO USE 'retrieve': • Reading content of a specific uploaded file (paginated, 10 chunks per call by default) • When a user uploads a file and asks you to read, summarize, or analyze it • For large documents, retrieve returns the first page — use chunkStart/chunkEnd to read more, or use 'search' with a query for targeted lookup WHEN TO USE 'list_indexed': -• See which files are available for RAG search +• See which documents are in the Document Hub (org/team knowledge base) • Get file IDs for use with the search or retrieve operations -• Check when files were last modified +• Check when documents were last modified +• NOTE: This only lists Document Hub files. Files uploaded in chat are NOT included — their file IDs are already in the conversation context. WHEN NOT TO USE: • "How many customers?" → Use customer_read with operation='list' From 8a6b718988f35d4b18633246452b9e3e2c40e56d Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 13:35:08 +0800 Subject: [PATCH 17/21] fix: resolve CI typecheck and test failures - Add non-null assertion for threadId in message_metadata query (already guarded by if-check) - Add progress_phase/progress_detail fields to test mock data matching updated SQL query - Update vision_client test to accept additional on_progress kwarg --- services/platform/convex/message_metadata/queries.ts | 2 +- services/rag/tests/test_background_ingest.py | 6 ++++++ services/rag/tests/test_indexing_service.py | 9 ++++----- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/services/platform/convex/message_metadata/queries.ts b/services/platform/convex/message_metadata/queries.ts index 41f2536fb6..c2e11390dd 100644 --- a/services/platform/convex/message_metadata/queries.ts +++ b/services/platform/convex/message_metadata/queries.ts @@ -22,7 +22,7 @@ export const getMessageMetadata = query({ if (args.threadId) { return ctx.db .query('messageMetadata') - .withIndex('by_threadId', (q) => q.eq('threadId', args.threadId)) + .withIndex('by_threadId', (q) => q.eq('threadId', args.threadId!)) .order('desc') .first(); } diff --git a/services/rag/tests/test_background_ingest.py b/services/rag/tests/test_background_ingest.py index 30c5dc8104..b4f00f8102 100644 --- a/services/rag/tests/test_background_ingest.py +++ b/services/rag/tests/test_background_ingest.py @@ -70,6 +70,8 @@ async def test_returns_status_for_found_documents(self): "file_id": "doc-1", "status": "completed", "error": None, + "progress_phase": None, + "progress_detail": None, "source_created_at": None, "source_modified_at": None, }, @@ -77,6 +79,8 @@ async def test_returns_status_for_found_documents(self): "file_id": "doc-2", "status": "processing", "error": None, + "progress_phase": None, + "progress_detail": None, "source_created_at": None, "source_modified_at": None, }, @@ -103,6 +107,8 @@ async def test_returns_error_field_for_failed_documents(self): "file_id": "doc-1", "status": "failed", "error": "Embedding failed", + "progress_phase": None, + "progress_detail": None, "source_created_at": None, "source_modified_at": None, }, diff --git a/services/rag/tests/test_indexing_service.py b/services/rag/tests/test_indexing_service.py index 71ae6e3cfa..08fec12ca8 100644 --- a/services/rag/tests/test_indexing_service.py +++ b/services/rag/tests/test_indexing_service.py @@ -211,11 +211,10 @@ async def test_passes_vision_client_to_extract(self): vision_client=mock_vision, ) - mock_extract.assert_awaited_once_with( - SAMPLE_CONTENT, - SAMPLE_FILENAME, - vision_client=mock_vision, - ) + call_kwargs = mock_extract.call_args + assert call_kwargs.args == (SAMPLE_CONTENT, SAMPLE_FILENAME) + assert call_kwargs.kwargs["vision_client"] is mock_vision + assert "on_progress" in call_kwargs.kwargs async def test_custom_chunk_size_and_overlap(self): from app.services.indexing_service import index_document From 1894343bcf05b72fa403ec06ca7e2fb2b004dff8 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 13:37:22 +0800 Subject: [PATCH 18/21] fix: resolve lint and test failures - Use destructuring instead of non-null assertion for threadId narrowing - Add ragStatus: 'queued' to file metadata insert test expectations --- .../convex/file_metadata/__tests__/mutations.test.ts | 2 ++ services/platform/convex/message_metadata/queries.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/services/platform/convex/file_metadata/__tests__/mutations.test.ts b/services/platform/convex/file_metadata/__tests__/mutations.test.ts index 18d0b03ffa..8674c6c969 100644 --- a/services/platform/convex/file_metadata/__tests__/mutations.test.ts +++ b/services/platform/convex/file_metadata/__tests__/mutations.test.ts @@ -111,6 +111,7 @@ describe('saveFileMetadata (public)', () => { fileName: 'test.pdf', contentType: 'application/pdf', size: 1024, + ragStatus: 'queued', }); expect(ctx.db.patch).not.toHaveBeenCalled(); }); @@ -171,6 +172,7 @@ describe('saveFileMetadata (public)', () => { contentType: 'application/pdf', size: 1024, documentId: 'doc_1', + ragStatus: 'queued', }); }); diff --git a/services/platform/convex/message_metadata/queries.ts b/services/platform/convex/message_metadata/queries.ts index c2e11390dd..f690876100 100644 --- a/services/platform/convex/message_metadata/queries.ts +++ b/services/platform/convex/message_metadata/queries.ts @@ -19,10 +19,11 @@ export const getMessageMetadata = query({ // In error scenarios, the metadata is saved with the failed message's // ID which differs from the UIMessage id (first message in group). // Fall back to the most recent metadata entry for this thread. - if (args.threadId) { + const { threadId } = args; + if (threadId) { return ctx.db .query('messageMetadata') - .withIndex('by_threadId', (q) => q.eq('threadId', args.threadId!)) + .withIndex('by_threadId', (q) => q.eq('threadId', threadId)) .order('desc') .first(); } From 5b3f223b51ea5398e775abc47f940a4c3ac95a0c Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 13:39:45 +0800 Subject: [PATCH 19/21] fix: add ragStatus to internal_mutations test expectations --- .../convex/file_metadata/__tests__/internal_mutations.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/platform/convex/file_metadata/__tests__/internal_mutations.test.ts b/services/platform/convex/file_metadata/__tests__/internal_mutations.test.ts index a27f8ac2db..d08749af4a 100644 --- a/services/platform/convex/file_metadata/__tests__/internal_mutations.test.ts +++ b/services/platform/convex/file_metadata/__tests__/internal_mutations.test.ts @@ -94,6 +94,7 @@ describe('saveFileMetadata (internal)', () => { fileName: 'test.pdf', contentType: 'application/pdf', size: 1024, + ragStatus: 'queued', }); expect(ctx.db.patch).not.toHaveBeenCalled(); }); @@ -151,6 +152,7 @@ describe('saveFileMetadata (internal)', () => { contentType: 'application/pdf', size: 1024, documentId: 'doc_1', + ragStatus: 'queued', }); }); From 5d19a6c341d7d86dd640e184b1e7d37353ecd23b Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 13:50:55 +0800 Subject: [PATCH 20/21] feat(crawler): add /from-html and /from-markdown endpoints to PPTX router The platform's generateDocument helper builds URLs like /api/v1/pptx/from-html but the crawler only had the JSON-based POST /api/v1/pptx endpoint, causing 404s. Added the missing endpoints following the same pattern as the DOCX router. --- services/crawler/app/models.py | 12 + services/crawler/app/routers/pptx.py | 85 +++++++ .../app/services/html_to_pptx_converter.py | 235 ++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 services/crawler/app/services/html_to_pptx_converter.py diff --git a/services/crawler/app/models.py b/services/crawler/app/models.py index 1c81162a2c..9d5a51c31d 100644 --- a/services/crawler/app/models.py +++ b/services/crawler/app/models.py @@ -207,6 +207,18 @@ class HtmlToDocxRequest(BaseModel): # ==================== PPTX Models ==================== +class MarkdownToPptxRequest(BaseModel): + """Request to convert Markdown to PPTX.""" + + content: str = Field(..., description="Markdown content to convert") + + +class HtmlToPptxRequest(BaseModel): + """Request to convert HTML to PPTX.""" + + html: str = Field(..., description="HTML content to convert") + + class TableData(BaseModel): """Table data for PPTX generation.""" diff --git a/services/crawler/app/routers/pptx.py b/services/crawler/app/routers/pptx.py index 9652f85500..9c512a73d0 100644 --- a/services/crawler/app/routers/pptx.py +++ b/services/crawler/app/routers/pptx.py @@ -6,11 +6,14 @@ import json from fastapi import APIRouter, File, Form, HTTPException, UploadFile, status +from fastapi.responses import Response from loguru import logger from app.models import ( FileMetadataResponse, GeneratePptxResponse, + HtmlToPptxRequest, + MarkdownToPptxRequest, ParseFileResponse, ) from app.services.file_parser_service import get_file_parser_service @@ -133,6 +136,88 @@ async def generate_pptx_from_json( ) +_PPTX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.presentation" + + +@router.post("/from-markdown") +async def convert_markdown_to_pptx(request: MarkdownToPptxRequest): + """ + Convert Markdown content to PPTX. + + Parses markdown into HTML, then extracts slide structure (headings become + slide titles, lists become bullet points, etc.) and generates a PowerPoint. + + Args: + request: Markdown content + + Returns: + PPTX file as binary response + """ + try: + from app.services.base_converter import BaseConverterService + from app.services.html_to_pptx_converter import html_to_slides + + converter = BaseConverterService() + html = await converter.markdown_to_html(request.content) + slides_content = html_to_slides(html) + + template_service = get_template_service() + pptx_bytes = await template_service.generate_pptx_from_content( + slides_content=slides_content, + ) + + return Response( + content=pptx_bytes, + media_type=_PPTX_CONTENT_TYPE, + headers={"Content-Disposition": "attachment; filename=presentation.pptx"}, + ) + + except Exception: + logger.exception("Error converting markdown to PPTX") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to convert markdown to PPTX", + ) from None + + +@router.post("/from-html") +async def convert_html_to_pptx(request: HtmlToPptxRequest): + """ + Convert HTML content to PPTX. + + Parses HTML to extract slide structure (h1/h2 headings become slide titles, + lists become bullet points, tables preserved) and generates a PowerPoint. + + Args: + request: HTML content + + Returns: + PPTX file as binary response + """ + try: + from app.services.html_to_pptx_converter import html_to_slides + + slides_content = html_to_slides(request.html) + + template_service = get_template_service() + pptx_bytes = await template_service.generate_pptx_from_content( + slides_content=slides_content, + ) + + return Response( + content=pptx_bytes, + media_type=_PPTX_CONTENT_TYPE, + headers={"Content-Disposition": "attachment; filename=presentation.pptx"}, + ) + + except Exception: + logger.exception("Error converting HTML to PPTX") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to convert HTML to PPTX", + ) from None + + @router.post("/parse", response_model=ParseFileResponse) async def parse_pptx_file( file: UploadFile = _FILE_UPLOAD, diff --git a/services/crawler/app/services/html_to_pptx_converter.py b/services/crawler/app/services/html_to_pptx_converter.py new file mode 100644 index 0000000000..3979bbc89f --- /dev/null +++ b/services/crawler/app/services/html_to_pptx_converter.py @@ -0,0 +1,235 @@ +""" +HTML to PPTX slide converter. + +Parses HTML content and converts it into structured slide dicts +that can be passed to PptxService.generate_pptx_from_content(). + +Uses BeautifulSoup for HTML parsing. Each top-level heading (h1/h2) +starts a new slide; content between headings becomes bullet points +or text content on that slide. +""" + +import logging +import re +from typing import Any + +from bs4 import BeautifulSoup, NavigableString, Tag + +logger = logging.getLogger(__name__) + +# Heading tags that start a new slide +_SLIDE_BREAK_TAGS = {"h1", "h2"} + +# Tags to skip entirely +_SKIP_TAGS = {"script", "style", "meta", "link", "head"} + + +def _get_text(element: Tag) -> str: + """Extract clean text from an element, collapsing whitespace.""" + text = element.get_text(separator=" ", strip=True) + return re.sub(r"\s+", " ", text).strip() + + +def _parse_list_items(list_tag: Tag) -> list[str]: + """Extract text from
  • children of a list tag.""" + items: list[str] = [] + for li in list_tag.find_all("li", recursive=False): + text = _get_text(li) + if text: + items.append(text) + return items + + +def _parse_table(table_tag: Tag) -> dict[str, Any] | None: + """Parse an HTML table into headers and rows.""" + headers: list[str] = [] + rows: list[list[str]] = [] + + thead = table_tag.find("thead") + if thead: + for th in thead.find_all("th"): + headers.append(_get_text(th)) + + tbody = table_tag.find("tbody") or table_tag + for tr in tbody.find_all("tr", recursive=False): + cells = tr.find_all(["td", "th"]) + if not cells: + continue + + if not headers and all(cell.name == "th" for cell in cells): + headers = [_get_text(cell) for cell in cells] + continue + + row = [_get_text(cell) for cell in cells] + rows.append(row) + + if not headers and not rows: + return None + + if not headers and rows: + col_count = max(len(r) for r in rows) + headers = [f"Column {i + 1}" for i in range(col_count)] + + for i, row in enumerate(rows): + if len(row) < len(headers): + rows[i] = row + [""] * (len(headers) - len(row)) + elif len(row) > len(headers): + rows[i] = row[: len(headers)] + + return {"headers": headers, "rows": rows} + + +def _flush_slide( + slides: list[dict[str, Any]], + title: str | None, + subtitle: str | None, + text_content: list[str], + bullet_points: list[str], + tables: list[dict[str, Any]], +) -> None: + """Flush accumulated content into a slide dict.""" + if not title and not text_content and not bullet_points and not tables: + return + + slide: dict[str, Any] = {} + if title: + slide["title"] = title + if subtitle: + slide["subtitle"] = subtitle + if text_content: + slide["textContent"] = text_content + if bullet_points: + slide["bulletPoints"] = bullet_points + if tables: + slide["tables"] = tables + + slides.append(slide) + + +def _collect_content( + element: Tag, + text_content: list[str], + bullet_points: list[str], + tables: list[dict[str, Any]], +) -> None: + """Collect content from an element into the appropriate lists.""" + tag_name = element.name.lower() + + if tag_name in _SKIP_TAGS: + return + + # Lists become bullet points + if tag_name in ("ul", "ol"): + items = _parse_list_items(element) + bullet_points.extend(items) + return + + # Tables + if tag_name == "table": + table_data = _parse_table(element) + if table_data: + tables.append(table_data) + return + + # Container tags — recurse into children + if tag_name in ("div", "section", "article", "main", "header", "footer", "nav", "aside"): + for child in element.children: + if isinstance(child, NavigableString): + text = child.strip() + if text: + text_content.append(text) + elif isinstance(child, Tag): + _collect_content(child, text_content, bullet_points, tables) + return + + # Sub-headings (h3-h6) become bold text content within a slide + if tag_name in ("h3", "h4", "h5", "h6"): + text = _get_text(element) + if text: + text_content.append(text) + return + + # Code blocks + if tag_name == "pre": + code_tag = element.find("code") + text = code_tag.get_text() if code_tag else element.get_text() + if text.strip(): + text_content.append(text.strip()) + return + + # Paragraph and everything else with text + text = _get_text(element) + if text: + text_content.append(text) + + +def html_to_slides(html: str) -> list[dict[str, Any]]: + """ + Convert HTML content to a list of slide content dicts for PptxService. + + Each h1/h2 heading starts a new slide. Content between headings + becomes textContent or bulletPoints on that slide. + + Returns: + List of slide dicts with title, subtitle, textContent, bulletPoints, tables. + """ + soup = BeautifulSoup(html, "html.parser") + body = soup.find("body") or soup + + slides: list[dict[str, Any]] = [] + + # Current slide accumulation + current_title: str | None = None + current_subtitle: str | None = None + current_text: list[str] = [] + current_bullets: list[str] = [] + current_tables: list[dict[str, Any]] = [] + + for child in body.children: + if isinstance(child, NavigableString): + text = child.strip() + if text: + current_text.append(text) + continue + + if not isinstance(child, Tag): + continue + + tag_name = child.name.lower() + + if tag_name in _SKIP_TAGS: + continue + + # h1/h2 starts a new slide + if tag_name in _SLIDE_BREAK_TAGS: + # Flush previous slide + _flush_slide(slides, current_title, current_subtitle, current_text, current_bullets, current_tables) + current_title = _get_text(child) + current_subtitle = None + current_text = [] + current_bullets = [] + current_tables = [] + continue + + # h3 right after a title with no content yet becomes subtitle + if tag_name == "h3" and current_title and not current_text and not current_bullets and not current_subtitle: + current_subtitle = _get_text(child) + continue + + _collect_content(child, current_text, current_bullets, current_tables) + + # Flush final slide + _flush_slide(slides, current_title, current_subtitle, current_text, current_bullets, current_tables) + + # If no slides were created (no headings found), create a single slide from all content + if not slides and (current_text or current_bullets or current_tables): + slide: dict[str, Any] = {"title": "Untitled Slide"} + if current_text: + slide["textContent"] = current_text + if current_bullets: + slide["bulletPoints"] = current_bullets + if current_tables: + slide["tables"] = current_tables + slides.append(slide) + + return slides From 378d1735793bd829c1a5c124176f2ee5d8ecbd2d Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 14:04:57 +0800 Subject: [PATCH 21/21] feat(platform): replace PPTX generation with HTML slide presentations Instead of calling the crawler service to generate .pptx files, the pptx tool now accepts a complete HTML document from the LLM and stores it directly. This gives the AI full control over styling, layout, and animations (using reveal.js or any framework via CDN). - Add storeRawContent internal action for direct string-to-storage uploads - Remove sourceType param; tool now takes a single html param - Add explicit "no templates" instruction to prevent template hallucination --- .../convex/agent_tools/files/pptx_tool.ts | 54 ++++++++------- .../convex/documents/internal_actions.ts | 68 +++++++++++++++++++ 2 files changed, 97 insertions(+), 25 deletions(-) diff --git a/services/platform/convex/agent_tools/files/pptx_tool.ts b/services/platform/convex/agent_tools/files/pptx_tool.ts index 64e146c129..a4c48618c4 100644 --- a/services/platform/convex/agent_tools/files/pptx_tool.ts +++ b/services/platform/convex/agent_tools/files/pptx_tool.ts @@ -1,7 +1,8 @@ /** - * Convex Tool: PPTX + * Convex Tool: PPTX (Presentation) * - * Generate PPTX presentations from Markdown or HTML via the crawler service. + * Generate HTML slide presentations. The LLM produces the full HTML content + * (using reveal.js or any other approach) and this tool stores it as a file. */ import type { ToolCtx } from '@convex-dev/agent'; @@ -15,7 +16,7 @@ import { appendFilePart } from './helpers/append_file_part'; const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]'); -interface GeneratePptxResult { +interface GeneratePresentationResult { operation: 'generate'; success: boolean; fileStorageId: string; @@ -31,45 +32,49 @@ const pptxArgs = z.discriminatedUnion('operation', [ operation: z.literal('generate'), fileName: z .string() - .describe('Base name for the PPTX file (without extension)'), - sourceType: z.enum(['markdown', 'html']).describe('Source content type'), - content: z + .describe('Base name for the presentation file (without extension)'), + html: z .string() - .describe('The Markdown or HTML content to convert to PPTX'), + .describe( + 'Complete HTML document for the presentation. Must be a self-contained HTML file that can be opened directly in a browser.', + ), }), ]); export const pptxTool: ToolDefinition = { name: 'pptx', tool: createTool({ - description: `PowerPoint (PPTX) tool for generating presentations from Markdown or HTML content. + description: `Presentation tool for generating HTML slide decks. -IMPORTANT: Only call the "generate" operation when the user explicitly requests creating or exporting a PowerPoint/PPTX file. Do NOT proactively generate presentations unless the user specifically asks for this format. +IMPORTANT: Only call the "generate" operation when the user explicitly requests creating a presentation / slides / PPT. Do NOT proactively generate presentations unless the user specifically asks. -TO READ PPTX FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: +Do NOT mention templates — this tool does not use templates. Just generate the content directly. + +TO READ EXISTING PPTX FILE CONTENT: Do NOT use this tool. Instead use the rag_search tool: • To get the full content of a PPTX file: use rag_search with operation='retrieve' and the fileId • To search for specific information across PPTX files: use rag_search with operation='search' OPERATIONS: -1. generate - Generate a PPTX from Markdown or HTML +1. generate - Generate an HTML slide presentation Parameters: - - fileName: Base name for the PPTX (without extension) - - sourceType: "markdown" or "html" - - content: The Markdown or HTML content to convert + - fileName: Base name for the file (without extension) + - html: A complete, self-contained HTML document for the presentation. + Use reveal.js (loaded from CDN: https://cdn.jsdelivr.net/npm/reveal.js@5) as the slide framework. + You have full control over styling, layout, colors, animations, and themes. + The HTML must work when opened directly in a browser with no server needed. Returns: { success, fileStorageId, downloadUrl, fileName, contentType, size } -EXAMPLES: -• Generate from Markdown: { "operation": "generate", "fileName": "Report", "sourceType": "markdown", "content": "# Slide 1\\n\\nBullet points here..." } -• Generate from HTML: { "operation": "generate", "fileName": "Report", "sourceType": "html", "content": "

    Slide 1

    • Item
    " } - AFTER GENERATING: Check the downloadUrl in the result: - If it says "[file card shown in chat]": the file is already visible as a download card. Do NOT mention downloading, do NOT include a link, and do NOT say "you can download it" — the card handles this. - If it contains an actual URL: no download card was shown. You MUST include the URL as a clickable markdown link so the user can download the file. To also save the file to a folder in the documents hub, call document_write with the returned fileStorageId and the desired folderPath. `, inputSchema: pptxArgs, - execute: async (ctx: ToolCtx, args): Promise => { + execute: async ( + ctx: ToolCtx, + args, + ): Promise => { const { organizationId } = ctx; if (!organizationId) { throw new Error( @@ -79,18 +84,17 @@ To also save the file to a folder in the documents hub, call document_write with debugLog('tool:pptx generate start', { fileName: args.fileName, - sourceType: args.sourceType, }); try { const result = await ctx.runAction( - internal.documents.internal_actions.generateDocument, + internal.documents.internal_actions.storeRawContent, { organizationId, fileName: args.fileName, - sourceType: args.sourceType, - outputFormat: 'pptx', - content: args.content, + content: args.html, + contentType: 'text/html', + extension: 'html', }, ); @@ -112,7 +116,7 @@ To also save the file to a folder in the documents hub, call document_write with downloadUrl: cardAppended ? '[file card shown in chat]' : result.downloadUrl, - } as GeneratePptxResult; + } as GeneratePresentationResult; } catch (error) { console.error('[tool:pptx generate] error', { fileName: args.fileName, diff --git a/services/platform/convex/documents/internal_actions.ts b/services/platform/convex/documents/internal_actions.ts index a0ae09ef14..6399f84858 100644 --- a/services/platform/convex/documents/internal_actions.ts +++ b/services/platform/convex/documents/internal_actions.ts @@ -3,6 +3,7 @@ import { v } from 'convex/values'; import { extractExtension } from '../../lib/shared/file-types'; +import { fetchJson } from '../../lib/utils/type-cast-helpers'; import { isRecord, getBoolean, @@ -10,7 +11,9 @@ import { getString, } from '../../lib/utils/type-guards'; import { internal } from '../_generated/api'; +import type { Id } from '../_generated/dataModel'; import { internalAction } from '../_generated/server'; +import { buildDownloadUrl } from '../lib/helpers/public_storage_url'; import { getRagConfig } from '../lib/helpers/rag_config'; import { ragAction } from '../workflow_engine/action_defs/rag/rag_action'; import { getCrawlerUrl } from './generate_document_helpers'; @@ -631,6 +634,71 @@ export const reindexDocumentInRag = internalAction({ }, }); +/** + * Store raw string content (e.g. HTML) directly as a file in Convex storage. + * Used by tools that generate content locally without the crawler service. + */ +export const storeRawContent = internalAction({ + args: { + organizationId: v.string(), + fileName: v.string(), + content: v.string(), + contentType: v.string(), + extension: v.string(), + }, + handler: async (ctx, args): Promise => { + const bytes = new TextEncoder().encode(args.content); + const size = bytes.byteLength; + + const uploadUrl = await ctx.storage.generateUploadUrl(); + const uploadResponse = await fetch(uploadUrl, { + method: 'POST', + headers: { 'Content-Type': args.contentType }, + body: bytes, + }); + + if (!uploadResponse.ok) { + throw new Error( + `Failed to upload content: ${uploadResponse.status} ${uploadResponse.statusText}`, + ); + } + + const { storageId } = await fetchJson<{ storageId: Id<'_storage'> }>( + uploadResponse, + ); + + const lowerFileName = args.fileName.toLowerCase(); + const expectedSuffix = `.${args.extension.toLowerCase()}`; + const finalFileName = lowerFileName.endsWith(expectedSuffix) + ? args.fileName + : `${args.fileName}.${args.extension}`; + + await ctx.runMutation( + internal.file_metadata.internal_mutations.saveFileMetadata, + { + organizationId: args.organizationId, + storageId, + fileName: finalFileName, + contentType: args.contentType, + size, + source: 'agent', + }, + ); + + const downloadUrl = buildDownloadUrl(storageId, finalFileName); + + return { + success: true, + fileStorageId: storageId, + downloadUrl, + fileName: finalFileName, + contentType: args.contentType, + size, + extension: args.extension, + }; + }, +}); + const EXTRACT_DATES_SUPPORTED_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']); const EXTRACT_DATES_RETRY_DELAYS = [30_000, 60_000, 120_000];