From e719829f0be73e9aef46e511ddec9443b0b369d3 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 23 Jun 2026 15:10:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EC=9D=98=20AI=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B0=B0=EC=A7=80=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 추천이 문서 내용을 반영하지 못해도(추출 실패·미지원 형식) 추천은 정상 응답되어 사용자가 인지할 수 없던 문제를 해소한다. 문서 목록에 반영 상태 배지를 노출한다. - ProjectAttachmentResponse.aiStatus 추가(ready/failed/unsupported) · ready: 추출 성공(반영 가능) · failed: 지원 형식이나 추출 실패 · unsupported: 미지원 형식(이미지·xlsx·ppt·zip 등) - isExtractableType + deriveAiStatus 로 상태 산출 (추출 본문은 응답에 노출하지 않고 상태 판별에만 사용) - listFiles 에서 extracted_text 를 명시적으로 선택(select:false 우회) - 공용 AttachmentAiBadge 컴포넌트 추가 → 프로젝트 상세·설정 문서 목록에 적용 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/modules/project/extract-text.ts | 20 ++++++ .../src/modules/project/project.service.ts | 32 +++++++-- .../components/common/AttachmentAiBadge.vue | 72 +++++++++++++++++++ .../src/pages/relay/ProjectDetailPage.vue | 2 + .../src/pages/relay/ProjectSettingsPage.vue | 2 + frontend/src/types/project.ts | 2 + 6 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/common/AttachmentAiBadge.vue diff --git a/backend/src/modules/project/extract-text.ts b/backend/src/modules/project/extract-text.ts index 96899cc..f0093c7 100644 --- a/backend/src/modules/project/extract-text.ts +++ b/backend/src/modules/project/extract-text.ts @@ -5,6 +5,26 @@ import { extractRawText } from 'mammoth'; // 문서 1건당 추출 텍스트 상한(자) — DB 용량과 AI 토큰 사용량을 함께 보호한다. export const PER_DOC_TEXT_LIMIT = 8000; +// 문서의 AI 반영 상태 — 목록 배지 표기에 사용 +// ready: 추출 성공(반영 가능) / failed: 지원 형식이나 추출 실패 / unsupported: 미지원 형식 +export type AttachmentAiStatus = 'ready' | 'failed' | 'unsupported'; + +// 텍스트 추출 대상 형식인지 여부(txt·csv·pdf·docx). 미지원/추출실패 배지 구분에 사용한다. +export function isExtractableType(mime: string, name: string): boolean { + const lower = name.toLowerCase(); + return ( + mime === 'text/plain' || + mime === 'text/csv' || + lower.endsWith('.txt') || + lower.endsWith('.csv') || + mime === 'application/pdf' || + lower.endsWith('.pdf') || + mime === + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || + lower.endsWith('.docx') + ); +} + // 첨부 문서에서 평문 텍스트를 추출한다(업로드 시 1회). 추출 불가 형식/실패/빈 결과는 null. // 지원 형식: txt·csv(직접 읽기), pdf(pdf-parse), docx(mammoth). // 그 외(이미지·xlsx·ppt·압축 등)는 추출하지 않고 null 을 반환한다. diff --git a/backend/src/modules/project/project.service.ts b/backend/src/modules/project/project.service.ts index 52a4ea9..ebb9e5d 100644 --- a/backend/src/modules/project/project.service.ts +++ b/backend/src/modules/project/project.service.ts @@ -24,7 +24,11 @@ import { decodeOriginalName, type UploadedDiskFile, } from '../task/upload.config'; -import { extractAttachmentText } from './extract-text'; +import { + extractAttachmentText, + isExtractableType, + type AttachmentAiStatus, +} from './extract-text'; import { CreateProjectDto } from './dto/create-project.dto'; import { UpdateProjectDto } from './dto/update-project.dto'; @@ -37,6 +41,8 @@ export interface ProjectAttachmentResponse { url: string; uploader: PublicUser | null; createdAt: string; + // AI 추천 반영 상태 — 추출 본문은 노출하지 않고 상태 플래그만 제공 + aiStatus: AttachmentAiStatus; } // 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산 @@ -211,11 +217,15 @@ export class ProjectService { // 첨부 목록(최신순) — 프로젝트 상세에 포함 async listFiles(projectId: string): Promise { - const rows = await this.attachmentRepo.find({ - where: { project: { id: projectId } }, - relations: ['uploader'], - order: { createdAt: 'DESC' }, - }); + // extractedText 는 select:false 이지만, AI 반영 상태 배지 산출을 위해 명시적으로 선택한다. + // (본문은 응답에 담지 않고 상태 판별에만 사용) + const rows = await this.attachmentRepo + .createQueryBuilder('a') + .leftJoinAndSelect('a.uploader', 'uploader') + .addSelect('a.extractedText') + .where('a.project_id = :projectId', { projectId }) + .orderBy('a.created_at', 'DESC') + .getMany(); return rows.map((a) => this.toAttachmentResponse(a, projectId)); } @@ -339,9 +349,19 @@ export class ProjectService { url: `/api/projects/${projectId}/files/${att.id}/download`, uploader: att.uploader ? UserService.toPublic(att.uploader) : null, createdAt: att.createdAt.toISOString(), + aiStatus: this.deriveAiStatus(att), }; } + // 첨부의 AI 반영 상태 산출 — 추출 본문 유무 + 형식으로 판별 + private deriveAiStatus(att: ProjectAttachment): AttachmentAiStatus { + if (att.extractedText && att.extractedText.trim().length > 0) { + return 'ready'; + } + // 본문이 비어 있음 — 지원 형식이면 추출 실패, 아니면 미지원 형식 + return isExtractableType(att.mime, att.name) ? 'failed' : 'unsupported'; + } + // 프로젝트별 업무 완료/전체 수 — 진행률 집계용 private async taskCountsByProject( ids: string[], diff --git a/frontend/src/components/common/AttachmentAiBadge.vue b/frontend/src/components/common/AttachmentAiBadge.vue new file mode 100644 index 0000000..0b802ac --- /dev/null +++ b/frontend/src/components/common/AttachmentAiBadge.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/frontend/src/pages/relay/ProjectDetailPage.vue b/frontend/src/pages/relay/ProjectDetailPage.vue index 5e9ffc7..800e598 100644 --- a/frontend/src/pages/relay/ProjectDetailPage.vue +++ b/frontend/src/pages/relay/ProjectDetailPage.vue @@ -12,6 +12,7 @@ import DropdownSelect from '@/components/DropdownSelect.vue' import SegmentedTabs from '@/components/common/SegmentedTabs.vue' import LoadMore from '@/components/common/LoadMore.vue' import EmptyState from '@/components/common/EmptyState.vue' +import AttachmentAiBadge from '@/components/common/AttachmentAiBadge.vue' import { useProjectStore } from '@/stores/project.store' import { useTaskStore } from '@/stores/task.store' import type { TaskListQuery, TaskSegment, TaskSort } from '@/types/task' @@ -199,6 +200,7 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length) download > {{ a.name }} + {{ formatBytes(a.size) }} diff --git a/frontend/src/pages/relay/ProjectSettingsPage.vue b/frontend/src/pages/relay/ProjectSettingsPage.vue index bac7e44..4d5b31d 100644 --- a/frontend/src/pages/relay/ProjectSettingsPage.vue +++ b/frontend/src/pages/relay/ProjectSettingsPage.vue @@ -5,6 +5,7 @@ import { useRoute, useRouter, RouterLink } from 'vue-router' import AppShell from '@/layouts/AppShell.vue' import ProjectHeader from '@/components/ProjectHeader.vue' import ProjectTabs from '@/components/ProjectTabs.vue' +import AttachmentAiBadge from '@/components/common/AttachmentAiBadge.vue' import { useProjectStore } from '@/stores/project.store' import { useProject } from '@/composables/useProject' import { useDialog } from '@/composables/useDialog' @@ -262,6 +263,7 @@ async function removeProject() { download > {{ a.name }} + {{ formatBytes(a.size) }}