Merge feature/attachment-ai-badge: 문서 AI 반영 상태 배지

This commit is contained in:
2026-06-23 15:10:21 +09:00
6 changed files with 124 additions and 6 deletions
@@ -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 을 반환한다.
+26 -6
View File
@@ -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<ProjectAttachmentResponse[]> {
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[],
@@ -0,0 +1,72 @@
<script setup lang="ts">
// 프로젝트 문서의 AI 추천 반영 상태 배지
// ready: 반영 가능 / failed: 추출 실패 / unsupported: 미지원 형식
import { computed } from 'vue'
interface Props {
status: 'ready' | 'failed' | 'unsupported'
}
const props = defineProps<Props>()
// 상태별 라벨·툴팁·스타일 클래스
const META: Record<Props['status'], { label: string; title: string; cls: string }> = {
ready: {
label: 'AI 반영',
title: 'AI 업무 추천에 이 문서 내용이 반영됩니다.',
cls: 'ok',
},
failed: {
label: '추출 실패',
title: '문서에서 텍스트를 추출하지 못해 AI 추천에 반영되지 않습니다.',
cls: 'warn',
},
unsupported: {
label: 'AI 반영 불가',
title: '미지원 형식이라 AI 추천에 반영되지 않습니다. (txt·csv·pdf·docx 지원)',
cls: 'muted',
},
}
const meta = computed(() => META[props.status])
</script>
<template>
<span
class="ai-badge"
:class="meta.cls"
:title="meta.title"
>
{{ meta.label }}
</span>
</template>
<style scoped>
.ai-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
height: 1.25rem;
padding: 0 0.4375rem;
border-radius: 0.3125rem;
border: 1px solid transparent;
font-size: 0.6875rem;
font-weight: 600;
white-space: nowrap;
cursor: default;
}
.ai-badge.ok {
color: var(--green);
background: var(--green-weak);
border-color: var(--green-border);
}
.ai-badge.warn {
color: var(--amber);
background: var(--amber-weak);
border-color: var(--amber-border);
}
.ai-badge.muted {
color: var(--text-3);
background: #f3f4f6;
border-color: var(--border);
}
</style>
@@ -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
>
<span class="pd-name">{{ a.name }}</span>
<AttachmentAiBadge :status="a.aiStatus" />
<span class="pd-size">{{ formatBytes(a.size) }}</span>
</a>
</div>
@@ -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
>
<span class="fr-name">{{ a.name }}</span>
<AttachmentAiBadge :status="a.aiStatus" />
<span class="fr-size">{{ formatBytes(a.size) }}</span>
<button
type="button"
+2
View File
@@ -11,6 +11,8 @@ export interface ApiProjectAttachment {
url: string // 다운로드 경로(/api/...)
uploader: { id: string; name: string; avatarUrl: string | null } | null
createdAt: string
// AI 추천 반영 상태 — ready: 반영 가능 / failed: 추출 실패 / unsupported: 미지원 형식
aiStatus: 'ready' | 'failed' | 'unsupported'
}
// 프로젝트(API 원시) — 백엔드 ProjectResponse 와 1:1