revert: AI 코드 검토 기능 제거 (85ae82f 되돌림)
통짜 코드 전송 방식이 토큰 비효율·부정확해, 검색 기반(grep으로 좁힌 뒤 관련
스니펫만 판정) 방식으로 재설계하기 위해 일괄 제거. 추후 개선안으로 재작업 예정.
This reverts commit 85ae82f.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,8 @@ import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import {
|
||||
AiService,
|
||||
type ChecklistReviewResult,
|
||||
type ChecklistSuggestionGroup,
|
||||
} from './ai.service';
|
||||
import { AiService, type ChecklistSuggestionGroup } from './ai.service';
|
||||
import { SuggestChecklistDto } from './dto/suggest-checklist.dto';
|
||||
import { ReviewChecklistDto } from './dto/review-checklist.dto';
|
||||
|
||||
// AI 보조 — 업무 작성 시 할 일(체크리스트) 추천
|
||||
@ApiTags('AI')
|
||||
@@ -42,23 +37,4 @@ export class AiSuggestController {
|
||||
content: dto.content,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('review-checklist')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// 코드 검토는 비용이 커서 더 엄격히 — IP당 분당 6회
|
||||
@Throttle({ default: { limit: 6, ttl: 60_000 } })
|
||||
@ApiOperation({
|
||||
summary: '코드 검토 — Gitea 코드로 체크리스트 구현 여부 판정',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '항목별 판정 + 요약(체크리스트 반영됨)',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '저장소/업무 없음 (RES_001)' })
|
||||
reviewChecklist(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: ReviewChecklistDto,
|
||||
): Promise<ChecklistReviewResult> {
|
||||
return this.aiService.reviewChecklist(user.id, dto.repoId, dto.taskSeq);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TaskModule } from '../task/task.module';
|
||||
import { RepoModule } from '../repo/repo.module';
|
||||
import { GiteaModule } from '../gitea/gitea.module';
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiSuggestController } from './ai-suggest.controller';
|
||||
import { AiService } from './ai.service';
|
||||
@@ -10,13 +9,12 @@ import { AiConversation } from './entities/ai-conversation.entity';
|
||||
import { AiMessage } from './entities/ai-message.entity';
|
||||
|
||||
// AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속.
|
||||
// Task/Repo/GiteaModule 을 가져와 업무·저장소 컨텍스트 주입 및 코드 검토에 사용한다.
|
||||
// Task/RepoModule 을 가져와 사용자 업무·저장소 컨텍스트를 시스템 프롬프트에 주입한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AiConversation, AiMessage]),
|
||||
TaskModule,
|
||||
RepoModule,
|
||||
GiteaModule,
|
||||
],
|
||||
controllers: [AiController, AiSuggestController],
|
||||
providers: [AiService],
|
||||
|
||||
@@ -10,9 +10,7 @@ import {
|
||||
} from '../../common/pagination/pagination';
|
||||
import { TaskService } from '../task/task.service';
|
||||
import type { TaskStatus } from '../task/entities/task.entity';
|
||||
import type { ReviewVerdict } from '../task/entities/checklist-item.entity';
|
||||
import { RepoService } from '../repo/repo.service';
|
||||
import { GiteaService } from '../gitea/gitea.service';
|
||||
import { AiConversation } from './entities/ai-conversation.entity';
|
||||
import { AiMessage } from './entities/ai-message.entity';
|
||||
|
||||
@@ -44,19 +42,6 @@ export interface ChecklistSuggestionGroup {
|
||||
items: string[];
|
||||
}
|
||||
|
||||
// 코드 검토 결과 — 항목별 판정 + 요약
|
||||
export interface ChecklistReviewItem {
|
||||
id: string;
|
||||
text: string;
|
||||
verdict: ReviewVerdict; // 'done'(구현됨) | 'missing'(미구현)
|
||||
reason: string;
|
||||
}
|
||||
export interface ChecklistReviewResult {
|
||||
items: ChecklistReviewItem[];
|
||||
summary: string;
|
||||
truncated: boolean; // 코드가 예산 초과로 일부만 검토됨
|
||||
}
|
||||
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
const MAX_TOKENS = 1024;
|
||||
@@ -106,21 +91,6 @@ const SUGGEST_SYSTEM_PROMPT = [
|
||||
'{"groups":[{"category":"필수 기능","items":["...","..."]},{"category":"보안 기능","items":["..."]}]}',
|
||||
].join('\n');
|
||||
|
||||
// 코드 검토용 시스템 프롬프트 — 체크리스트 항목별 구현 여부를 코드 근거로 판정, JSON 으로만 응답
|
||||
const REVIEW_SYSTEM_PROMPT = [
|
||||
'당신은 코드 리뷰어입니다. 주어진 저장소 코드를 근거로, 업무의 체크리스트 각 항목이 실제로 구현되었는지 판정합니다.',
|
||||
'',
|
||||
'[규칙]',
|
||||
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·코드펜스 없이 순수 JSON 만 출력합니다.',
|
||||
'- 각 항목의 verdict 는 "done"(코드에 구현 근거가 명확) 또는 "missing"(근거 없음/미구현) 중 하나입니다.',
|
||||
'- 코드에 근거가 없으면 추측하지 말고 "missing" 으로 판정하세요.',
|
||||
'- reason 은 판정 근거를 한국어 한 문장으로(가능하면 파일/함수 언급). 제공된 코드는 일부일 수 있음을 감안하세요.',
|
||||
'- items 의 id 는 입력으로 받은 체크리스트 항목 id 를 그대로 사용합니다.',
|
||||
'',
|
||||
'[형식]',
|
||||
'{"items":[{"id":"<항목id>","verdict":"done","reason":"..."}],"summary":"전체 요약 한두 문장"}',
|
||||
].join('\n');
|
||||
|
||||
// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출.
|
||||
// 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다.
|
||||
@Injectable()
|
||||
@@ -133,7 +103,6 @@ export class AiService {
|
||||
config: ConfigService,
|
||||
private readonly taskService: TaskService,
|
||||
private readonly repoService: RepoService,
|
||||
private readonly giteaService: GiteaService,
|
||||
@InjectRepository(AiConversation)
|
||||
private readonly convRepo: Repository<AiConversation>,
|
||||
@InjectRepository(AiMessage)
|
||||
@@ -285,160 +254,6 @@ export class AiService {
|
||||
);
|
||||
}
|
||||
|
||||
// 코드 검토 — 연결된 Gitea 저장소 코드를 가져와 체크리스트 항목별 구현 여부를 판정하고
|
||||
// 결과(done/missing)를 체크리스트에 반영(applyReviewVerdicts)한다.
|
||||
async reviewChecklist(
|
||||
userId: string,
|
||||
repoId: string,
|
||||
taskSeq: number,
|
||||
): Promise<ChecklistReviewResult> {
|
||||
this.ensureEnabled();
|
||||
const { repo, task } = await this.taskService.getTaskForReview(
|
||||
repoId,
|
||||
userId,
|
||||
taskSeq,
|
||||
);
|
||||
const checklist = (task.checklist ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
if (checklist.length === 0) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'검토할 체크리스트 항목이 없습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
if (!this.giteaService.enabled || repo.giteaRepoId === null) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'Gitea 에 연결된 저장소만 코드 검토를 할 수 있습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
// 1) 저장소 코드 스냅샷
|
||||
let code: {
|
||||
files: { path: string; content: string }[];
|
||||
truncated: boolean;
|
||||
};
|
||||
try {
|
||||
code = await this.giteaService.fetchRepoCode(repo.slugName, repo.branch);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Gitea 코드 조회 실패: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'저장소 코드를 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
if (code.files.length === 0) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'검토할 코드가 저장소에 없습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
// 2) 프롬프트 구성(코드 + 체크리스트)
|
||||
const codeBlock = code.files
|
||||
.map((f) => `### ${f.path}\n${f.content}`)
|
||||
.join('\n\n');
|
||||
const checklistBlock = checklist
|
||||
.map((c) => `- id:${c.id} | ${c.text}`)
|
||||
.join('\n');
|
||||
const userMsg = [
|
||||
`[업무] ${task.title}`,
|
||||
task.content.length ? `내용:\n${task.content.join('\n')}` : '',
|
||||
'',
|
||||
`[체크리스트] (각 항목의 구현 여부를 판정)`,
|
||||
checklistBlock,
|
||||
'',
|
||||
`[저장소 코드]${code.truncated ? ' (일부만 포함됨 — 용량 제한)' : ''}`,
|
||||
codeBlock,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
// 3) 판정
|
||||
const raw = await this.callClaude(
|
||||
[{ role: 'user', content: userMsg }],
|
||||
REVIEW_SYSTEM_PROMPT,
|
||||
2048,
|
||||
60_000, // 코드 검토는 프롬프트가 커서 더 넉넉히
|
||||
);
|
||||
const parsed = this.parseReview(raw, checklist);
|
||||
|
||||
// 4) 체크리스트에 반영(done/missing)
|
||||
const verdictMap = new Map<string, ReviewVerdict>(
|
||||
parsed.items.map((i) => [i.id, i.verdict]),
|
||||
);
|
||||
await this.taskService.applyReviewVerdicts(task.id, verdictMap);
|
||||
|
||||
return { ...parsed, truncated: code.truncated };
|
||||
}
|
||||
|
||||
// Claude 의 검토 JSON 파싱 — 알려진 체크리스트 id 만 채택, 누락 항목은 missing 으로 보정
|
||||
private parseReview(
|
||||
raw: string,
|
||||
checklist: { id: string; text: string }[],
|
||||
): { items: ChecklistReviewItem[]; summary: string } {
|
||||
let text = raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```$/, '')
|
||||
.trim();
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) text = text.slice(start, end + 1);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw this.reviewFailed();
|
||||
}
|
||||
|
||||
const obj = parsed as { items?: unknown; summary?: unknown };
|
||||
const rawItems: unknown[] = Array.isArray(obj.items) ? obj.items : [];
|
||||
const byId = new Map<string, ReviewVerdict>();
|
||||
for (const it of rawItems) {
|
||||
const r = it as { id?: unknown; verdict?: unknown };
|
||||
if (typeof r.id !== 'string') continue;
|
||||
const verdict: ReviewVerdict = r.verdict === 'done' ? 'done' : 'missing';
|
||||
byId.set(r.id, verdict);
|
||||
}
|
||||
const reasonById = new Map<string, string>();
|
||||
for (const it of rawItems) {
|
||||
const r = it as { id?: unknown; reason?: unknown };
|
||||
if (typeof r.id === 'string' && typeof r.reason === 'string') {
|
||||
reasonById.set(r.id, r.reason.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// 알려진 체크리스트 항목 기준으로 결과 구성(모델이 빠뜨린 항목은 missing)
|
||||
const items: ChecklistReviewItem[] = checklist.map((c) => ({
|
||||
id: c.id,
|
||||
text: c.text,
|
||||
verdict: byId.get(c.id) ?? 'missing',
|
||||
reason: reasonById.get(c.id) ?? '코드에서 구현 근거를 찾지 못했습니다.',
|
||||
}));
|
||||
const summary =
|
||||
typeof obj.summary === 'string' && obj.summary.trim()
|
||||
? obj.summary.trim()
|
||||
: '코드 검토를 완료했습니다.';
|
||||
return { items, summary };
|
||||
}
|
||||
|
||||
private reviewFailed(): BusinessException {
|
||||
return new BusinessException(
|
||||
'BIZ_001',
|
||||
'코드 검토 결과를 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// 대화 삭제(본인 소유만) — 메시지 CASCADE
|
||||
async deleteConversation(
|
||||
userId: string,
|
||||
@@ -603,8 +418,6 @@ export class AiService {
|
||||
private async callClaude(
|
||||
messages: { role: 'user' | 'assistant'; content: string }[],
|
||||
system: string,
|
||||
maxTokens: number = MAX_TOKENS,
|
||||
timeoutMs: number = REQUEST_TIMEOUT_MS,
|
||||
): Promise<string> {
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -617,11 +430,11 @@ export class AiService {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: MAX_TOKENS,
|
||||
system,
|
||||
messages,
|
||||
}),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 코드 검토 요청 — 대상 저장소·업무
|
||||
export class ReviewChecklistDto {
|
||||
@ApiProperty({ description: '저장소 식별자(slugName)' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '저장소가 필요합니다.' })
|
||||
repoId!: string;
|
||||
|
||||
@ApiProperty({ description: '업무 순번(seq)' })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
taskSeq!: number;
|
||||
}
|
||||
@@ -36,12 +36,6 @@ interface GiteaRepoApi {
|
||||
default_branch: string;
|
||||
}
|
||||
|
||||
// Git 트리 API 응답(재귀)
|
||||
interface GiteaTreeApi {
|
||||
tree?: { path: string; type: string; size?: number }[];
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
// 외부 Gitea 인스턴스와 통신하는 얇은 API 클라이언트.
|
||||
// 조직(GITEA_ORG) 아래 저장소를 생성/수정/삭제한다.
|
||||
// 필수 env(GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG)가 비어 있으면 비활성화 상태가 되어,
|
||||
@@ -160,68 +154,6 @@ export class GiteaService {
|
||||
);
|
||||
}
|
||||
|
||||
// 저장소 코드 스냅샷 — AI 코드 검토용. 기본 브랜치의 텍스트 파일을 바이트 예산까지 모아 반환.
|
||||
// 바이너리/대용량/무관 경로(node_modules, .git, 잠금파일 등)는 제외하고, 합산이 예산을 넘으면 잘린다.
|
||||
async fetchRepoCode(
|
||||
name: string,
|
||||
branch: string,
|
||||
byteBudget = 120_000,
|
||||
): Promise<{
|
||||
files: { path: string; content: string }[];
|
||||
truncated: boolean;
|
||||
}> {
|
||||
const repoPath = `${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`;
|
||||
// 1) 재귀 트리 — 기본 브랜치 이름을 ref 로 사용
|
||||
const tree = await this.request<GiteaTreeApi>(
|
||||
'GET',
|
||||
`/repos/${repoPath}/git/trees/${encodeURIComponent(branch)}?recursive=true&per_page=1000`,
|
||||
);
|
||||
const blobs = (tree?.tree ?? [])
|
||||
.filter((e) => e.type === 'blob' && typeof e.path === 'string')
|
||||
.filter((e) => this.isReviewablePath(e.path))
|
||||
.filter((e) => (e.size ?? 0) > 0 && (e.size ?? 0) <= 64_000)
|
||||
// 작은 파일 우선(예산 내 더 많은 파일 포함)
|
||||
.sort((a, b) => (a.size ?? 0) - (b.size ?? 0));
|
||||
|
||||
const files: { path: string; content: string }[] = [];
|
||||
let used = 0;
|
||||
let truncated = tree?.truncated ?? false;
|
||||
for (const blob of blobs) {
|
||||
if (used + (blob.size ?? 0) > byteBudget) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const raw = await this.requestText(
|
||||
`/repos/${repoPath}/raw/${blob.path
|
||||
.split('/')
|
||||
.map(encodeURIComponent)
|
||||
.join('/')}?ref=${encodeURIComponent(branch)}`,
|
||||
);
|
||||
if (raw == null) continue;
|
||||
files.push({ path: blob.path, content: raw });
|
||||
used += Buffer.byteLength(raw, 'utf8');
|
||||
} catch {
|
||||
// 개별 파일 실패는 건너뜀(베스트에포트)
|
||||
}
|
||||
}
|
||||
return { files, truncated };
|
||||
}
|
||||
|
||||
// 검토 대상 경로 필터 — 의존성/빌드산출물/바이너리/잠금파일 제외
|
||||
private isReviewablePath(path: string): boolean {
|
||||
const lower = path.toLowerCase();
|
||||
const skipDir =
|
||||
/(^|\/)(node_modules|\.git|dist|build|coverage|\.next|\.nuxt|vendor|__pycache__)\//;
|
||||
if (skipDir.test('/' + lower)) return false;
|
||||
if (/(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/.test(lower))
|
||||
return false;
|
||||
// 텍스트(코드/설정/문서) 확장자만 — 이미지/폰트/바이너리 제외
|
||||
return /\.(ts|tsx|js|jsx|vue|mjs|cjs|json|md|py|java|go|rb|php|cs|c|cc|cpp|h|hpp|rs|kt|swift|dart|sql|ya?ml|toml|env|sh|html?|css|scss|sass|less)$/.test(
|
||||
lower,
|
||||
);
|
||||
}
|
||||
|
||||
// 조직 아래 모든 저장소 조회(페이지네이션 순회) — Gitea → Relay 동기화용
|
||||
async listOrgRepos(): Promise<GiteaRepoResult[]> {
|
||||
const limit = 50; // Gitea 기본 최대 페이지 크기
|
||||
@@ -272,15 +204,6 @@ export class GiteaService {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// raw 파일 등 텍스트 본문 요청 — JSON 이 아니라 원문 텍스트를 반환(404 등은 null)
|
||||
private async requestText(path: string): Promise<string | null> {
|
||||
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
headers: { Authorization: `token ${this.token}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.text();
|
||||
}
|
||||
|
||||
// API 응답 → 보관용 결과 매핑
|
||||
private toResult(data: GiteaRepoApi): GiteaRepoResult {
|
||||
return {
|
||||
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Task } from './task.entity';
|
||||
|
||||
// AI 코드 검토 판정 — 'done'(구현됨) | 'missing'(미구현). null 은 미검토.
|
||||
export type ReviewVerdict = 'done' | 'missing';
|
||||
|
||||
// 업무 체크리스트 항목
|
||||
@Entity('checklist_items')
|
||||
export class ChecklistItem {
|
||||
@@ -34,14 +31,6 @@ export class ChecklistItem {
|
||||
@Column({ name: 'sort_order', type: 'int', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
// AI 코드 검토 판정(미검토면 null)
|
||||
@Column({
|
||||
name: 'review_verdict',
|
||||
type: 'varchar',
|
||||
nullable: true,
|
||||
})
|
||||
reviewVerdict!: ReviewVerdict | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,7 @@ import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { Repo } from '../repo/entities/repo.entity';
|
||||
import { RepoMember } from '../repo/entities/repo-member.entity';
|
||||
import { Task, type TaskStatus } from './entities/task.entity';
|
||||
import {
|
||||
ChecklistItem,
|
||||
type ReviewVerdict,
|
||||
} from './entities/checklist-item.entity';
|
||||
import { ChecklistItem } from './entities/checklist-item.entity';
|
||||
import { Comment } from './entities/comment.entity';
|
||||
import { Attachment, type AttachmentKind } from './entities/attachment.entity';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
@@ -55,7 +52,6 @@ export interface ChecklistItemResponse {
|
||||
id: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
reviewVerdict: ReviewVerdict | null; // AI 코드 검토 판정(미검토면 null)
|
||||
}
|
||||
|
||||
// 저장소 업무 목록의 세그먼트별 카운트(전체/진행/대기/완료) — 검색어 반영
|
||||
@@ -1090,41 +1086,6 @@ export class TaskService {
|
||||
}
|
||||
}
|
||||
|
||||
// --- AI 코드 검토 연동 (ai 모듈에서 사용) ---
|
||||
|
||||
// 검토 대상 업무 조회 — 멤버 검증 후 업무(+체크리스트) 반환.
|
||||
// 반환된 repo 로 호출측(AiService)이 Gitea 코드를 가져온다.
|
||||
async getTaskForReview(
|
||||
slugName: string,
|
||||
actingUserId: string,
|
||||
seq: number,
|
||||
): Promise<{ repo: Repo; task: Task }> {
|
||||
const repo = await this.getRepoOrThrow(slugName);
|
||||
await this.assertMember(repo.id, actingUserId);
|
||||
const task = await this.getTaskOrThrow(repo.id, seq);
|
||||
return { repo, task };
|
||||
}
|
||||
|
||||
// AI 검토 결과를 체크리스트에 반영 — verdict('done'|'missing')별로 done·reviewVerdict 갱신.
|
||||
// verdicts: 체크리스트 항목 id → 판정. 누락된 항목은 변경하지 않는다.
|
||||
async applyReviewVerdicts(
|
||||
taskId: string,
|
||||
verdicts: Map<string, ReviewVerdict>,
|
||||
): Promise<void> {
|
||||
const items = await this.checklistRepo.find({
|
||||
where: { task: { id: taskId } },
|
||||
});
|
||||
const toSave: ChecklistItem[] = [];
|
||||
for (const item of items) {
|
||||
const verdict = verdicts.get(item.id);
|
||||
if (!verdict) continue;
|
||||
item.reviewVerdict = verdict;
|
||||
item.done = verdict === 'done';
|
||||
toSave.push(item);
|
||||
}
|
||||
if (toSave.length) await this.checklistRepo.save(toSave);
|
||||
}
|
||||
|
||||
// slugName 으로 저장소 로딩, 없으면 404
|
||||
private async getRepoOrThrow(slugName: string): Promise<Repo> {
|
||||
const repo = await this.repoRepo.findOne({ where: { slugName } });
|
||||
@@ -1290,12 +1251,7 @@ export class TaskService {
|
||||
const checklist = (task.checklist ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
text: c.text,
|
||||
done: c.done,
|
||||
reviewVerdict: c.reviewVerdict ?? null,
|
||||
}));
|
||||
.map((c) => ({ id: c.id, text: c.text, done: c.done }));
|
||||
|
||||
// 승인 정보는 승인 대기(review) 상태이고 요청 메타가 있을 때만 노출
|
||||
const approval: ApprovalInfo | null =
|
||||
|
||||
@@ -346,14 +346,12 @@ Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가
|
||||
| POST | `/ai/conversations/:id/messages` | `{ content }` | `{ conversationId, title, reply }` | 메시지 추가. throttle 20/분 |
|
||||
| DELETE | `/ai/conversations/:id` | — | `null` | 대화 삭제(메시지 CASCADE) |
|
||||
| POST | `/ai/suggest-checklist` | `{ repoId, title, content? }` | `{ groups: [{category, items[]}] }` | 업무 작성 시 할 일(체크리스트) 추천. throttle 20/분 |
|
||||
| POST | `/ai/review-checklist` | `{ repoId, taskSeq }` | `{ items:[{id,text,verdict,reason}], summary, truncated }` | 코드 검토 — Gitea 코드로 체크리스트 구현 여부 판정·반영. throttle 6/분 |
|
||||
|
||||
- **대화/메시지를 DB 보관**(`AiConversation`·`AiMessage`, user CASCADE). 모든 접근은 **본인 소유 스코프**(타 사용자 대화는 404=RES_001, IDOR 차단). 클라는 "이 대화에 새 메시지 1건"만 보내고 **서버가 DB 이력으로 Claude 호출**(이전의 클라 전체-이력 전송 방식 폐기).
|
||||
- `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 503 / 외부오류 502(내부·키 비노출). **Claude 실패 시 방금 사용자 메시지·빈 대화 롤백**(고아 데이터 방지).
|
||||
- **주제 한정 + 데이터 인지(시스템 프롬프트)**: 업무/Relay 관련만 답하고 무관한 주제는 정중히 거절(가드레일, 프롬프트 레벨·소프트). 매 요청 시 **오늘 날짜(KST) + 담당 업무(최대 30건: 제목/상태/마감/체크리스트) + 지시 업무 수 + 저장소 목록(최대 30개: 이름/공개범위/업무수)** 을 시스템 프롬프트에 주입(`TaskService.listAssigned/listIssued` + `RepoService.findAll`). "내 마감 임박 업무"·"저장소 뭐 있어" 같은 질문에 실데이터로 응답. 조회 실패해도 채팅은 진행(베스트에포트).
|
||||
- 프론트: `components/AgentChatPanel.vue`(우측 슬라이드, **채팅 ↔ 지난 대화 목록** 전환·열기·삭제), 버튼은 **AppShell 헤더 상시 노출(모든 화면)**. `stores/ai.store` 가 목록·현재 대화 관리 → **DB 영속이라 다른 기기·재로그인에도 기록 유지**. AI 응답은 **마크다운 렌더**(`shared/utils/markdown.ts` — escapeHtml 후 신뢰 태그만 조립, 외부 의존성 0, XSS 안전: 굵게/목록/코드/제목/링크).
|
||||
- **할 일 추천**(`POST /ai/suggest-checklist`): `AiService.suggestChecklist` 가 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내 **JSON(카테고리별 항목)** 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502). 프론트 **TaskCreatePage**: "취소 / **AI 추천** / 지시 보내기" 버튼 + 체크리스트 아래 추천 패널(카테고리별 항목 클릭 추가·전체 추가·중복 스킵).
|
||||
- **코드 검토**(`POST /ai/review-checklist`): `AiService.reviewChecklist` 가 **연결된 Gitea 코드 스냅샷**(`GiteaService.fetchRepoCode` — 기본 브랜치 텍스트 파일, node_modules/lock/바이너리 제외, 합산 ~120KB 예산까지·초과분 truncated)를 체크리스트와 함께 Claude 에 보내 항목별 `done`/`missing` 판정. 결과를 **체크리스트에 영속 반영**(`ChecklistItem.reviewVerdict` 추가 — `done`이면 done=true, `missing`이면 false). Gitea 미연동/코드 없음/항목 없음은 400. throttle 6/분(비용 큼), Claude 타임아웃 60초. 프론트 **TaskDetailPage** 승인 요청 카드(prog/changes)에 "**AI 검토**" 버튼 → 체크리스트가 ✓(초록)/✗(빨강)로 채워지고 요약 배너 표시. *참고: 코드 일부만 검토하는 베스트에포트 휴리스틱.*
|
||||
|
||||
### 그 외(미착수)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ChecklistReviewResult,
|
||||
ConversationDetail,
|
||||
ConversationSummary,
|
||||
SendResult,
|
||||
@@ -56,17 +55,6 @@ export function useAi() {
|
||||
})) as unknown as SuggestChecklistResult
|
||||
}
|
||||
|
||||
// 코드 검토 — 체크리스트 구현 여부 판정(서버가 체크리스트에 반영). 코드 양에 따라 오래 걸림
|
||||
async function reviewChecklist(payload: {
|
||||
repoId: string
|
||||
taskSeq: number
|
||||
}): Promise<ChecklistReviewResult> {
|
||||
return (await api.post('/ai/review-checklist', payload, {
|
||||
silent: true,
|
||||
timeout: 90_000,
|
||||
})) as unknown as ChecklistReviewResult
|
||||
}
|
||||
|
||||
return {
|
||||
listConversations,
|
||||
getConversation,
|
||||
@@ -74,6 +62,5 @@ export function useAi() {
|
||||
sendMessage,
|
||||
deleteConversation,
|
||||
suggestChecklist,
|
||||
reviewChecklist,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ export interface ChecklistItem {
|
||||
id?: string
|
||||
text: string
|
||||
done: boolean
|
||||
/** AI 코드 검토 판정 — 'done'(구현) | 'missing'(미구현) | null(미검토) */
|
||||
reviewVerdict?: 'done' | 'missing' | null
|
||||
}
|
||||
|
||||
/** 저장소 */
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from '@/mock/relay.mock'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useAi } from '@/composables/useAi'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { attachmentKindOf, fileBadge } from '@/shared/utils/format'
|
||||
|
||||
@@ -32,7 +31,6 @@ const taskId = computed(() => Number(route.params.taskId))
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const authStore = useAuthStore()
|
||||
const ai = useAi()
|
||||
|
||||
// 업무 상세 — API 연동
|
||||
const task = ref<TaskDetail | null>(null)
|
||||
@@ -171,33 +169,6 @@ async function sendApproval() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- AI 코드 검토 ---
|
||||
// 연결된 Gitea 코드를 검토해 체크리스트 항목별 구현 여부(✓/✗)를 채운다.
|
||||
const aiReviewing = ref(false)
|
||||
const aiReviewSummary = ref<string | null>(null)
|
||||
const aiReviewTruncated = ref(false)
|
||||
async function runAiReview() {
|
||||
if (!task.value || aiReviewing.value) return
|
||||
aiReviewing.value = true
|
||||
aiReviewSummary.value = null
|
||||
try {
|
||||
const result = await ai.reviewChecklist({
|
||||
repoId: repoId.value,
|
||||
taskSeq: task.value.id,
|
||||
})
|
||||
aiReviewSummary.value = result.summary
|
||||
aiReviewTruncated.value = result.truncated
|
||||
// 서버가 체크리스트에 판정을 반영했으므로 상세를 다시 불러와 ✓/✗ 갱신
|
||||
await loadTask()
|
||||
} catch {
|
||||
window.alert(
|
||||
'AI 코드 검토에 실패했습니다. Gitea 연결 상태를 확인하거나 잠시 후 다시 시도해 주세요.',
|
||||
)
|
||||
} finally {
|
||||
aiReviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 댓글 / 활동 탭
|
||||
* ============================================================ */
|
||||
@@ -515,48 +486,20 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
</div>
|
||||
<!--
|
||||
체크리스트는 읽기 전용 — 사용자가 개별 토글/추가/삭제하지 않는다.
|
||||
완료 처리는 승인 워크플로우(승인 완료 시 일괄 완료) 또는 AI 코드 검토로 채워진다.
|
||||
완료 처리는 승인 워크플로우(승인 완료 시 일괄 완료)로만 이루어진다.
|
||||
-->
|
||||
<div
|
||||
v-if="aiReviewSummary"
|
||||
class="ai-review-summary"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 3 13.4 8.6 19 10l-5.6 1.4L12 17l-1.4-5.6L5 10l5.6-1.4Z" /></svg>
|
||||
<span>{{ aiReviewSummary }}<template v-if="aiReviewTruncated"> (코드 일부만 검토됨)</template></span>
|
||||
</div>
|
||||
<div class="checklist">
|
||||
<div
|
||||
v-for="(item, i) in task.checklist"
|
||||
:key="item.id ?? i"
|
||||
class="citem"
|
||||
:class="{ done: item.done, missing: item.reviewVerdict === 'missing' }"
|
||||
:title="item.reviewVerdict === 'missing' ? '미구현으로 판정됨' : undefined"
|
||||
:class="{ done: item.done }"
|
||||
>
|
||||
<span
|
||||
class="cbox"
|
||||
:class="{
|
||||
done: item.done,
|
||||
missing: item.reviewVerdict === 'missing',
|
||||
}"
|
||||
:class="{ done: item.done }"
|
||||
>
|
||||
<svg
|
||||
v-if="item.reviewVerdict === 'missing'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -921,23 +864,6 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
placeholder="승인 요청 메시지(선택) — 작업 내용이나 검토 포인트를 적어주세요."
|
||||
/>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn ai-review"
|
||||
type="button"
|
||||
:disabled="aiReviewing"
|
||||
title="연결된 Gitea 코드를 검토해 체크리스트 구현 여부를 채웁니다"
|
||||
@click="runAiReview"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 3 13.4 8.6 19 10l-5.6 1.4L12 17l-1.4-5.6L5 10l5.6-1.4Z" /></svg>
|
||||
{{ aiReviewing ? '검토 중…' : 'AI 검토' }}
|
||||
</button>
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
@@ -985,23 +911,6 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
placeholder="보완 내용 메시지(선택)"
|
||||
/>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn ai-review"
|
||||
type="button"
|
||||
:disabled="aiReviewing"
|
||||
title="연결된 Gitea 코드를 검토해 체크리스트 구현 여부를 채웁니다"
|
||||
@click="runAiReview"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 3 13.4 8.6 19 10l-5.6 1.4L12 17l-1.4-5.6L5 10l5.6-1.4Z" /></svg>
|
||||
{{ aiReviewing ? '검토 중…' : 'AI 검토' }}
|
||||
</button>
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
@@ -1588,18 +1497,13 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
.cbox.missing {
|
||||
background: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.cbox svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
.cbox.done svg,
|
||||
.cbox.missing svg {
|
||||
.cbox.done svg {
|
||||
opacity: 1;
|
||||
}
|
||||
.citem .ctext {
|
||||
@@ -1610,31 +1514,6 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
color: var(--text-3);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.citem.missing .ctext {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* AI 코드 검토 요약 배너 */
|
||||
.ai-review-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4375rem;
|
||||
margin-bottom: 0.625rem;
|
||||
padding: 0.5625rem 0.75rem;
|
||||
background: var(--accent-weak);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.781rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
}
|
||||
.ai-review-summary svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* 댓글/활동 탭 */
|
||||
.thread-tabs {
|
||||
@@ -2370,18 +2249,6 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
.ap-btn.request-send:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.ap-btn.ai-review {
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
.ap-btn.ai-review:hover:not(:disabled) {
|
||||
background: #e7e4fb;
|
||||
}
|
||||
.ap-btn.ai-review:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ===== 5단계: 첨부파일 ===== */
|
||||
.files-head {
|
||||
|
||||
@@ -176,12 +176,7 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
|
||||
status: api.status,
|
||||
statusLabel: taskStatusLabel(api.status),
|
||||
content: api.content,
|
||||
checklist: api.checklist.map((c) => ({
|
||||
id: c.id,
|
||||
text: c.text,
|
||||
done: c.done,
|
||||
reviewVerdict: c.reviewVerdict,
|
||||
})),
|
||||
checklist: api.checklist.map((c) => ({ id: c.id, text: c.text, done: c.done })),
|
||||
comments: api.comments.map((c) => toCommentView(c, issuerId)),
|
||||
activities: api.activities.map(toTaskActivityView),
|
||||
assignees: api.assignees.map(toUserView),
|
||||
|
||||
@@ -33,17 +33,3 @@ export interface ChecklistSuggestionGroup {
|
||||
export interface SuggestChecklistResult {
|
||||
groups: ChecklistSuggestionGroup[]
|
||||
}
|
||||
|
||||
// 코드 검토 — 항목별 판정 결과
|
||||
export type ReviewVerdict = 'done' | 'missing'
|
||||
export interface ChecklistReviewItem {
|
||||
id: string
|
||||
text: string
|
||||
verdict: ReviewVerdict
|
||||
reason: string
|
||||
}
|
||||
export interface ChecklistReviewResult {
|
||||
items: ChecklistReviewItem[]
|
||||
summary: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface ApiChecklistItem {
|
||||
id: string
|
||||
text: string
|
||||
done: boolean
|
||||
reviewVerdict: 'done' | 'missing' | null // AI 코드 검토 판정(미검토면 null)
|
||||
}
|
||||
|
||||
// 첨부파일(API 원시) — 백엔드 AttachmentResponse 와 1:1
|
||||
|
||||
Reference in New Issue
Block a user