From 91ed19bda0ceb11e1747b3acd8a9376ca124bdf0 Mon Sep 17 00:00:00 2001 From: kim Date: Wed, 23 Sep 2026 16:32:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=91=EC=97=85=EC=8B=A4=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=EB=B3=84=20=EA=B0=9C=EC=9A=94=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20+=20readme.md=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workshop/<프로젝트id>/개요/readme.md — 대시보드 데이터(개요/예산/부품/작업/메모)에서 자동 생성, saveWorkshop과 대시보드 data GET에서 내용 변화 때만 동기화(기존 프로젝트 백필 포함) - WorkshopProject.description 신설 + workshop_project set_description 액션 — 채팅 모델이 서술형 개요를 쓰면 readme 맨 위 개요 섹션으로 들어감 - formatProject/budgetSummary/fmtWon을 storage로 이동(도구 get과 readme가 같은 문서 소스) - 파일탭에는 개요 폴더 그룹으로 자동 표시(walkCaseFiles) Co-Authored-By: Claude Code --- src/gateway/chat/build-tools.ts | 3 +- src/gateway/routes/routes-workshop.ts | 10 ++- src/gateway/routes/workshop-storage.ts | 76 +++++++++++++++++++++++ src/tools/workshop-project.ts | 54 +++++----------- tests/workshop-readme.test.ts | 85 ++++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 43 deletions(-) create mode 100644 tests/workshop-readme.test.ts diff --git a/src/gateway/chat/build-tools.ts b/src/gateway/chat/build-tools.ts index a22a5e4..3422bda 100644 --- a/src/gateway/chat/build-tools.ts +++ b/src/gateway/chat/build-tools.ts @@ -1162,8 +1162,9 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) { parameters: { type: 'object', required: ['action'], properties: { - action: { type: 'string', enum: ['list_projects', 'create_project', 'set_active_project', 'get', 'add_part', 'update_part', 'delete_part', 'add_task', 'set_task_done', 'add_phase', 'add_note', 'add_link', 'delete_link'], description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(예산 요약+부품+작업+메모). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제.' }, + action: { type: 'string', enum: ['list_projects', 'create_project', 'set_active_project', 'get', 'set_description', 'add_part', 'update_part', 'delete_part', 'add_task', 'set_task_done', 'add_phase', 'add_note', 'add_link', 'delete_link'], description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(개요+예산 요약+부품+작업+메모). set_description: 프로젝트 서술형 개요 설정(파일탭의 개요/readme.md에 반영). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제.' }, project_name: { type: 'string', description: '(create_project/set_active_project 필수, 그 외 모든 액션 선택) 대상 프로젝트 이름(일부만 일치해도 됨). 생략하면 현재 활성 프로젝트를 대상으로 함.' }, + description: { type: 'string', description: '(set_description 필수) 프로젝트 개요 문단 — 무엇을 만드는지/목표/구성 등을 자유 서술. 파일탭의 workshop/<프로젝트id>/개요/readme.md 맨 위 "## 개요" 섹션으로 들어간다.' }, part_name: { type: 'string', description: '(update_part/delete_part/add_link/delete_link 필수) 대상 부품 이름(일부만 일치해도 됨, 예: "메카넘휠")' }, name: { type: 'string', description: '(add_part 필수, update_part 선택) 부품 이름' }, qty: { type: 'number', description: '(add_part/update_part 선택) 수량. 기본 1' }, diff --git a/src/gateway/routes/routes-workshop.ts b/src/gateway/routes/routes-workshop.ts index e452928..443bec8 100644 --- a/src/gateway/routes/routes-workshop.ts +++ b/src/gateway/routes/routes-workshop.ts @@ -14,7 +14,7 @@ import express from 'express'; import fs from 'fs'; import path from 'path'; import { getUserWorkspace } from '../../config/config'; -import { loadWorkshop, saveWorkshop } from './workshop-storage'; +import { loadWorkshop, saveWorkshop, syncProjectReadmes } from './workshop-storage'; import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage'; const APP_TYPE = 'workshop'; @@ -40,7 +40,13 @@ export function registerWorkshopRoutes( app.get('/api/workshop/data', (req, res) => { const session = getSessionUser(req); if (!session) return res.status(401).json({ error: 'Unauthorized' }); - res.json(loadWorkshop(getUserWorkspace(session.username))); + const ws = getUserWorkspace(session.username); + const data = loadWorkshop(ws); + // 개요/readme.md 자동생성(09-23) — 편집 없이도 대시보드를 열면 기존 프로젝트의 + // readme가 채워지게(saveWorkshop 경로만으론 미수정 프로젝트가 밀림). 내용이 같으면 + // 쓰지 않으므로 폴링 비용은 파일 stat 몇 개다. + syncProjectReadmes(ws, data); + res.json(data); }); app.put('/api/workshop/data', (req, res) => { diff --git a/src/gateway/routes/workshop-storage.ts b/src/gateway/routes/workshop-storage.ts index a2b9833..2ecf859 100644 --- a/src/gateway/routes/workshop-storage.ts +++ b/src/gateway/routes/workshop-storage.ts @@ -39,6 +39,7 @@ export interface WorkshopPhase { export interface WorkshopProject { id: string; name: string; + description?: string; // 서술형 프로젝트 개요(set_description 액션). 개요/readme.md의 "## 개요" 섹션 parts: WorkshopPart[]; phases: WorkshopPhase[]; notes: string; @@ -150,6 +151,81 @@ export function saveWorkshop(workspaceRoot: string, data: WorkshopData): void { const tmp = `${p}.tmp`; fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8'); fs.renameSync(tmp, p); + syncProjectReadmes(workspaceRoot, data); +} + +// ── 프로젝트 readme(2026-09-23) ────────────────────────────────────────────── +// 대시보드 데이터를 그대로 마크다운으로 내보낸다 — workshop-project.ts의 formatProject를 +// 여기로 옮긴 것(순환 import 방지: 도구가 storage를 import하므로 반대 방향은 안 됨). +// 도구의 'get' 액션과 개요/readme.md 생성이 같은 문서를 쓴다. +export function fmtWon(n: number): string { + return `${Math.round(n).toLocaleString('ko-KR')}원`; +} + +export function budgetSummary(parts: WorkshopPart[]): string { + const sum = (status: string) => parts.filter(p => p.status === status).reduce((s, p) => s + p.qty * p.unitPrice, 0); + const review = sum('검토중'), ordered = sum('주문완료'), owned = sum('보유'); + return `검토중 ${fmtWon(review)} · 주문완료 ${fmtWon(ordered)} · 보유 ${fmtWon(owned)} · 총 예상비용 ${fmtWon(review + ordered + owned)}`; +} + +export function projectToReadme(project: WorkshopProject): string { + const lines: string[] = []; + lines.push(`# ${project.name}`); + const desc = String(project.description || '').trim(); + if (desc) { + lines.push(''); + lines.push('## 개요'); + lines.push(desc); + } + lines.push(''); + lines.push(`예산: ${budgetSummary(project.parts)}`); + lines.push(''); + lines.push('## 부품'); + for (const p of project.parts) { + lines.push(`- [${p.status}] ${p.name} × ${p.qty} = ${fmtWon(p.qty * p.unitPrice)}${p.memo ? ` (${p.memo})` : ''}`); + for (const link of p.links || []) { + lines.push(` - ${link.label}: ${link.url}`); + } + } + lines.push(''); + lines.push('## 작업'); + for (const ph of project.phases) { + lines.push(`### ${ph.name}`); + for (const t of ph.tasks) { + lines.push(`- [${t.done ? 'x' : ' '}] ${t.text}`); + } + } + if (project.notes.trim()) { + lines.push(''); + lines.push('## 메모'); + lines.push(project.notes); + } + lines.push(''); + lines.push('---'); + lines.push('*이 파일은 작업실 대시보드 데이터에서 자동 생성됨. 부품·작업·메모를 바꾸면 갱신되니 이 파일을 직접 고치지 말 것.*'); + return lines.join('\n'); +} + +// 프로젝트당 workshop/<프로젝트id>/개요/readme.md 를 유지한다 — 파일탭에서 프로젝트 +// 설명 문서가 항상 존재하게. 내용이 바뀌었을 때만 쓴다(전수 saveWorkshop 경로라 값싸게). +// 개별 프로젝트 실패(권한 등)는 나머지에 영향을 주지 않게 전부 삼킨다. +export function syncProjectReadmes(workspaceRoot: string, data: WorkshopData): void { + for (const project of data.projects) { + try { + const dir = path.join(workspaceRoot, 'workshop', project.id, '개요'); + const file = path.join(dir, 'readme.md'); + const md = projectToReadme(project); + try { + if (fs.readFileSync(file, 'utf-8') === md) continue; + } catch { + // 파일이 없으면 새로 쓴다 + } + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(file, md, 'utf-8'); + } catch { + // 파생 문서 생성 실패는 원본 데이터 저장을 막아선 안 된다 + } + } } export function genWorkshopId(prefix: string): string { diff --git a/src/tools/workshop-project.ts b/src/tools/workshop-project.ts index a604002..e29bdc1 100644 --- a/src/tools/workshop-project.ts +++ b/src/tools/workshop-project.ts @@ -3,6 +3,7 @@ import { getWorkspacePath } from '../config/paths.js'; import { loadWorkshop, saveWorkshop, genWorkshopId, WorkshopData, WorkshopProject, WorkshopPart, WorkshopPhase, + budgetSummary, projectToReadme, fmtWon, } from '../gateway/routes/workshop-storage.js'; // "작업실"(여러 메이커 프로젝트 관리 대시보드) 전용 채팅도구. 원래 로봇 프로젝트 하나만 @@ -13,43 +14,8 @@ import { const STATUSES = ['검토중', '주문완료', '보유'] as const; -function fmtWon(n: number): string { - return `${Math.round(n).toLocaleString('ko-KR')}원`; -} - -function budgetSummary(parts: WorkshopPart[]): string { - const sum = (status: string) => parts.filter(p => p.status === status).reduce((s, p) => s + p.qty * p.unitPrice, 0); - const review = sum('검토중'), ordered = sum('주문완료'), owned = sum('보유'); - return `검토중 ${fmtWon(review)} · 주문완료 ${fmtWon(ordered)} · 보유 ${fmtWon(owned)} · 총 예상비용 ${fmtWon(review + ordered + owned)}`; -} - -function formatProject(project: WorkshopProject): string { - const lines: string[] = []; - lines.push(`# ${project.name}`); - lines.push(`예산: ${budgetSummary(project.parts)}`); - lines.push(''); - lines.push('## 부품'); - for (const p of project.parts) { - lines.push(`- [${p.status}] ${p.name} × ${p.qty} = ${fmtWon(p.qty * p.unitPrice)}${p.memo ? ` (${p.memo})` : ''}`); - for (const link of p.links || []) { - lines.push(` - ${link.label}: ${link.url}`); - } - } - lines.push(''); - lines.push('## 작업'); - for (const ph of project.phases) { - lines.push(`### ${ph.name}`); - for (const t of ph.tasks) { - lines.push(`- [${t.done ? 'x' : ' '}] ${t.text}`); - } - } - if (project.notes.trim()) { - lines.push(''); - lines.push('## 메모'); - lines.push(project.notes); - } - return lines.join('\n'); -} +// fmtWon/budgetSummary/formatProject는 workshop-storage.ts로 옮겨졌다(2026-09-23) — +// 'get' 출력과 개요/readme.md 자동생성이 같은 projectToReadme을 쓰게 하기 위함. function ensureLinks(p: WorkshopPart): NonNullable { if (!p.links) p.links = []; @@ -115,10 +81,11 @@ export const workshopProjectTool = { properties: { action: { type: 'string', - enum: ['list_projects', 'create_project', 'set_active_project', 'get', 'add_part', 'update_part', 'delete_part', 'add_task', 'set_task_done', 'add_phase', 'add_note', 'add_link', 'delete_link'], - description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(예산 요약+부품+작업+메모). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제.', + enum: ['list_projects', 'create_project', 'set_active_project', 'get', 'set_description', 'add_part', 'update_part', 'delete_part', 'add_task', 'set_task_done', 'add_phase', 'add_note', 'add_link', 'delete_link'], + description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(개요+예산 요약+부품+작업+메모). set_description: 프로젝트 서술형 개요 설정(개요/readme.md에 반영). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제.', }, project_name: { type: 'string', description: '(create_project/set_active_project 필수, 그 외 모든 액션 선택) 대상 프로젝트 이름(일부만 일치해도 됨). 생략하면 현재 활성 프로젝트를 대상으로 함.' }, + description: { type: 'string', description: '(set_description 필수) 프로젝트 개요 문단 — 무엇을 만드는지/목표/구성 등을 자유 서술. 파일탭의 workshop/<프로젝트id>/개요/readme.md 맨 위 "## 개요" 섹션으로 들어간다.' }, part_name: { type: 'string', description: '(update_part/delete_part/add_link/delete_link 필수) 대상 부품 이름(일부만 일치해도 됨, 예: "메카넘휠")' }, name: { type: 'string', description: '(add_part 필수, update_part 선택) 부품 이름' }, qty: { type: 'number', description: '(add_part/update_part 선택) 수량. 기본 1' }, @@ -171,7 +138,14 @@ export const workshopProjectTool = { const project = resolved.project; if (action === 'get') { - return { success: true, stdout: formatProject(project) }; + return { success: true, stdout: projectToReadme(project) }; + } + + if (action === 'set_description') { + const desc = String(args?.description || '').trim(); + project.description = desc; + saveWorkshop(workspaceRoot, data); + return { success: true, stdout: `[${project.name}] 개요 ${desc ? '업데이트됨' : '비움'} — 파일탭의 개요/readme.md에 반영됨` }; } if (action === 'add_part') { diff --git a/tests/workshop-readme.test.ts b/tests/workshop-readme.test.ts new file mode 100644 index 0000000..94ec429 --- /dev/null +++ b/tests/workshop-readme.test.ts @@ -0,0 +1,85 @@ +// workshop-storage.ts 개요/readme.md 자동생성 단위 테스트 (2026-09-23) +// 네트워크/게이트웨이 없음 — 순수 문서 생성 + 임시 디렉토리 파일 동기화만 검증. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { projectToReadme, syncProjectReadmes, WorkshopData, WorkshopProject } from '../src/gateway/routes/workshop-storage.js'; + +function sampleProject(over: Partial = {}): WorkshopProject { + return { + id: 'proj_test', + name: 'SO-101 로봇팔', + parts: [ + { id: 'p1', name: 'STS3215 서보', qty: 12, unitPrice: 20000, status: '검토중', memo: 'Leader 6 + Follower 6', links: [{ id: 'lk1', label: '구매처', url: 'https://example.com' }] }, + { id: 'p2', name: '나사 세트', qty: 1, unitPrice: 0, status: '보유', memo: '', links: [] }, + ], + phases: [ + { id: 'ph1', name: '준비', tasks: [{ id: 't1', text: 'STL 다운로드', done: true }, { id: 't2', text: '출력', done: false }] }, + ], + notes: 'K2로 출력한다', + ...over, + }; +} + +test('projectToReadme: 개요 섹션은 description이 있을 때만', () => { + const withDesc = projectToReadme(sampleProject({ description: 'LeRobot SO-101 오픈소스 로봇팔 조립 프로젝트' })); + assert.ok(withDesc.includes('# SO-101 로봇팔')); + assert.ok(withDesc.includes('## 개요\nLeRobot SO-101 오픈소스 로봇팔 조립 프로젝트')); + + const withoutDesc = projectToReadme(sampleProject()); + assert.ok(!withoutDesc.includes('## 개요')); + // 부품/작업/메모는 항상 실린다 + assert.ok(withoutDesc.includes('- [검토중] STS3215 서보 × 12 = 240,000원')); + assert.ok(withoutDesc.includes('- [x] STL 다운로드')); + assert.ok(withoutDesc.includes('## 메모')); + assert.ok(withoutDesc.includes('자동 생성됨')); // 직접수정 방지 각주 +}); + +test('projectToReadme: 부품 링크를 들여쓰기로 실음', () => { + const md = projectToReadme(sampleProject()); + assert.ok(md.includes(' - 구매처: https://example.com')); +}); + +test('syncProjectReadmes: readme 생성 + 내용 변화 때만 재기록', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'workshop-readme-')); + try { + const data: WorkshopData = { projects: [sampleProject()], activeProjectId: 'proj_test' }; + syncProjectReadmes(tmp, data); + const file = path.join(tmp, 'workshop', 'proj_test', '개요', 'readme.md'); + assert.ok(fs.existsSync(file)); + const first = fs.readFileSync(file, 'utf-8'); + + // 같은 내용이면 mtime 변화 없음(다시 쓰지 않음) + const mtime1 = fs.statSync(file).mtimeMs; + syncProjectReadmes(tmp, data); + assert.equal(fs.statSync(file).mtimeMs, mtime1); + + // 데이터를 바꾸면 갱신 + data.projects[0].description = '개요 문단'; + syncProjectReadmes(tmp, data); + assert.ok(fs.readFileSync(file, 'utf-8').includes('## 개요\n개요 문단')); + assert.notEqual(fs.statSync(file).mtimeMs, mtime1); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('syncProjectReadmes: 여러 프로젝트 동시 갱신, 개별 실패가 전체를 막지 않음', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'workshop-readme-')); + try { + const data: WorkshopData = { + projects: [sampleProject(), sampleProject({ id: 'proj_second', name: '치과 스캐너' })], + activeProjectId: 'proj_test', + }; + // 두 번째 프로젝트 경로를 파일로 점거해 쓰기 실패 상황을 만든다 — 첫 프로젝트는 여전히 쓰여야 함 + const blocked = path.join(tmp, 'workshop', 'proj_second'); + fs.mkdirSync(blocked, { recursive: true }); + fs.writeFileSync(path.join(blocked, '개요'), 'blocker'); + syncProjectReadmes(tmp, data); + assert.ok(fs.existsSync(path.join(tmp, 'workshop', 'proj_test', '개요', 'readme.md'))); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); \ No newline at end of file