"use strict"; /* * originalData配下のExcel(顧客相談履歴など)をシート単位でCSVへ分割し、 * LINE WORKS用とは別のAnythingLLMワークスペースにRAG登録するスクリプト。 * * 使い方: * node csv-anythingllm.js convert … originalData/ 内の全.xlsxをシート別CSV+設定雛形に変換 * (変換完了後、そのExcelはoriginalData/complete/へ移動) * node csv-anythingllm.js sync <シート名> … 指定シートのみ同期 * node csv-anythingllm.js sync-all … csv-list.csv でflag=1のシートのみ順番に同期 * * csv-list.csv (このスクリプトと同じフォルダに配置、sync-all実行には必須): * sheetName,flag の形式。flag列が "1" の行だけが sync-all の対象になる。 * このファイルが無い、またはflag=1の行が1つも無い場合、sync-allは何も同期せず終了する * (lineworks-anythingllm.jsのboard-list.csvと異なり、全件同期へのフォールバックはしない)。 * convert実行時に新しく見つかったシートは自動で flag=0 として追記登録される * (既存行のflag値は上書きされない)。同期したいシートは手動でflagを1に変更すること。 * * 環境変数: * ANYTHINGLLM_BASE_URL / ANYTHINGLLM_API_KEY … lineworks-anythingllm.js と共通 * EMBEDDING_BATCH_SIZE … 既定 50 * ORIGINAL_DATA_DIR … 既定 ./originalData * CSV_ROOT_DIR … 既定 ./csv * CSV_CONFIG_DIR … 既定 ./csv-config * CSV_CHUNK_MAX_LENGTH … 既定 2000 (相談履歴のチャンク分割の目安文字数) * CSV_LOG_DIR … 既定 ./logs (実行ログの保存先) * * 実行ログ: logs/csv-anythingllm_<コマンド>_<日時>.log に、コンソール出力を * 日時([YYYY-MM-DD HH:mm:ss])・レベル(INFO/WARN/ERROR)付きで実行毎に保存する。 * * シート毎の設定ファイル csv-config/<シート名>.json: * { "csvDir": "csv/<シート名>", "workspaceSlug": "アップロード先ワークスペースのslug" } * workspaceSlug は convert 実行後に手動で入力する。 * 指定した名前/slugのワークスペースがAnythingLLM側に存在しない場合は、 * sync実行時にその名前で新規ワークスペースを自動作成し、実際に採番されたslugを * この設定ファイルに書き戻す。 * * CSVの列構成は位置ベースで読む(1列目=顧客名, 2列目=日付, 3列目=相談履歴)。 * 相談履歴は「名前:コメント」形式の発言が連続する形式で、2000文字を超える場合は * 発言(名前)の境界で分割する(発言の途中では切らない。1発言が2000文字を超える場合は * そのまま1チャンクとする)。 * * 重複防止: 顧客名+日付+相談履歴の全文からSHA-256ハッシュを生成し、 * csv-config/<シート名>.uploaded-hashes.json に記録済みの行はスキップする。 * originalDataのExcelには過去分すべてのログが毎回まるごと含まれる想定のため、 * 日次実行しても既にアップロード済みの行が重複登録されないようにするための仕組み。 * * 取り込み完了ファイルの退避: 処理が完了したファイルは、それぞれ元のディレクトリ配下の * complete/ サブフォルダへ移動し、次回実行時に対象外(再変換・再アップロード対象外)とする。 * - originalData配下のExcel → convertで全シート変換後、originalData/complete/ * - csv/<シート名>配下のCSV → syncで全行処理後、csv/<シート名>/complete/ * - csv_chunks_md/<シート名>配下のMD → syncでアップロード成功後、csv_chunks_md/<シート名>/complete/ * 同名ファイルが既にcomplete側にある場合は上書きせず、タイムスタンプを付けて退避する。 */ const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const XLSX = require("xlsx"); const { uploadFile, addToWorkspaceEmbeddings, ensureWorkspaceExists } = require("./anythingllm-client"); const { ANYTHINGLLM_BASE_URL, ANYTHINGLLM_API_KEY } = process.env; const EMBEDDING_BATCH_SIZE = Number(process.env.EMBEDDING_BATCH_SIZE || 50); const ORIGINAL_DATA_DIR = process.env.ORIGINAL_DATA_DIR ? path.resolve(process.env.ORIGINAL_DATA_DIR) : path.join(__dirname, "originalData"); const ORIGINAL_DATA_COMPLETE_DIR = path.join(ORIGINAL_DATA_DIR, "complete"); const CSV_ROOT_DIR = process.env.CSV_ROOT_DIR ? path.resolve(process.env.CSV_ROOT_DIR) : path.join(__dirname, "csv"); const CSV_CONFIG_DIR = process.env.CSV_CONFIG_DIR ? path.resolve(process.env.CSV_CONFIG_DIR) : path.join(__dirname, "csv-config"); const CSV_CHUNK_DIR = path.join(__dirname, "csv_chunks_md"); const CSV_CHUNK_MAX_LENGTH = Number(process.env.CSV_CHUNK_MAX_LENGTH || 2000); const LOG_DIR = process.env.CSV_LOG_DIR ? path.resolve(process.env.CSV_LOG_DIR) : path.join(__dirname, "logs"); // csv-list.csv (sheetName,flag) を読み込み、flag列が "1" の行だけを同期対象とする。 // lineworks-anythingllm.js の board-list.csv/loadTargetBoardsFromCsv と同じ流儀。 // このファイルが無い、またはflag=1の行が1つも無い場合は [] を返す(全件同期へのフォールバックはしない)。 const CSV_LIST_CSV_PATH = path.join(__dirname, "csv-list.csv"); function loadTargetSheetsFromCsv(csvPath = CSV_LIST_CSV_PATH) { if (!fs.existsSync(csvPath)) { return []; } const lines = fs .readFileSync(csvPath, "utf8") .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); // 先頭行は "sheetName,flag" のヘッダーなのでスキップ return lines .slice(1) .map((line) => line.split(",").map((s) => s.trim())) .filter(([, flag]) => flag === "1") .map(([sheetName]) => sheetName); } // ファイル名として安全な形に変換 (パス区切り文字等を除去) function sanitizeFileName(name) { return String(name || "file").replace(/[\\/:*?"<>|]/g, "_"); } function pad2(n) { return String(n).padStart(2, "0"); } function timestampForFileName(d = new Date()) { return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`; } function formatTimestamp(d = new Date()) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; } // console.log/warn/error の出力を、実行毎に日時付きでログファイルへも書き出す。 // 1行ずつ同期書き込みするため、途中で異常終了してもそこまでのログは残る。 function startLogging(commandLabel) { fs.mkdirSync(LOG_DIR, { recursive: true }); const logFilePath = path.join(LOG_DIR, `csv-anythingllm_${commandLabel || "run"}_${timestampForFileName()}.log`); const writeLine = (level, args) => { const message = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "); fs.appendFileSync(logFilePath, `[${formatTimestamp()}] [${level}] ${message}\n`, "utf8"); }; const original = { log: console.log.bind(console), warn: console.warn.bind(console), error: console.error.bind(console), }; console.log = (...args) => { original.log(...args); writeLine("INFO", args); }; console.warn = (...args) => { original.warn(...args); writeLine("WARN", args); }; console.error = (...args) => { original.error(...args); writeLine("ERROR", args); }; return logFilePath; } // 処理が完了したファイルを、そのファイルがあったディレクトリ配下の complete/ サブフォルダへ移動する。 // 同名ファイルが既にcomplete側にある場合は上書きせず、タイムスタンプを付けて退避する。 // (originalDataのExcel、csv/配下のCSV、csv_chunks_md/配下のチャンクMDファイルで共通利用) function moveFileToCompleteDir(srcPath, completeDir) { fs.mkdirSync(completeDir, { recursive: true }); const filename = path.basename(srcPath); const ext = path.extname(filename); const base = path.basename(filename, ext); let destPath = path.join(completeDir, filename); if (fs.existsSync(destPath)) { destPath = path.join(completeDir, `${base}_${timestampForFileName()}${ext}`); } fs.renameSync(srcPath, destPath); return destPath; } // 変換が完了したExcelファイルを originalData/complete/ へ移動する。 function moveToCompleteDir(xlsxFileName) { const destPath = moveFileToCompleteDir(path.join(ORIGINAL_DATA_DIR, xlsxFileName), ORIGINAL_DATA_COMPLETE_DIR); console.log(` 取り込み完了、completeフォルダへ移動しました: ${destPath}`); } function configPath(sheetName) { return path.join(CSV_CONFIG_DIR, `${sheetName}.json`); } function hashesPath(sheetName) { return path.join(CSV_CONFIG_DIR, `${sheetName}.uploaded-hashes.json`); } // ==================================================================== // Excel → シート別CSV変換 + 設定雛形の生成 // ==================================================================== // csv-list.csv に未登録のシート名を flag=0 で追記する (convert実行時の自動登録)。 // 既に登録済みの行(flagの値含む)は一切変更しない。手動でflagを立てた行を壊さないための措置。 function ensureSheetRegisteredInCsvList(sheetName, csvPath = CSV_LIST_CSV_PATH) { const header = "sheetName,flag"; let lines = []; if (fs.existsSync(csvPath)) { lines = fs .readFileSync(csvPath, "utf8") .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); } if (lines.length === 0) { lines = [header]; } const alreadyListed = lines.slice(1).some((line) => line.split(",")[0].trim() === sheetName); if (alreadyListed) { return; } lines.push(`${sheetName},0`); // board-list.csv と同じくBOM付きUTF-8・LF改行で出力 fs.writeFileSync(csvPath, "" + lines.join("\n") + "\n", "utf8"); console.log(` ${csvPath} に新規シートを登録しました(flag=0): ${sheetName}`); } async function convertCommand() { if (!fs.existsSync(ORIGINAL_DATA_DIR)) { throw new Error(`originalDataフォルダが見つかりません: ${ORIGINAL_DATA_DIR}`); } const xlsxFiles = fs.readdirSync(ORIGINAL_DATA_DIR).filter((f) => f.toLowerCase().endsWith(".xlsx")); if (xlsxFiles.length === 0) { console.log(`${ORIGINAL_DATA_DIR} に.xlsxファイルが見つかりませんでした。`); return; } for (const xlsxFileName of xlsxFiles) { const xlsxPath = path.join(ORIGINAL_DATA_DIR, xlsxFileName); const xlsxBaseName = path.basename(xlsxFileName, path.extname(xlsxFileName)); console.log(`変換中: ${xlsxFileName}`); const workbook = XLSX.readFile(xlsxPath); for (const sheetName of workbook.SheetNames) { const sheet = workbook.Sheets[sheetName]; const csvText = XLSX.utils.sheet_to_csv(sheet); const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }); const safeSheetName = sanitizeFileName(sheetName); const sheetCsvDir = path.join(CSV_ROOT_DIR, safeSheetName); fs.mkdirSync(sheetCsvDir, { recursive: true }); const csvFileName = `${safeSheetName}__${sanitizeFileName(xlsxBaseName)}.csv`; const csvPath = path.join(sheetCsvDir, csvFileName); // ExcelでUTF-8を正しく開けるようBOM付きで出力(lineworks-anythingllm.jsのlistCommandと同じ流儀) fs.writeFileSync(csvPath, "" + csvText, "utf8"); console.log(` シート「${sheetName}」: ${Math.max(rows.length - 1, 0)}行 → ${csvPath}`); if (rows.length > 0) { console.log(` 1行目(ヘッダー想定): ${JSON.stringify(rows[0])}`); } const cfgPath = configPath(safeSheetName); if (!fs.existsSync(cfgPath)) { fs.mkdirSync(CSV_CONFIG_DIR, { recursive: true }); const relativeCsvDir = path.relative(__dirname, sheetCsvDir).split(path.sep).join("/"); fs.writeFileSync( cfgPath, JSON.stringify({ csvDir: relativeCsvDir, workspaceSlug: "" }, null, 2), "utf8" ); console.log(` 設定ファイルを新規作成: ${cfgPath} (workspaceSlugを設定してください)`); } ensureSheetRegisteredInCsvList(safeSheetName); } try { moveToCompleteDir(xlsxFileName); } catch (moveErr) { console.warn(` [警告] ${xlsxFileName} のcompleteフォルダへの移動に失敗しました: ${moveErr.message}`); } } } // ==================================================================== // 相談履歴のチャンク分割 (発言=「名前:コメント」の境界でのみ区切る) // ==================================================================== // 行頭が「名前:」「名前:」の形式にマッチする行を新しい発言の開始とみなす。 // "https://..." のようなURLの先頭を誤って名前と判定しないよう除外する。 const TURN_START_PATTERN = /^([^::\r\n]{1,30})[::](?!\/\/)/; function parseTurns(historyText) { const lines = String(historyText || "").split(/\r\n|\r|\n/); const turns = []; for (const line of lines) { if (TURN_START_PATTERN.test(line) || turns.length === 0) { turns.push(line); } else { turns[turns.length - 1] += "\n" + line; } } return turns; } // 発言を順に貪欲へ積み上げ、次の発言を足すと上限を超える場合はチャンクを区切る。 // 1発言自体が上限を超える場合はそのまま単独チャンクとする(発言の途中では切らない)。 function splitIntoChunks(turns, maxLen) { const chunks = []; let current = ""; for (const turnText of turns) { if (current.length === 0) { current = turnText; } else if (current.length + 1 + turnText.length <= maxLen) { current += "\n" + turnText; } else { chunks.push(current); current = turnText; } } if (current.length > 0) { chunks.push(current); } return chunks; } function buildChunkMarkdown(sheetName, customerName, date, chunkText, partNumber, totalParts) { const lines = [ `# ${customerName || "(顧客名不明)"}`, "", `- 顧客名: ${customerName || ""}`, `- 日付: ${date || ""}`, `- シート: ${sheetName}`, ]; if (totalParts > 1) { lines.push(`- 分割: ${partNumber}/${totalParts}`); } lines.push("", chunkText); return lines.join("\n"); } // ==================================================================== // 重複防止 (顧客名+日付+相談履歴 のハッシュで既アップロード分をスキップ) // ==================================================================== function rowHash(customerName, date, history) { return crypto.createHash("sha256").update(`${customerName}|${date}|${history}`).digest("hex"); } function loadUploadedHashes(sheetName) { const p = hashesPath(sheetName); if (!fs.existsSync(p)) { return new Set(); } try { const arr = JSON.parse(fs.readFileSync(p, "utf8")); return new Set(Array.isArray(arr) ? arr : []); } catch { return new Set(); } } function saveUploadedHashes(sheetName, hashSet) { fs.mkdirSync(CSV_CONFIG_DIR, { recursive: true }); fs.writeFileSync(hashesPath(sheetName), JSON.stringify([...hashSet]), "utf8"); } // ==================================================================== // CSV → チャンク分割 → アップロード (シート単位) // ==================================================================== function loadSheetConfig(sheetName) { const cfgPath = configPath(sheetName); if (!fs.existsSync(cfgPath)) { throw new Error(`設定ファイルが見つかりません: ${cfgPath} (先に convert を実行してください)`); } return JSON.parse(fs.readFileSync(cfgPath, "utf8")); } function resolveCsvDir(csvDir) { return path.isAbsolute(csvDir) ? csvDir : path.join(__dirname, csvDir); } async function syncSheet(sheetName) { const config = loadSheetConfig(sheetName); if (!config.workspaceSlug) { throw new Error( `シート「${sheetName}」の workspaceSlug が未設定です。${configPath(sheetName)} を編集してください。` ); } const actualSlug = await ensureWorkspaceExists(config.workspaceSlug, { baseUrl: ANYTHINGLLM_BASE_URL, apiKey: ANYTHINGLLM_API_KEY, }); if (actualSlug !== config.workspaceSlug) { config.workspaceSlug = actualSlug; fs.writeFileSync(configPath(sheetName), JSON.stringify(config, null, 2), "utf8"); console.log(` 設定ファイルのworkspaceSlugを実際のslugに更新しました: ${actualSlug}`); } const csvDir = resolveCsvDir(config.csvDir); if (!fs.existsSync(csvDir)) { throw new Error(`CSVフォルダが見つかりません: ${csvDir}`); } const csvFiles = fs.readdirSync(csvDir).filter((f) => f.toLowerCase().endsWith(".csv")); if (csvFiles.length === 0) { console.log(`シート「${sheetName}」: CSVファイルが見つかりませんでした (${csvDir})`); return; } const uploadedHashes = loadUploadedHashes(sheetName); const chunkDir = path.join(CSV_CHUNK_DIR, sanitizeFileName(sheetName)); const chunkCompleteDir = path.join(chunkDir, "complete"); const csvCompleteDir = path.join(csvDir, "complete"); fs.mkdirSync(chunkDir, { recursive: true }); let newCount = 0; let skippedCount = 0; const locations = []; for (const csvFileName of csvFiles) { const csvPath = path.join(csvDir, csvFileName); const workbook = XLSX.readFile(csvPath); const sheet = workbook.Sheets[workbook.SheetNames[0]]; const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }); for (let i = 1; i < rows.length; i++) { const row = rows[i] || []; const customerName = String(row[0] ?? "").trim(); const date = String(row[1] ?? "").trim(); const history = String(row[2] ?? "").trim(); if (!customerName && !history) { continue; // 空行はスキップ } const hash = rowHash(customerName, date, history); if (uploadedHashes.has(hash)) { skippedCount++; continue; } const chunks = splitIntoChunks(parseTurns(history), CSV_CHUNK_MAX_LENGTH); const rowLocations = []; for (let partIndex = 0; partIndex < chunks.length; partIndex++) { const partNumber = partIndex + 1; const markdown = buildChunkMarkdown( sheetName, customerName, date, chunks[partIndex], partNumber, chunks.length ); const filename = `${sanitizeFileName(csvFileName.replace(/\.csv$/i, ""))}_row${i}_${sanitizeFileName( customerName || "unknown" )}_part${partNumber}.md`; const filepath = path.join(chunkDir, filename); fs.writeFileSync(filepath, markdown, "utf8"); const location = await uploadFile(filepath, { baseUrl: ANYTHINGLLM_BASE_URL, apiKey: ANYTHINGLLM_API_KEY, mimeType: "text/markdown", }); if (location) { rowLocations.push(location); moveFileToCompleteDir(filepath, chunkCompleteDir); } } locations.push(...rowLocations); uploadedHashes.add(hash); saveUploadedHashes(sheetName, uploadedHashes); newCount++; } // このCSVの全行を処理し終えた(新規アップロード or 重複スキップ)ので、 // 元のCSVファイルもcomplete/へ移動して次回convert/syncの対象から外す。 moveFileToCompleteDir(csvPath, csvCompleteDir); } console.log(`シート「${sheetName}」: 新規アップロード ${newCount}件 / スキップ(重複) ${skippedCount}件`); if (locations.length > 0) { await addToWorkspaceEmbeddings(locations, { baseUrl: ANYTHINGLLM_BASE_URL, apiKey: ANYTHINGLLM_API_KEY, workspaceSlug: config.workspaceSlug, batchSize: EMBEDDING_BATCH_SIZE, }); } else { console.log(" 埋め込み対象の新規ドキュメントはありませんでした。"); } } async function syncAllCommand() { if (!fs.existsSync(CSV_CONFIG_DIR)) { console.log(`設定フォルダが見つかりません: ${CSV_CONFIG_DIR} (先に convert を実行してください)`); return; } const targetSheetNames = loadTargetSheetsFromCsv(); if (targetSheetNames.length === 0) { console.log( `${CSV_LIST_CSV_PATH} に同期対象(flag=1)のシートが見つからないため、何も同期せず終了します。` + `(csv-list.csv の対象行の flag を 1 にしてください)` ); return; } console.log(`${CSV_LIST_CSV_PATH} から対象シートを読み込みました: ${targetSheetNames.length} 件`); targetSheetNames.forEach((name) => console.log(` - ${name}`)); for (const sheetName of targetSheetNames) { console.log(`=== シート「${sheetName}」を同期します ===`); try { await syncSheet(sheetName); } catch (err) { console.error(`シート「${sheetName}」の同期に失敗: ${err.message}`); } } } // ==================================================================== // コマンド // ==================================================================== async function main() { const [, , command, ...rest] = process.argv; const logFilePath = startLogging(command || "run"); console.log(`ログファイル: ${logFilePath}`); if (command === "convert") { await convertCommand(); } else if (command === "sync") { const sheetName = rest[0]; if (!sheetName) { console.error("使い方: node csv-anythingllm.js sync <シート名>"); process.exitCode = 1; return; } await syncSheet(sheetName); } else if (command === "sync-all") { await syncAllCommand(); } else { console.log("使い方: node csv-anythingllm.js [convert|sync <シート名>|sync-all]"); } } main().catch((err) => { console.error("処理に失敗しました。"); console.error(err.message); process.exitCode = 1; });