From 5290c61178dfd33b54e6d019fd63412db649eb32 Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 14 Oct 2026 17:42:07 +0900 Subject: [PATCH] feat(ai): persist agent sessions and request context --- .../migration.sql | 68 +++ prisma/schema.prisma | 75 +++ src/ai/agent.controller.spec.ts | 19 + src/ai/agent.controller.ts | 18 +- src/ai/agent.service.spec.ts | 100 ++++ src/ai/agent.service.ts | 535 +++++++++++++++++- src/ai/ai.types.ts | 2 + src/ai/dto/agent-session.dto.spec.ts | 44 ++ src/ai/dto/agent-session.dto.ts | 32 ++ src/ai/dto/create-agent-plan.dto.ts | 10 + src/main.ts | 6 +- 11 files changed, 905 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20261014183000_add_agent_history/migration.sql create mode 100644 src/ai/agent.controller.spec.ts create mode 100644 src/ai/dto/agent-session.dto.spec.ts create mode 100644 src/ai/dto/agent-session.dto.ts diff --git a/prisma/migrations/20261014183000_add_agent_history/migration.sql b/prisma/migrations/20261014183000_add_agent_history/migration.sql new file mode 100644 index 0000000..e736163 --- /dev/null +++ b/prisma/migrations/20261014183000_add_agent_history/migration.sql @@ -0,0 +1,68 @@ +CREATE TABLE "agent_sessions" ( + "id" UUID NOT NULL, + "course_id" UUID NOT NULL, + "week_id" UUID, + "student_id" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "title" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + "last_activity_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "agent_sessions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "agent_messages" ( + "id" UUID NOT NULL, + "session_id" UUID NOT NULL, + "client_message_id" TEXT NOT NULL, + "role" TEXT NOT NULL, + "content" TEXT NOT NULL, + "provider" TEXT, + "model" TEXT, + "summary" TEXT, + "explanation" TEXT, + "action_plan" JSONB, + "create_module" JSONB, + "apply_state" TEXT, + "apply_error" TEXT, + "usage" JSONB, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + CONSTRAINT "agent_messages_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "agent_actions" ( + "id" UUID NOT NULL, + "message_id" UUID NOT NULL, + "client_key" TEXT NOT NULL, + "type" TEXT NOT NULL, + "status" TEXT NOT NULL, + "module_name" TEXT, + "path" TEXT, + "command" TEXT, + "purpose" TEXT, + "reason" TEXT, + "content" TEXT, + "output" TEXT, + "exit_code" INTEGER, + "error" TEXT, + "payload" JSONB, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + CONSTRAINT "agent_actions_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "agent_sessions_student_id_course_id_week_id_status_last_activity_at_idx" ON "agent_sessions"("student_id", "course_id", "week_id", "status", "last_activity_at"); +CREATE INDEX "agent_sessions_course_id_last_activity_at_idx" ON "agent_sessions"("course_id", "last_activity_at"); +CREATE UNIQUE INDEX "agent_messages_session_id_client_message_id_key" ON "agent_messages"("session_id", "client_message_id"); +CREATE INDEX "agent_messages_session_id_created_at_idx" ON "agent_messages"("session_id", "created_at"); +CREATE UNIQUE INDEX "agent_actions_message_id_client_key_key" ON "agent_actions"("message_id", "client_key"); +CREATE INDEX "agent_actions_message_id_sort_order_idx" ON "agent_actions"("message_id", "sort_order"); +CREATE INDEX "agent_actions_type_status_created_at_idx" ON "agent_actions"("type", "status", "created_at"); + +ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "agent_messages" ADD CONSTRAINT "agent_messages_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "agent_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "agent_actions" ADD CONSTRAINT "agent_actions_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "agent_messages"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5ccb8da..9b96548 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,6 +28,7 @@ model User { studentChatThreads ChatThread[] @relation("ChatThreadStudent") assignedChatThreads ChatThread[] @relation("ChatThreadInstructor") chatMessages ChatMessage[] + agentSessions AgentSession[] platformControlSessions PlatformControlSession[] platformControlQueueEntries PlatformControlQueueEntry[] studioRepositories StudioRepository[] @@ -177,6 +178,7 @@ model Course { studentProgress CourseStudentProgress[] studentWeekProgress CourseStudentWeekProgress[] chatThreads ChatThread[] + agentSessions AgentSession[] enrollments StudentEnrollment[] instructorCurriculumSettings InstructorCurriculumSetting[] instructorCurriculumWeekOverrides InstructorCurriculumWeekOverride[] @@ -579,6 +581,7 @@ model CourseWeek { references CourseWeekReference[] modules CourseWeekModule[] chatThreads ChatThread[] + agentSessions AgentSession[] submissionScores SubmissionScore[] submissionTotalScores SubmissionTotalScore[] @@ -835,3 +838,75 @@ model AiChatRun { @@index([status, createdAt]) @@map("ai_chat_runs") } + +model AgentSession { + id String @id @default(uuid()) @db.Uuid + courseId String @map("course_id") @db.Uuid + weekId String? @map("week_id") @db.Uuid + studentId String @map("student_id") @db.Uuid + status String @default("ACTIVE") + title String? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + lastActivityAt DateTime @default(now()) @map("last_activity_at") @db.Timestamptz(3) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + week CourseWeek? @relation(fields: [weekId], references: [id], onDelete: SetNull) + student User @relation(fields: [studentId], references: [id], onDelete: Cascade) + messages AgentMessage[] + + @@index([studentId, courseId, weekId, status, lastActivityAt]) + @@index([courseId, lastActivityAt]) + @@map("agent_sessions") +} + +model AgentMessage { + id String @id @default(uuid()) @db.Uuid + sessionId String @map("session_id") @db.Uuid + clientMessageId String @map("client_message_id") + role String + content String + provider String? + model String? + summary String? + explanation String? + actionPlan Json? @map("action_plan") + createModule Json? @map("create_module") + applyState String? @map("apply_state") + applyError String? @map("apply_error") + usage Json? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + session AgentSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + actions AgentAction[] + + @@unique([sessionId, clientMessageId]) + @@index([sessionId, createdAt]) + @@map("agent_messages") +} + +model AgentAction { + id String @id @default(uuid()) @db.Uuid + messageId String @map("message_id") @db.Uuid + clientKey String @map("client_key") + type String + status String + moduleName String? @map("module_name") + path String? + command String? + purpose String? + reason String? + content String? + output String? + exitCode Int? @map("exit_code") + error String? + payload Json? + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + message AgentMessage @relation(fields: [messageId], references: [id], onDelete: Cascade) + + @@unique([messageId, clientKey]) + @@index([messageId, sortOrder]) + @@index([type, status, createdAt]) + @@map("agent_actions") +} diff --git a/src/ai/agent.controller.spec.ts b/src/ai/agent.controller.spec.ts new file mode 100644 index 0000000..d81a572 --- /dev/null +++ b/src/ai/agent.controller.spec.ts @@ -0,0 +1,19 @@ +import 'reflect-metadata'; + +import { RequestMethod } from '@nestjs/common'; +import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; + +import { AgentController } from './agent.controller'; + +describe('AgentController', () => { + it.each([ + ['getSession', 'session', RequestMethod.GET], + ['syncSession', 'session/sync', RequestMethod.POST], + ['clearSession', 'session', RequestMethod.DELETE], + ] as const)('exposes %s through the expected route', (methodName, path, method) => { + const handler = AgentController.prototype[methodName]; + + expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe(path); + expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(method); + }); +}); diff --git a/src/ai/agent.controller.ts b/src/ai/agent.controller.ts index fb647ca..c127988 100644 --- a/src/ai/agent.controller.ts +++ b/src/ai/agent.controller.ts @@ -1,7 +1,8 @@ -import { Body, Controller, Get, Post, Query, Req } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Post, Query, Req } from '@nestjs/common'; import { Request } from 'express'; import { AgentService } from './agent.service'; +import { AgentSessionQueryDto, SyncAgentSessionDto } from './dto/agent-session.dto'; import { CreateAgentPlanDto } from './dto/create-agent-plan.dto'; @Controller('chat/agent') @@ -18,6 +19,21 @@ export class AgentController { return this.agentService.createPlan(this.getBearerToken(request), body); } + @Get('session') + getSession(@Req() request: Request, @Query() query: AgentSessionQueryDto) { + return this.agentService.getSession(this.getBearerToken(request), query); + } + + @Post('session/sync') + syncSession(@Req() request: Request, @Body() body: SyncAgentSessionDto) { + return this.agentService.syncSession(this.getBearerToken(request), body); + } + + @Delete('session') + clearSession(@Req() request: Request, @Query() query: AgentSessionQueryDto) { + return this.agentService.clearSession(this.getBearerToken(request), query); + } + private getBearerToken(request: Request): string | undefined { const [type, token] = request.headers.authorization?.split(' ') ?? []; return type === 'Bearer' ? token : undefined; diff --git a/src/ai/agent.service.spec.ts b/src/ai/agent.service.spec.ts index 79b0882..f6e6eff 100644 --- a/src/ai/agent.service.spec.ts +++ b/src/ai/agent.service.spec.ts @@ -118,4 +118,104 @@ describe('AgentService', () => { createModule: null, })).toThrow('위험한 시스템 명령'); }); + + it('parses source and log context requests without proposing actions', () => { + const plan = parsePlan({ + summary: '추가 컨텍스트가 필요합니다.', + explanation: '제어 모듈 소스와 실행 로그를 확인합니다.', + requestedModules: ['NAV2-Control'], + requestedLogs: ['NAV2-Control'], + actionPlan: ['소스와 로그 확인'], + commands: [], + changes: [], + createModule: null, + }) as unknown as { requestedModules: string[]; requestedLogs: string[] }; + + expect(plan.requestedModules).toEqual(['NAV2-Control']); + expect(plan.requestedLogs).toEqual(['NAV2-Control']); + }); + + it('rejects actions returned together with a context request', () => { + expect(() => parsePlan({ + summary: '소스 확인과 빌드를 동시에 요청합니다.', + explanation: '컨텍스트 요청 단계에서는 허용되지 않아야 합니다.', + requestedModules: ['NAV2-Control'], + requestedLogs: [], + actionPlan: ['소스 확인', '빌드'], + commands: [{ + id: 'command-1', + moduleName: 'NAV2-Control', + command: './build.sh', + purpose: '제어 모듈 빌드', + }], + changes: [], + createModule: null, + })).toThrow('소스나 로그를 요청하는 단계'); + }); + + it('serializes command results and file changes for persistence', () => { + const service = Object.create(AgentService.prototype) as AgentService; + const privateService = service as unknown as { + parseSyncedMessage: ( + value: Record, + index: number, + ) => unknown; + toStoredActions: (message: unknown) => Array>; + }; + const message = privateService.parseSyncedMessage( + { + id: 'agent-plan-1', + role: 'assistant', + content: '빌드와 파일 변경을 제안합니다.', + applyState: 'applied', + plan: { + summary: '제어 모듈을 수정합니다.', + explanation: '로그를 추가한 뒤 빌드합니다.', + actionPlan: ['로그 추가', '빌드'], + commands: [ + { + id: 'command-1', + moduleName: 'NAV2-Control', + command: './build.sh', + purpose: '제어 모듈 빌드', + }, + ], + changes: [ + { + moduleName: 'NAV2-Control', + path: 'src/control_pkg/control_pkg/control_manager.py', + content: 'print("started")\n', + reason: '시작 로그 추가', + }, + ], + createModule: null, + }, + commandExecutions: { + 'command-1': { + status: 'completed', + output: 'build complete', + exitCode: 0, + }, + }, + }, + 0, + ); + + expect(privateService.toStoredActions(message)).toEqual([ + expect.objectContaining({ + clientKey: 'command:command-1', + type: 'COMMAND', + status: 'completed', + output: 'build complete', + exitCode: 0, + }), + expect.objectContaining({ + clientKey: + 'change:NAV2-Control:src/control_pkg/control_pkg/control_manager.py', + type: 'FILE_CHANGE', + status: 'applied', + content: 'print("started")\n', + }), + ]); + }); }); diff --git a/src/ai/agent.service.ts b/src/ai/agent.service.ts index 6d14835..a983873 100644 --- a/src/ai/agent.service.ts +++ b/src/ai/agent.service.ts @@ -1,10 +1,12 @@ import { BadGatewayException, BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { AuthService, type AuthResponseUser } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { AiCredentialService } from './ai-credential.service'; import { AiProviderService } from './ai-provider.service'; import type { AgentCommand, AgentFileChange, AgentModulePlan, AgentPlan, AiProvider } from './ai.types'; +import { AgentSessionQueryDto, SyncAgentSessionDto } from './dto/agent-session.dto'; import { CreateAgentPlanDto } from './dto/create-agent-plan.dto'; const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'; @@ -13,6 +15,13 @@ const MAX_RUNTIME_LOG_CHARACTERS = 60_000; const MAX_CHANGE_COUNT = 24; const MAX_CHANGE_CONTENT_CHARACTERS = 200_000; const MAX_AGENT_COMMAND_COUNT = 6; +const MAX_SYNC_PAYLOAD_CHARACTERS = 5_000_000; +const AGENT_EXECUTION_STATUSES = new Set(['pending', 'running', 'completed', 'failed', 'rejected']); +const AGENT_APPLY_STATES = new Set(['ready', 'applying', 'applied', 'error']); + +type AgentSessionRecord = Prisma.AgentSessionGetPayload<{ + include: { messages: { include: { actions: true } } }; +}>; @Injectable() export class AgentService { @@ -34,6 +43,188 @@ export class AgentService { }; } + async getSession( + accessToken: string | undefined, + query: AgentSessionQueryDto, + ) { + const scope = await this.getAgentScope( + accessToken, + query.courseId, + query.week, + ); + const session = await this.prisma.agentSession.findFirst({ + where: { + studentId: scope.student.id, + courseId: scope.course.id, + weekId: scope.week?.id ?? null, + status: 'ACTIVE', + }, + orderBy: { lastActivityAt: 'desc' }, + include: { + messages: { + orderBy: { createdAt: 'asc' }, + include: { actions: { orderBy: { sortOrder: 'asc' } } }, + }, + }, + }); + return this.toSessionResponse(session); + } + + async syncSession(accessToken: string | undefined, dto: SyncAgentSessionDto) { + if (JSON.stringify(dto.messages).length > MAX_SYNC_PAYLOAD_CHARACTERS) { + throw new BadRequestException( + '에이전트 대화 동기화 데이터가 너무 큽니다.', + ); + } + const scope = await this.getAgentScope(accessToken, dto.courseId, dto.week); + const messages = dto.messages.map((message, index) => + this.parseSyncedMessage(message, index), + ); + const sessionId = await this.prisma.$transaction(async (tx) => { + let session = await tx.agentSession.findFirst({ + where: { + studentId: scope.student.id, + courseId: scope.course.id, + weekId: scope.week?.id ?? null, + status: 'ACTIVE', + }, + orderBy: { lastActivityAt: 'desc' }, + }); + const firstUserMessage = messages.find( + (message) => message.role === 'user', + ); + if (!session) { + session = await tx.agentSession.create({ + data: { + studentId: scope.student.id, + courseId: scope.course.id, + weekId: scope.week?.id ?? null, + title: firstUserMessage?.content.slice(0, 120) || null, + }, + }); + } + + for (const message of messages) { + const plan = message.plan; + const storedMessage = await tx.agentMessage.upsert({ + where: { + sessionId_clientMessageId: { + sessionId: session.id, + clientMessageId: message.id, + }, + }, + create: { + sessionId: session.id, + clientMessageId: message.id, + role: message.role, + content: message.content, + provider: message.provider, + model: message.model, + summary: plan?.summary ?? null, + explanation: plan?.explanation ?? null, + actionPlan: plan + ? (plan.actionPlan as Prisma.InputJsonValue) + : Prisma.DbNull, + createModule: plan?.createModule + ? (plan.createModule as unknown as Prisma.InputJsonValue) + : Prisma.DbNull, + applyState: message.applyState, + applyError: message.applyError, + usage: message.usage + ? (message.usage as Prisma.InputJsonValue) + : Prisma.DbNull, + }, + update: { + content: message.content, + provider: message.provider, + model: message.model, + summary: plan?.summary ?? null, + explanation: plan?.explanation ?? null, + actionPlan: plan + ? (plan.actionPlan as Prisma.InputJsonValue) + : Prisma.DbNull, + createModule: plan?.createModule + ? (plan.createModule as unknown as Prisma.InputJsonValue) + : Prisma.DbNull, + applyState: message.applyState, + applyError: message.applyError, + usage: message.usage + ? (message.usage as Prisma.InputJsonValue) + : Prisma.DbNull, + }, + }); + + const actions = this.toStoredActions(message); + await tx.agentAction.deleteMany({ + where: { + messageId: storedMessage.id, + ...(actions.length + ? { + clientKey: { + notIn: actions.map((action) => action.clientKey), + }, + } + : {}), + }, + }); + for (const action of actions) { + await tx.agentAction.upsert({ + where: { + messageId_clientKey: { + messageId: storedMessage.id, + clientKey: action.clientKey, + }, + }, + create: { messageId: storedMessage.id, ...action }, + update: action, + }); + } + } + + await tx.agentSession.update({ + where: { id: session.id }, + data: { + title: + session.title ?? firstUserMessage?.content.slice(0, 120) ?? null, + lastActivityAt: new Date(), + }, + }); + return session.id; + }); + + const session = await this.prisma.agentSession.findUnique({ + where: { id: sessionId }, + include: { + messages: { + orderBy: { createdAt: 'asc' }, + include: { actions: { orderBy: { sortOrder: 'asc' } } }, + }, + }, + }); + return this.toSessionResponse(session); + } + + async clearSession( + accessToken: string | undefined, + query: AgentSessionQueryDto, + ) { + const scope = await this.getAgentScope( + accessToken, + query.courseId, + query.week, + ); + const result = await this.prisma.agentSession.updateMany({ + where: { + studentId: scope.student.id, + courseId: scope.course.id, + weekId: scope.week?.id ?? null, + status: 'ACTIVE', + }, + data: { status: 'ARCHIVED', lastActivityAt: new Date() }, + }); + return { archivedCount: result.count }; + } + async createPlan(accessToken: string | undefined, dto: CreateAgentPlanDto) { const student = await this.getLmsUser(accessToken); const course = await this.getStudentCourse(student.id, dto.courseId); @@ -48,6 +239,8 @@ export class AgentService { const modules = dto.modules.map((module) => ({ name: module.name.trim(), folderName: module.folderName?.trim() || null, + category: module.category?.trim() || null, + state: module.state?.trim() || null, files: module.files.map((file) => ({ path: this.safeRelativePath(file.path), content: file.content, @@ -94,6 +287,323 @@ export class AgentService { return { provider: setting.provider, model: setting.model, usage: response.usage, plan }; } + private parseSyncedMessage(value: Record, index: number) { + const id = this.syncText(value.id, `messages[${index}].id`, 200); + if (!/^[A-Za-z0-9:._-]+$/.test(id)) + throw new BadRequestException( + `messages[${index}].id 형식이 올바르지 않습니다.`, + ); + const role = this.syncText(value.role, `messages[${index}].role`, 20); + if (role !== 'user' && role !== 'assistant') + throw new BadRequestException( + `messages[${index}].role 값이 올바르지 않습니다.`, + ); + const content = this.syncText( + value.content, + `messages[${index}].content`, + 10_000, + true, + ); + const rawPlan = + value.plan && typeof value.plan === 'object' && !Array.isArray(value.plan) + ? (value.plan as Record) + : null; + let plan: AgentPlan | null = null; + if (rawPlan) { + const moduleNames = new Set(); + for (const command of Array.isArray(rawPlan.commands) + ? rawPlan.commands + : []) { + if ( + command && + typeof command === 'object' && + !Array.isArray(command) && + typeof (command as Record).moduleName === 'string' + ) { + moduleNames.add( + (command as Record).moduleName as string, + ); + } + } + for (const change of Array.isArray(rawPlan.changes) + ? rawPlan.changes + : []) { + if ( + change && + typeof change === 'object' && + !Array.isArray(change) && + typeof (change as Record).moduleName === 'string' + ) { + moduleNames.add( + (change as Record).moduleName as string, + ); + } + } + plan = this.parsePlan(JSON.stringify(rawPlan), moduleNames); + } + const rawExecutions = + value.commandExecutions && + typeof value.commandExecutions === 'object' && + !Array.isArray(value.commandExecutions) + ? (value.commandExecutions as Record) + : {}; + const commandExecutions: Record< + string, + { + status: string; + output?: string; + exitCode?: number | null; + error?: string; + } + > = {}; + for (const command of plan?.commands ?? []) { + const rawExecution = rawExecutions[command.id]; + if ( + !rawExecution || + typeof rawExecution !== 'object' || + Array.isArray(rawExecution) + ) + continue; + const execution = rawExecution as Record; + const status = + typeof execution.status === 'string' && + AGENT_EXECUTION_STATUSES.has(execution.status) + ? execution.status + : 'pending'; + commandExecutions[command.id] = { + status, + output: + typeof execution.output === 'string' + ? execution.output.slice(-80_000) + : undefined, + exitCode: + typeof execution.exitCode === 'number' && + Number.isInteger(execution.exitCode) + ? execution.exitCode + : null, + error: + typeof execution.error === 'string' + ? execution.error.slice(0, 2_000) + : undefined, + }; + } + const applyState = + typeof value.applyState === 'string' && + AGENT_APPLY_STATES.has(value.applyState) + ? value.applyState + : null; + const usage = + value.usage && + typeof value.usage === 'object' && + !Array.isArray(value.usage) + ? Object.fromEntries( + Object.entries(value.usage as Record).filter( + ([, item]) => typeof item === 'number', + ), + ) + : null; + return { + id, + role, + content, + provider: + typeof value.provider === 'string' ? value.provider.slice(0, 80) : null, + model: typeof value.model === 'string' ? value.model.slice(0, 200) : null, + plan, + applyState, + applyError: + typeof value.applyError === 'string' + ? value.applyError.slice(0, 2_000) + : null, + commandExecutions, + usage: usage && Object.keys(usage).length ? usage : null, + }; + } + + private toStoredActions( + message: ReturnType, + ) { + if (!message.plan) return []; + const plan = message.plan; + const actions: Array<{ + clientKey: string; + type: string; + status: string; + moduleName: string | null; + path: string | null; + command: string | null; + purpose: string | null; + reason: string | null; + content: string | null; + output: string | null; + exitCode: number | null; + error: string | null; + payload: Prisma.InputJsonValue | typeof Prisma.DbNull; + sortOrder: number; + }> = []; + plan.commands.forEach((command, index) => { + const execution = message.commandExecutions[command.id]; + actions.push({ + clientKey: `command:${command.id}`, + type: 'COMMAND', + status: execution?.status ?? 'pending', + moduleName: command.moduleName, + path: null, + command: command.command, + purpose: command.purpose, + reason: null, + content: null, + output: execution?.output ?? null, + exitCode: execution?.exitCode ?? null, + error: execution?.error ?? null, + payload: Prisma.DbNull, + sortOrder: index, + }); + }); + plan.changes.forEach((change, index) => { + actions.push({ + clientKey: `change:${change.moduleName}:${change.path}`, + type: 'FILE_CHANGE', + status: message.applyState ?? 'ready', + moduleName: change.moduleName, + path: change.path, + command: null, + purpose: null, + reason: change.reason, + content: change.content, + output: null, + exitCode: null, + error: message.applyError, + payload: Prisma.DbNull, + sortOrder: plan.commands.length + index, + }); + }); + if (plan.createModule) { + actions.push({ + clientKey: `module:${plan.createModule.name}`, + type: 'MODULE_CREATE', + status: message.applyState ?? 'ready', + moduleName: plan.createModule.name, + path: null, + command: null, + purpose: null, + reason: null, + content: null, + output: null, + exitCode: null, + error: message.applyError, + payload: plan.createModule as unknown as Prisma.InputJsonValue, + sortOrder: plan.commands.length + plan.changes.length, + }); + } + return actions; + } + + private toSessionResponse(session: AgentSessionRecord | null) { + if (!session) return { sessionId: null, messages: [] }; + return { + sessionId: session.id, + messages: session.messages.map((message) => { + const commandActions = message.actions.filter( + (action) => action.type === 'COMMAND', + ); + const changeActions = message.actions.filter( + (action) => action.type === 'FILE_CHANGE', + ); + const moduleAction = message.actions.find( + (action) => action.type === 'MODULE_CREATE', + ); + const hasPlan = + message.summary !== null && message.explanation !== null; + return { + id: message.clientMessageId, + role: message.role, + content: message.content, + provider: message.provider ?? undefined, + model: message.model ?? undefined, + usage: + message.usage && typeof message.usage === 'object' + ? message.usage + : undefined, + plan: hasPlan + ? { + summary: message.summary, + explanation: message.explanation, + requestedModules: [], + requestedLogs: [], + actionPlan: Array.isArray(message.actionPlan) + ? message.actionPlan + : [], + commands: commandActions.map((action) => ({ + id: action.clientKey.replace(/^command:/, ''), + moduleName: action.moduleName ?? '', + command: action.command ?? '', + purpose: action.purpose ?? '', + })), + changes: changeActions.map((action) => ({ + moduleName: action.moduleName ?? '', + path: action.path ?? '', + content: action.content ?? '', + reason: action.reason ?? '', + })), + createModule: + moduleAction?.payload && + typeof moduleAction.payload === 'object' + ? moduleAction.payload + : null, + } + : undefined, + applyState: message.applyState ?? undefined, + applyError: message.applyError ?? undefined, + commandExecutions: Object.fromEntries( + commandActions.map((action) => [ + action.clientKey.replace(/^command:/, ''), + { + status: action.status, + output: action.output ?? undefined, + exitCode: action.exitCode, + error: action.error ?? undefined, + }, + ]), + ), + }; + }), + }; + } + + private syncText( + value: unknown, + field: string, + maxLength: number, + allowEmpty = false, + ): string { + if ( + typeof value !== 'string' || + (!allowEmpty && !value.trim()) || + value.length > maxLength + ) { + throw new BadRequestException(`${field} 값이 올바르지 않습니다.`); + } + return allowEmpty ? value : value.trim(); + } + + private async getAgentScope( + accessToken: string | undefined, + courseId: string, + weekNumber?: number, + ) { + const student = await this.getLmsUser(accessToken); + const course = await this.getStudentCourse(student.id, courseId); + const week = weekNumber + ? await this.prisma.courseWeek.findUnique({ + where: { courseId_week: { courseId: course.id, week: weekNumber } }, + }) + : null; + if (weekNumber && !week) + throw new NotFoundException('강좌 주차를 찾을 수 없습니다.'); + return { student, course, week }; + } + private parsePlan(content: string, currentModules: Set): AgentPlan { const normalized = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); let value: unknown; @@ -106,6 +616,20 @@ export class AgentService { throw new BadGatewayException('AI 변경 계획 형식이 올바르지 않습니다.'); } const input = value as Record; + const rawRequestedModules = Array.isArray(input.requestedModules) ? input.requestedModules : []; + if (rawRequestedModules.length > 4) throw new BadGatewayException('한 번에 4개 이하의 모듈 소스만 요청할 수 있습니다.'); + const requestedModules = rawRequestedModules.map((moduleName, index) => { + const name = this.requiredText(moduleName, `requestedModules[${index}]`, 120); + if (!currentModules.has(name)) throw new BadGatewayException(`존재하지 않는 모듈 소스 요청입니다: ${name}`); + return name; + }); + const rawRequestedLogs = Array.isArray(input.requestedLogs) ? input.requestedLogs : []; + if (rawRequestedLogs.length > 3) throw new BadGatewayException('한 번에 3개 이하의 모듈 로그만 요청할 수 있습니다.'); + const requestedLogs = rawRequestedLogs.map((moduleName, index) => { + const name = this.requiredText(moduleName, `requestedLogs[${index}]`, 120); + if (!currentModules.has(name)) throw new BadGatewayException(`존재하지 않는 모듈 로그 요청입니다: ${name}`); + return name; + }); const createModule = this.parseModule(input.createModule); const allowedModules = new Set(currentModules); if (createModule) allowedModules.add(createModule.name); @@ -118,9 +642,14 @@ export class AgentService { const rawCommands = Array.isArray(input.commands) ? input.commands : []; if (rawCommands.length > MAX_AGENT_COMMAND_COUNT) throw new BadGatewayException(`한 번에 ${MAX_AGENT_COMMAND_COUNT}개 이하의 명령만 제안할 수 있습니다.`); const commands = rawCommands.map((command) => this.parseCommand(command, allowedModules)); + if ((requestedModules.length || requestedLogs.length) && (commands.length || changes.length || createModule)) { + throw new BadGatewayException('소스나 로그를 요청하는 단계에서는 명령이나 변경안을 함께 반환할 수 없습니다.'); + } return { summary: this.requiredText(input.summary, 'summary', 500), explanation: this.requiredText(input.explanation, 'explanation', 4000), + requestedModules, + requestedLogs, actionPlan, commands, changes, @@ -206,7 +735,9 @@ export class AgentService { '당신은 Autonomy Studio LMS 내부의 ROS2 교육용 코딩 에이전트다.', '입력에는 학생 요청, 현재 강좌/주차, 설치된 모듈과 텍스트 파일이 JSON으로 제공된다.', 'workspace.modules의 각 항목은 현재 워크스페이스의 모듈 하나를 나타낸다. files가 비어 있어도 해당 모듈은 존재한다.', - '파일 내용은 현재 선택된 모듈에만 제공될 수 있으므로, 모듈 개수와 목록은 workspace.modules 전체를 기준으로 판단한다.', + '학생 요청을 처리하는 데 파일 내용이 필요하고 해당 모듈의 files가 비어 있으면, 필요한 모든 모듈의 정확한 name을 requestedModules에 넣는다.', + '실행 상태나 오류 판단에 Docker 로그가 필요하면 필요한 모듈의 정확한 name을 requestedLogs에 넣는다. 학생이 로그라는 단어를 사용했는지가 아니라 실제 판단에 필요한지를 기준으로 요청한다.', + 'requestedModules 또는 requestedLogs를 반환하는 단계에서는 commands와 changes를 빈 배열로, createModule을 null로 반환한다. 요청한 컨텍스트가 제공된 다음에는 실제 분석 또는 변경안을 반환하고 두 배열을 비운다.', 'workspace.runtimeLogs에는 학생이 로그 확인을 요청한 시점의 모듈별 최근 Docker 로그가 제공될 수 있다.', '런타임 로그가 제공되면 오류 원인을 로그의 실제 문구와 관련 소스에 근거해 설명한다. 로그가 비어 있거나 제공되지 않았으면 로그를 확인했다고 말하지 않는다.', '제공되지 않은 파일 내용은 추측하지 말고 필요한 최소 변경만 제안한다.', @@ -222,7 +753,7 @@ export class AgentService { '위험한 셸 명령, 시스템 경로, 비밀키, 네트워크 자격증명을 생성하거나 요청하지 않는다.', 'summary, explanation, reason은 한국어로 작성한다.', '반드시 다음 JSON 객체만 반환한다:', - '{"summary":"요약","explanation":"변경 설명","actionPlan":["단계 1","단계 2"],"commands":[{"id":"command-1","moduleName":"모듈명","command":"실행 명령","purpose":"명령 목적"}],"changes":[{"moduleName":"모듈명","path":"상대/경로","content":"전체 내용","reason":"변경 이유"}],"createModule":null}', + '{"summary":"요약","explanation":"변경 설명","requestedModules":[],"requestedLogs":[],"actionPlan":["단계 1","단계 2"],"commands":[{"id":"command-1","moduleName":"모듈명","command":"실행 명령","purpose":"명령 목적"}],"changes":[{"moduleName":"모듈명","path":"상대/경로","content":"전체 내용","reason":"변경 이유"}],"createModule":null}', '새 모듈이면 createModule은 {"name","category","folderName","dockerImage","packageName","launchFile"} 객체다.', ].join('\n'); } diff --git a/src/ai/ai.types.ts b/src/ai/ai.types.ts index 1cff13a..f9e929d 100644 --- a/src/ai/ai.types.ts +++ b/src/ai/ai.types.ts @@ -38,6 +38,8 @@ export type AgentCommand = { export type AgentPlan = { summary: string; explanation: string; + requestedModules: string[]; + requestedLogs: string[]; actionPlan: string[]; commands: AgentCommand[]; changes: AgentFileChange[]; diff --git a/src/ai/dto/agent-session.dto.spec.ts b/src/ai/dto/agent-session.dto.spec.ts new file mode 100644 index 0000000..d9600f2 --- /dev/null +++ b/src/ai/dto/agent-session.dto.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { AgentSessionQueryDto, SyncAgentSessionDto } from './agent-session.dto'; + +describe('AgentSessionDto', () => { + const courseId = '019f3689-4fe2-7b93-a7c4-72e51fe3ec17'; + + it('transforms and validates the requested course week', async () => { + const dto = plainToInstance(AgentSessionQueryDto, { courseId, week: '3' }); + + expect(await validate(dto)).toHaveLength(0); + expect(dto.week).toBe(3); + }); + + it('accepts bounded agent messages for synchronization', async () => { + const dto = plainToInstance(SyncAgentSessionDto, { + courseId, + week: 1, + messages: [ + { id: 'agent-user-1', role: 'user', content: '빌드 로그를 확인해줘' }, + ], + }); + + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects more than 100 synchronized messages', async () => { + const dto = plainToInstance(SyncAgentSessionDto, { + courseId, + messages: Array.from({ length: 101 }, (_, index) => ({ + id: `agent-user-${index}`, + role: 'user', + content: 'test', + })), + }); + + const errors = await validate(dto); + + expect(errors.some((error) => error.property === 'messages')).toBe(true); + }); +}); diff --git a/src/ai/dto/agent-session.dto.ts b/src/ai/dto/agent-session.dto.ts new file mode 100644 index 0000000..fbbc0c4 --- /dev/null +++ b/src/ai/dto/agent-session.dto.ts @@ -0,0 +1,32 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsInt, + IsObject, + IsOptional, + IsUUID, + Max, + Min, +} from 'class-validator'; + +export class AgentSessionQueryDto { + @IsUUID() + courseId: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + week?: number; +} + +export class SyncAgentSessionDto extends AgentSessionQueryDto { + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(100) + @IsObject({ each: true }) + messages: Array>; +} diff --git a/src/ai/dto/create-agent-plan.dto.ts b/src/ai/dto/create-agent-plan.dto.ts index 225ea3a..ce829ce 100644 --- a/src/ai/dto/create-agent-plan.dto.ts +++ b/src/ai/dto/create-agent-plan.dto.ts @@ -21,6 +21,16 @@ class AgentWorkspaceModuleDto { @MaxLength(120) folderName?: string; + @IsOptional() + @IsString() + @MaxLength(80) + category?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + state?: string; + @IsArray() @ArrayMaxSize(24) @ValidateNested({ each: true }) diff --git a/src/main.ts b/src/main.ts index dd713be..335a58d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,15 @@ import { RequestMethod, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { ConfigService } from '@nestjs/config'; +import { json, urlencoded } from 'express'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { bodyParser: false }); + app.use('/api/chat/agent/session/sync', json({ limit: '16mb' })); + app.use(json({ limit: '1mb' })); + app.use(urlencoded({ extended: true, limit: '1mb' })); const configService = app.get(ConfigService); const port = configService.get('PORT', 4400); const corsOrigin = configService.get(