feat: AI 업무 추천에 프로젝트 문서 내용 반영(DB 캐싱)

업무 작성 화면의 AI 추천이 프로젝트 제목·설명과 업무 제목·내용에 더해
프로젝트 첨부 문서의 내용까지 파악하도록 확장한다.

매 호출마다 문서를 재파싱하지 않도록, 업로드 시점에 1회 텍스트를
추출해 project_attachments.extracted_text 에 캐싱하고 AI 추천에서
꺼내 읽는 구조로 구현한다.

- ProjectAttachment.extractedText 컬럼 추가(text, nullable, select:false)
- extract-text.ts: txt/csv(직접)·pdf(pdf-parse)·docx(mammoth) 추출
  (문서당 8천 자 상한, 추출 불가/실패 시 null)
- addFile: 업로드 시 텍스트 추출·캐싱
- getAttachmentsTextForAi: 문서별 추출 본문을 합쳐 반환(총 1.6만 자 상한)
- suggestChecklist: [프로젝트 문서] 섹션을 프롬프트에 추가 + 시스템 규칙 보강
- 운영용 마이그레이션 추가(dev 는 synchronize 로 자동 반영)
- 추출 텍스트는 AI 내부용 — 프론트 응답 DTO 에는 노출하지 않음

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:54:29 +09:00
parent 87c9019446
commit 739a5a70f3
7 changed files with 555 additions and 100 deletions
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 프로젝트 첨부 문서의 추출 본문 캐시 컬럼 추가.
// 업로드 시 1회 추출해 보관하고, AI 업무 추천에서 꺼내 읽는다.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddProjectAttachmentExtractedText1782600000000 implements MigrationInterface {
name = 'AddProjectAttachmentExtractedText1782600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "project_attachments" ADD "extracted_text" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "project_attachments" DROP COLUMN "extracted_text"`,
);
}
}
+8
View File
@@ -88,6 +88,7 @@ const SUGGEST_SYSTEM_PROMPT = [
'- 각 카테고리에 필요한 항목을 충분히 제안하세요. 항목 수에 제한은 없습니다. 적합한 항목이 없으면 빈 배열([])로 두세요.',
'- 각 항목은 한국어로 간결한 한 문장(체크리스트 항목)으로 작성합니다.',
'- 업무·프로젝트 맥락에 구체적으로 맞추고 일반론은 피하세요.',
'- [프로젝트 문서] 섹션이 주어지면(첨부 문서에서 추출한 내용) 그 내용을 적극 반영해 더 구체적으로 제안하세요.',
'',
'[형식]',
'{"groups":[{"category":"필수 기능","items":["..."]},{"category":"관리 기능","items":["..."]},{"category":"보안 기능","items":["..."]},{"category":"부가 기능","items":["..."]}]}',
@@ -199,9 +200,16 @@ export class AiService {
// 프로젝트 정보(이름/설명) — 인증 사용자면 열람 가능. 없으면 404.
const project = await this.projectService.findOne(input.projectId, userId);
const content = (input.content ?? []).join('\n').trim() || '(없음)';
// 프로젝트 첨부 문서의 캐싱된 추출 본문(업로드 시 보관) — 있으면 추천에 반영
const docsText = await this.projectService.getAttachmentsTextForAi(
input.projectId,
);
const userMsg = [
`[프로젝트] 표시 제목: ${project.name}`,
`설명: ${project.description ?? '(없음)'}`,
...(docsText
? ['', '[프로젝트 문서] (첨부 문서에서 추출한 내용)', docsText]
: []),
'',
`[업무] 제목: ${input.title}`,
'내용:',
@@ -50,6 +50,16 @@ export class ProjectAttachment {
@Column({ type: 'varchar' })
kind!: AttachmentKind;
// AI 추천용 추출 본문 — 업로드 시 1회 추출·캐싱(매 호출 재파싱 방지).
// 추출 불가 형식/실패 시 null. 용량이 크므로 기본 조회에서는 제외(select:false).
@Column({
name: 'extracted_text',
type: 'text',
nullable: true,
select: false,
})
extractedText!: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,69 @@
import { readFile } from 'fs/promises';
import { PDFParse } from 'pdf-parse';
import { extractRawText } from 'mammoth';
// 문서 1건당 추출 텍스트 상한(자) — DB 용량과 AI 토큰 사용량을 함께 보호한다.
export const PER_DOC_TEXT_LIMIT = 8000;
// 첨부 문서에서 평문 텍스트를 추출한다(업로드 시 1회). 추출 불가 형식/실패/빈 결과는 null.
// 지원 형식: txt·csv(직접 읽기), pdf(pdf-parse), docx(mammoth).
// 그 외(이미지·xlsx·ppt·압축 등)는 추출하지 않고 null 을 반환한다.
export async function extractAttachmentText(
absPath: string,
mime: string,
name: string,
): Promise<string | null> {
const lower = name.toLowerCase();
try {
// 1) 평문 텍스트 — 라이브러리 없이 그대로 읽는다
if (
mime === 'text/plain' ||
mime === 'text/csv' ||
lower.endsWith('.txt') ||
lower.endsWith('.csv')
) {
const buf = await readFile(absPath);
return finalize(buf.toString('utf8'));
}
// 2) PDF — pdf-parse v2(PDFParse 클래스)로 페이지 텍스트 추출
if (mime === 'application/pdf' || lower.endsWith('.pdf')) {
const buf = await readFile(absPath);
const parser = new PDFParse({ data: buf });
try {
const result = await parser.getText();
return finalize(result.text);
} finally {
await parser.destroy();
}
}
// 3) Word(.docx) — mammoth 로 서식 제거한 본문만 추출
if (
mime ===
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
lower.endsWith('.docx')
) {
const result = await extractRawText({ path: absPath });
return finalize(result.value);
}
return null;
} catch {
// 추출 실패는 무시한다 — 첨부 자체는 정상 저장되어야 한다.
return null;
}
}
// 공백·개행 정리 후 길이 상한 적용. 내용이 없으면 null.
function finalize(text: string): string | null {
const cleaned = text
.replace(/\r\n/g, '\n')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (!cleaned) return null;
return cleaned.length > PER_DOC_TEXT_LIMIT
? cleaned.slice(0, PER_DOC_TEXT_LIMIT)
: cleaned;
}
@@ -24,6 +24,7 @@ import {
decodeOriginalName,
type UploadedDiskFile,
} from '../task/upload.config';
import { extractAttachmentText } from './extract-text';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
@@ -218,6 +219,32 @@ export class ProjectService {
return rows.map((a) => this.toAttachmentResponse(a, projectId));
}
// AI 업무 추천용 — 프로젝트 문서들의 캐싱된 추출 본문을 문서명과 함께 합쳐 반환한다.
// 총량 상한으로 잘라 토큰 사용을 보호하며, 추출 텍스트가 없으면 빈 문자열.
// (extractedText 는 select:false 이므로 명시적으로 선택해 조회한다.)
async getAttachmentsTextForAi(projectId: string): Promise<string> {
const TOTAL_LIMIT = 16000;
const rows = await this.attachmentRepo
.createQueryBuilder('a')
.select(['a.name', 'a.extractedText'])
.where('a.project_id = :projectId', { projectId })
.orderBy('a.created_at', 'ASC')
.getMany();
const parts: string[] = [];
let used = 0;
for (const r of rows) {
const text = r.extractedText?.trim();
if (!text) continue;
const remaining = TOTAL_LIMIT - used;
if (remaining <= 0) break;
const slice = text.length > remaining ? text.slice(0, remaining) : text;
parts.push(`### ${r.name}\n${slice}`);
used += slice.length;
}
return parts.join('\n\n');
}
// 첨부 업로드 — 관리자만. 디스크 저장은 multer 가 처리, 메타데이터만 영속
async addFile(
projectId: string,
@@ -229,6 +256,12 @@ export class ProjectService {
const uploader = await this.userService.findById(userId);
const name = decodeOriginalName(file.originalname);
// 업로드 시점에 텍스트를 1회 추출해 캐싱 — AI 추천에서 재파싱 없이 꺼내 쓴다.
const extractedText = await extractAttachmentText(
file.path,
file.mimetype,
name,
);
const saved = await this.attachmentRepo.save(
this.attachmentRepo.create({
project: { id: project.id } as Project,
@@ -238,6 +271,7 @@ export class ProjectService {
size: file.size,
mime: file.mimetype,
kind: deriveKind(file.mimetype, name),
extractedText,
}),
);
return this.toAttachmentResponse(