// knowledge.ts — DKS (Document Knowledge Service) client tools import { readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'fs'; import { join } from 'path'; import { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult } from './core.js'; import { logger } from '../../logger.js'; import { generateRawFilename } from './raw-save.js'; // --- Config access --- interface KnowledgeNamespaceConfig { apiKey: string; } function getServiceUrl(ctx: ToolContext): string | null { return ctx.toolsConfig?.knowledgeServiceUrl ?? null; } function getNamespaces(ctx: ToolContext): Record | null { return ctx.toolsConfig?.knowledgeNamespaces ?? null; } function getApiKey(ctx: ToolContext, namespace: string): string | null { return ctx.toolsConfig?.knowledgeNamespaces?.[namespace]?.apiKey ?? null; } // --- Fetch helper --- async function dksFetch( serviceUrl: string, path: string, apiKey: string, options: RequestInit & { timeoutMs?: number } = {}, ): Promise { const url = `${serviceUrl.replace(/\/+$/, '')}${path}`; const timeoutMs = options.timeoutMs ?? 10000; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const { timeoutMs: _, ...fetchOptions } = options; const headers: Record = { 'Authorization': `Bearer ${apiKey}`, ...(fetchOptions.headers as Record || {}), }; return await fetch(url, { ...fetchOptions, headers, signal: controller.signal, }); } finally { clearTimeout(timer); } } // --- History logging --- interface KnowledgeHistoryRecord { timestamp: string; tool: string; namespace?: string; query?: string; sectionsFound?: number; imagesDownloaded?: number; error?: string; durationMs?: number; [key: string]: unknown; } function appendKnowledgeHistory(ctx: ToolContext, record: KnowledgeHistoryRecord): void { try { const logsDir = join(ctx.workspacePath, 'logs'); const historyPath = join(logsDir, 'knowledge-history.jsonl'); mkdirSync(logsDir, { recursive: true }); appendFileSync(historyPath, `${JSON.stringify(record)}\n`, 'utf-8'); } catch (e) { logger.warn(`[knowledge] failed to write history: ${(e as Error).message}`); } } // --- Raw response save helper --- function saveRawResponse(ctx: ToolContext, toolName: string, data: unknown): void { try { const rawDir = join(ctx.workspacePath, 'logs', 'raw'); mkdirSync(rawDir, { recursive: true }); const filename = generateRawFilename(toolName, '.json'); writeFileSync(join(rawDir, filename), JSON.stringify(data, null, 2), 'utf-8'); const indexPath = join(ctx.workspacePath, 'logs', 'rawdata-history.jsonl'); const content = JSON.stringify(data); appendFileSync(indexPath, JSON.stringify({ timestamp: new Date().toISOString(), tool: toolName, filename, bytes: Buffer.byteLength(content, 'utf-8'), }) + '\n', 'utf-8'); } catch (e) { logger.warn(`[knowledge] failed to save raw response: ${(e as Error).message}`); } } // --- Page image download helper --- async function downloadPageImages( serviceUrl: string, apiKey: string, imageUrls: string[], ctx: ToolContext, namespace: string, ): Promise<{ localPaths: string[]; errors: string[] }> { const localPaths: string[] = []; const errors: string[] = []; if (imageUrls.length === 0) return { localPaths, errors }; const saveDir = join(ctx.workspacePath, 'input', 'knowledge', namespace); mkdirSync(saveDir, { recursive: true }); for (const relUrl of imageUrls) { const fullUrl = `${serviceUrl.replace(/\/+$/, '')}${relUrl}`; try { const resp = await dksFetch(serviceUrl, relUrl, apiKey, { timeoutMs: 15000 }); if (!resp.ok) { errors.push(`${relUrl}: HTTP ${resp.status}`); continue; } const buffer = Buffer.from(await resp.arrayBuffer()); // Extract filename from URL path (e.g. /pages/abc123.png -> abc123.png) const urlPath = relUrl.split('/').pop() || `page-${Date.now()}.png`; const localPath = join(saveDir, urlPath); writeFileSync(localPath, buffer); localPaths.push(`input/knowledge/${namespace}/${urlPath}`); } catch (e) { const msg = (e as Error).name === 'AbortError' ? 'timeout' : (e as Error).message; errors.push(`${fullUrl}: ${msg}`); } } return { localPaths, errors }; } // --- Tool Definitions --- const INGEST_DOCUMENT_DEF: ToolDef = { type: 'function', function: { name: 'IngestDocument', description: 'ドキュメントをナレッジベースに取り込む(非同期)。PDF, Word, PowerPoint, Excel, 画像, CSV/TSV に対応。', parameters: { type: 'object', properties: { namespace: { type: 'string', description: '対象ネームスペース' }, file_path: { type: 'string', description: 'ワークスペース内のファイルパス' }, }, required: ['namespace', 'file_path'], }, }, }; const INGEST_STATUS_DEF: ToolDef = { type: 'function', function: { name: 'IngestStatus', description: '取込ジョブの進捗状況を確認する。', parameters: { type: 'object', properties: { namespace: { type: 'string', description: '対象ネームスペース' }, job_id: { type: 'string', description: 'IngestDocument で返却されたジョブID' }, }, required: ['namespace', 'job_id'], }, }, }; const SEARCH_KNOWLEDGE_DEF: ToolDef = { type: 'function', function: { name: 'SearchKnowledge', description: 'DKS(社内ナレッジ)を自然言語で検索する。関連セクション(テキスト)+ ページ画像が返り、画像は input/knowledge/{ns}/ に自動保存され ReadImage で閲覧可能。詳細は ReadToolDoc({ name: "SearchKnowledge" })。', parameters: { type: 'object', properties: { namespace: { type: 'string', description: '検索対象ネームスペース' }, query: { type: 'string', description: '検索クエリ(自然言語)' }, }, required: ['namespace', 'query'], }, }, }; const LIST_NAMESPACES_DEF: ToolDef = { type: 'function', function: { name: 'ListNamespaces', description: '利用可能なナレッジベースのネームスペース一覧を表示する。', parameters: { type: 'object', properties: {}, required: [], }, }, }; const LIST_DOCUMENTS_DEF: ToolDef = { type: 'function', function: { name: 'ListDocuments', description: 'ネームスペース内の文書一覧を表示する。', parameters: { type: 'object', properties: { namespace: { type: 'string', description: '対象ネームスペース' }, }, required: ['namespace'], }, }, }; export const TOOL_DEFS: Record = { IngestDocument: INGEST_DOCUMENT_DEF, IngestStatus: INGEST_STATUS_DEF, SearchKnowledge: SEARCH_KNOWLEDGE_DEF, ListNamespaces: LIST_NAMESPACES_DEF, ListDocuments: LIST_DOCUMENTS_DEF, }; // --- Tool Execution --- export async function executeTool( name: string, input: Record, ctx: ToolContext, ): Promise { if (name === 'IngestDocument') return executeIngestDocument(input, ctx); if (name === 'IngestStatus') return executeIngestStatus(input, ctx); if (name === 'SearchKnowledge') return executeSearchKnowledge(input, ctx); if (name === 'ListNamespaces') return executeListNamespaces(ctx); if (name === 'ListDocuments') return executeListDocuments(input, ctx); return null; } // --- Tool Implementations --- async function executeIngestDocument(input: Record, ctx: ToolContext): Promise { const serviceUrl = getServiceUrl(ctx); if (!serviceUrl) return { output: 'Knowledge service not configured', isError: true }; const namespace = input.namespace as string; const filePath = input.file_path as string; const apiKey = getApiKey(ctx, namespace); if (!apiKey) return { output: `Namespace "${namespace}" not configured`, isError: true }; const startMs = Date.now(); try { const resolvedPath = filePath.startsWith('/') ? filePath : `${ctx.workspacePath}/${filePath}`; const fileData = readFileSync(resolvedPath); const fileName = resolvedPath.split('/').pop() || 'unknown'; const formData = new FormData(); formData.append('file', new Blob([fileData]), fileName); const resp = await dksFetch(serviceUrl, `/namespaces/${namespace}/ingest`, apiKey, { method: 'POST', body: formData, }); if (!resp.ok) { const errText = await resp.text(); const output = `Ingest failed (${resp.status}): ${errText}`; appendKnowledgeHistory(ctx, { timestamp: new Date().toISOString(), tool: 'IngestDocument', namespace, error: output, durationMs: Date.now() - startMs, }); return { output, isError: true }; } const data = await resp.json() as any; const output = `取込を開始しました (job: ${data.job_id}, ${data.pages_detected}ページ検出)。完了確認は IngestStatus で可能です。他の作業を続行できます。`; appendKnowledgeHistory(ctx, { timestamp: new Date().toISOString(), tool: 'IngestDocument', namespace, fileName, jobId: data.job_id, pagesDetected: data.pages_detected, durationMs: Date.now() - startMs, }); return { output, isError: false }; } catch (e: any) { const error = e.name === 'AbortError' ? `IngestDocument timeout: DKS server did not respond within 10s` : `IngestDocument error: ${e.message}`; appendKnowledgeHistory(ctx, { timestamp: new Date().toISOString(), tool: 'IngestDocument', namespace, error, durationMs: Date.now() - startMs, }); return { output: error, isError: true }; } } async function executeIngestStatus(input: Record, ctx: ToolContext): Promise { const serviceUrl = getServiceUrl(ctx); if (!serviceUrl) return { output: 'Knowledge service not configured', isError: true }; const namespace = input.namespace as string; const jobId = input.job_id as string; const apiKey = getApiKey(ctx, namespace); if (!apiKey) return { output: `Namespace "${namespace}" not configured`, isError: true }; try { const resp = await dksFetch(serviceUrl, `/namespaces/${namespace}/jobs/${jobId}`, apiKey); if (!resp.ok) { return { output: `Job not found (${resp.status})`, isError: true }; } const data = await resp.json() as any; const p = data.progress || {}; const statusLine = data.status === 'completed' ? `完了 (${data.document_name})` : data.status === 'failed' ? `失敗: ${data.error || 'unknown'}` : `処理中: VLM ${p.vlm_completed || 0}/${p.total_pages || 0}ページ, ツリー構築: ${p.tree_built ? '完了' : '未完了'}`; return { output: `ジョブ ${data.job_id}: ${statusLine}`, isError: false }; } catch (e: any) { if (e.name === 'AbortError') { return { output: `IngestStatus timeout: DKS server did not respond within 10s`, isError: true }; } return { output: `IngestStatus error: ${e.message}`, isError: true }; } } async function executeSearchKnowledge(input: Record, ctx: ToolContext): Promise { const serviceUrl = getServiceUrl(ctx); if (!serviceUrl) return { output: 'Knowledge service not configured', isError: true }; const namespace = input.namespace as string; const query = input.query as string; const apiKey = getApiKey(ctx, namespace); if (!apiKey) return { output: `Namespace "${namespace}" not configured`, isError: true }; const startMs = Date.now(); try { const resp = await dksFetch(serviceUrl, `/namespaces/${namespace}/search`, apiKey, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }), timeoutMs: 30000, }); if (!resp.ok) { const errText = await resp.text(); const output = `Search failed (${resp.status}): ${errText}`; appendKnowledgeHistory(ctx, { timestamp: new Date().toISOString(), tool: 'SearchKnowledge', namespace, query, error: output, durationMs: Date.now() - startMs, }); return { output, isError: true }; } const data = await resp.json() as any; // DKS 生レスポンスを logs/raw/ に保存 saveRawResponse(ctx, 'SearchKnowledge', { query, namespace, response: data }); const sections = data.sections || []; const rawImageUrls: string[] = data.page_image_urls || []; // Download page images to workspace so ReadImage can access them const { localPaths, errors: imgErrors } = await downloadPageImages( serviceUrl, apiKey, rawImageUrls, ctx, namespace, ); // Format response const lines: string[] = []; for (const section of sections) { lines.push(`## ${section.title} (${section.doc_name}, pages: ${section.pages.join(', ')})`); lines.push(section.content); lines.push(''); } if (localPaths.length > 0) { lines.push('### ページ画像(ReadImage で閲覧可能)'); for (const p of localPaths) { lines.push(`- ${p}`); } } if (imgErrors.length > 0) { lines.push(`\n[画像ダウンロードエラー: ${imgErrors.length}件]`); } if (data.system_status?.message) { lines.push(`\n[Info] ${data.system_status.message}`); } const output = lines.join('\n'); appendKnowledgeHistory(ctx, { timestamp: new Date().toISOString(), tool: 'SearchKnowledge', namespace, query, sectionsFound: sections.length, imagesDownloaded: localPaths.length, imageErrors: imgErrors.length, durationMs: Date.now() - startMs, }); return { output, isError: false }; } catch (e: any) { const error = e.name === 'AbortError' ? `SearchKnowledge timeout: DKS server did not respond within 30s` : `SearchKnowledge error: ${e.message}`; appendKnowledgeHistory(ctx, { timestamp: new Date().toISOString(), tool: 'SearchKnowledge', namespace, query, error, durationMs: Date.now() - startMs, }); return { output: error, isError: true }; } } async function executeListNamespaces(ctx: ToolContext): Promise { const serviceUrl = getServiceUrl(ctx); const namespaces = getNamespaces(ctx); if (!serviceUrl || !namespaces) { return { output: 'Knowledge service not configured', isError: true }; } const names = Object.keys(namespaces); if (names.length === 0) { return { output: '利用可能なネームスペースはありません', isError: false }; } appendKnowledgeHistory(ctx, { timestamp: new Date().toISOString(), tool: 'ListNamespaces', namespacesFound: names.length, }); return { output: `利用可能なネームスペース:\n${names.map(n => `- ${n}`).join('\n')}`, isError: false }; } async function executeListDocuments(input: Record, ctx: ToolContext): Promise { const serviceUrl = getServiceUrl(ctx); if (!serviceUrl) return { output: 'Knowledge service not configured', isError: true }; const namespace = input.namespace as string; const apiKey = getApiKey(ctx, namespace); if (!apiKey) return { output: `Namespace "${namespace}" not configured`, isError: true }; const startMs = Date.now(); try { const resp = await dksFetch(serviceUrl, `/namespaces/${namespace}/documents`, apiKey); if (!resp.ok) { return { output: `ListDocuments failed (${resp.status})`, isError: true }; } const data = await resp.json() as any; const docs = data.documents || []; if (docs.length === 0) { return { output: `"${namespace}" にはまだ文書がありません`, isError: false }; } const lines = docs.map((d: any) => `- ${d.name} (${d.page_count}ページ, id: ${d.id})`); const output = `"${namespace}" の文書一覧:\n${lines.join('\n')}`; appendKnowledgeHistory(ctx, { timestamp: new Date().toISOString(), tool: 'ListDocuments', namespace, documentsFound: docs.length, durationMs: Date.now() - startMs, }); return { output, isError: false }; } catch (e: any) { const error = e.name === 'AbortError' ? `ListDocuments timeout: DKS server did not respond within 10s` : `ListDocuments error: ${e.message}`; appendKnowledgeHistory(ctx, { timestamp: new Date().toISOString(), tool: 'ListDocuments', namespace, error, durationMs: Date.now() - startMs, }); return { output: error, isError: true }; } }