feat(ai): persist agent sessions and request context
This commit is contained in:
@@ -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;
|
||||||
@@ -28,6 +28,7 @@ model User {
|
|||||||
studentChatThreads ChatThread[] @relation("ChatThreadStudent")
|
studentChatThreads ChatThread[] @relation("ChatThreadStudent")
|
||||||
assignedChatThreads ChatThread[] @relation("ChatThreadInstructor")
|
assignedChatThreads ChatThread[] @relation("ChatThreadInstructor")
|
||||||
chatMessages ChatMessage[]
|
chatMessages ChatMessage[]
|
||||||
|
agentSessions AgentSession[]
|
||||||
platformControlSessions PlatformControlSession[]
|
platformControlSessions PlatformControlSession[]
|
||||||
platformControlQueueEntries PlatformControlQueueEntry[]
|
platformControlQueueEntries PlatformControlQueueEntry[]
|
||||||
studioRepositories StudioRepository[]
|
studioRepositories StudioRepository[]
|
||||||
@@ -177,6 +178,7 @@ model Course {
|
|||||||
studentProgress CourseStudentProgress[]
|
studentProgress CourseStudentProgress[]
|
||||||
studentWeekProgress CourseStudentWeekProgress[]
|
studentWeekProgress CourseStudentWeekProgress[]
|
||||||
chatThreads ChatThread[]
|
chatThreads ChatThread[]
|
||||||
|
agentSessions AgentSession[]
|
||||||
enrollments StudentEnrollment[]
|
enrollments StudentEnrollment[]
|
||||||
instructorCurriculumSettings InstructorCurriculumSetting[]
|
instructorCurriculumSettings InstructorCurriculumSetting[]
|
||||||
instructorCurriculumWeekOverrides InstructorCurriculumWeekOverride[]
|
instructorCurriculumWeekOverrides InstructorCurriculumWeekOverride[]
|
||||||
@@ -579,6 +581,7 @@ model CourseWeek {
|
|||||||
references CourseWeekReference[]
|
references CourseWeekReference[]
|
||||||
modules CourseWeekModule[]
|
modules CourseWeekModule[]
|
||||||
chatThreads ChatThread[]
|
chatThreads ChatThread[]
|
||||||
|
agentSessions AgentSession[]
|
||||||
submissionScores SubmissionScore[]
|
submissionScores SubmissionScore[]
|
||||||
submissionTotalScores SubmissionTotalScore[]
|
submissionTotalScores SubmissionTotalScore[]
|
||||||
|
|
||||||
@@ -835,3 +838,75 @@ model AiChatRun {
|
|||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@map("ai_chat_runs")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { Request } from 'express';
|
||||||
|
|
||||||
import { AgentService } from './agent.service';
|
import { AgentService } from './agent.service';
|
||||||
|
import { AgentSessionQueryDto, SyncAgentSessionDto } from './dto/agent-session.dto';
|
||||||
import { CreateAgentPlanDto } from './dto/create-agent-plan.dto';
|
import { CreateAgentPlanDto } from './dto/create-agent-plan.dto';
|
||||||
|
|
||||||
@Controller('chat/agent')
|
@Controller('chat/agent')
|
||||||
@@ -18,6 +19,21 @@ export class AgentController {
|
|||||||
return this.agentService.createPlan(this.getBearerToken(request), body);
|
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 {
|
private getBearerToken(request: Request): string | undefined {
|
||||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
return type === 'Bearer' ? token : undefined;
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
|||||||
@@ -118,4 +118,104 @@ describe('AgentService', () => {
|
|||||||
createModule: null,
|
createModule: null,
|
||||||
})).toThrow('위험한 시스템 명령');
|
})).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<string, unknown>,
|
||||||
|
index: number,
|
||||||
|
) => unknown;
|
||||||
|
toStoredActions: (message: unknown) => Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
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',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+533
-2
@@ -1,10 +1,12 @@
|
|||||||
import { BadGatewayException, BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
import { BadGatewayException, BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { AuthService, type AuthResponseUser } from '../auth/auth.service';
|
import { AuthService, type AuthResponseUser } from '../auth/auth.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AiCredentialService } from './ai-credential.service';
|
import { AiCredentialService } from './ai-credential.service';
|
||||||
import { AiProviderService } from './ai-provider.service';
|
import { AiProviderService } from './ai-provider.service';
|
||||||
import type { AgentCommand, AgentFileChange, AgentModulePlan, AgentPlan, AiProvider } from './ai.types';
|
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';
|
import { CreateAgentPlanDto } from './dto/create-agent-plan.dto';
|
||||||
|
|
||||||
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
|
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_COUNT = 24;
|
||||||
const MAX_CHANGE_CONTENT_CHARACTERS = 200_000;
|
const MAX_CHANGE_CONTENT_CHARACTERS = 200_000;
|
||||||
const MAX_AGENT_COMMAND_COUNT = 6;
|
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()
|
@Injectable()
|
||||||
export class AgentService {
|
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) {
|
async createPlan(accessToken: string | undefined, dto: CreateAgentPlanDto) {
|
||||||
const student = await this.getLmsUser(accessToken);
|
const student = await this.getLmsUser(accessToken);
|
||||||
const course = await this.getStudentCourse(student.id, dto.courseId);
|
const course = await this.getStudentCourse(student.id, dto.courseId);
|
||||||
@@ -48,6 +239,8 @@ export class AgentService {
|
|||||||
const modules = dto.modules.map((module) => ({
|
const modules = dto.modules.map((module) => ({
|
||||||
name: module.name.trim(),
|
name: module.name.trim(),
|
||||||
folderName: module.folderName?.trim() || null,
|
folderName: module.folderName?.trim() || null,
|
||||||
|
category: module.category?.trim() || null,
|
||||||
|
state: module.state?.trim() || null,
|
||||||
files: module.files.map((file) => ({
|
files: module.files.map((file) => ({
|
||||||
path: this.safeRelativePath(file.path),
|
path: this.safeRelativePath(file.path),
|
||||||
content: file.content,
|
content: file.content,
|
||||||
@@ -94,6 +287,323 @@ export class AgentService {
|
|||||||
return { provider: setting.provider, model: setting.model, usage: response.usage, plan };
|
return { provider: setting.provider, model: setting.model, usage: response.usage, plan };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseSyncedMessage(value: Record<string, unknown>, 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<string, unknown>)
|
||||||
|
: null;
|
||||||
|
let plan: AgentPlan | null = null;
|
||||||
|
if (rawPlan) {
|
||||||
|
const moduleNames = new Set<string>();
|
||||||
|
for (const command of Array.isArray(rawPlan.commands)
|
||||||
|
? rawPlan.commands
|
||||||
|
: []) {
|
||||||
|
if (
|
||||||
|
command &&
|
||||||
|
typeof command === 'object' &&
|
||||||
|
!Array.isArray(command) &&
|
||||||
|
typeof (command as Record<string, unknown>).moduleName === 'string'
|
||||||
|
) {
|
||||||
|
moduleNames.add(
|
||||||
|
(command as Record<string, unknown>).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<string, unknown>).moduleName === 'string'
|
||||||
|
) {
|
||||||
|
moduleNames.add(
|
||||||
|
(change as Record<string, unknown>).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<string, unknown>)
|
||||||
|
: {};
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>).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<AgentService['parseSyncedMessage']>,
|
||||||
|
) {
|
||||||
|
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<string>): AgentPlan {
|
private parsePlan(content: string, currentModules: Set<string>): AgentPlan {
|
||||||
const normalized = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
const normalized = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
||||||
let value: unknown;
|
let value: unknown;
|
||||||
@@ -106,6 +616,20 @@ export class AgentService {
|
|||||||
throw new BadGatewayException('AI 변경 계획 형식이 올바르지 않습니다.');
|
throw new BadGatewayException('AI 변경 계획 형식이 올바르지 않습니다.');
|
||||||
}
|
}
|
||||||
const input = value as Record<string, unknown>;
|
const input = value as Record<string, unknown>;
|
||||||
|
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 createModule = this.parseModule(input.createModule);
|
||||||
const allowedModules = new Set(currentModules);
|
const allowedModules = new Set(currentModules);
|
||||||
if (createModule) allowedModules.add(createModule.name);
|
if (createModule) allowedModules.add(createModule.name);
|
||||||
@@ -118,9 +642,14 @@ export class AgentService {
|
|||||||
const rawCommands = Array.isArray(input.commands) ? input.commands : [];
|
const rawCommands = Array.isArray(input.commands) ? input.commands : [];
|
||||||
if (rawCommands.length > MAX_AGENT_COMMAND_COUNT) throw new BadGatewayException(`한 번에 ${MAX_AGENT_COMMAND_COUNT}개 이하의 명령만 제안할 수 있습니다.`);
|
if (rawCommands.length > MAX_AGENT_COMMAND_COUNT) throw new BadGatewayException(`한 번에 ${MAX_AGENT_COMMAND_COUNT}개 이하의 명령만 제안할 수 있습니다.`);
|
||||||
const commands = rawCommands.map((command) => this.parseCommand(command, allowedModules));
|
const commands = rawCommands.map((command) => this.parseCommand(command, allowedModules));
|
||||||
|
if ((requestedModules.length || requestedLogs.length) && (commands.length || changes.length || createModule)) {
|
||||||
|
throw new BadGatewayException('소스나 로그를 요청하는 단계에서는 명령이나 변경안을 함께 반환할 수 없습니다.');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
summary: this.requiredText(input.summary, 'summary', 500),
|
summary: this.requiredText(input.summary, 'summary', 500),
|
||||||
explanation: this.requiredText(input.explanation, 'explanation', 4000),
|
explanation: this.requiredText(input.explanation, 'explanation', 4000),
|
||||||
|
requestedModules,
|
||||||
|
requestedLogs,
|
||||||
actionPlan,
|
actionPlan,
|
||||||
commands,
|
commands,
|
||||||
changes,
|
changes,
|
||||||
@@ -206,7 +735,9 @@ export class AgentService {
|
|||||||
'당신은 Autonomy Studio LMS 내부의 ROS2 교육용 코딩 에이전트다.',
|
'당신은 Autonomy Studio LMS 내부의 ROS2 교육용 코딩 에이전트다.',
|
||||||
'입력에는 학생 요청, 현재 강좌/주차, 설치된 모듈과 텍스트 파일이 JSON으로 제공된다.',
|
'입력에는 학생 요청, 현재 강좌/주차, 설치된 모듈과 텍스트 파일이 JSON으로 제공된다.',
|
||||||
'workspace.modules의 각 항목은 현재 워크스페이스의 모듈 하나를 나타낸다. files가 비어 있어도 해당 모듈은 존재한다.',
|
'workspace.modules의 각 항목은 현재 워크스페이스의 모듈 하나를 나타낸다. files가 비어 있어도 해당 모듈은 존재한다.',
|
||||||
'파일 내용은 현재 선택된 모듈에만 제공될 수 있으므로, 모듈 개수와 목록은 workspace.modules 전체를 기준으로 판단한다.',
|
'학생 요청을 처리하는 데 파일 내용이 필요하고 해당 모듈의 files가 비어 있으면, 필요한 모든 모듈의 정확한 name을 requestedModules에 넣는다.',
|
||||||
|
'실행 상태나 오류 판단에 Docker 로그가 필요하면 필요한 모듈의 정확한 name을 requestedLogs에 넣는다. 학생이 로그라는 단어를 사용했는지가 아니라 실제 판단에 필요한지를 기준으로 요청한다.',
|
||||||
|
'requestedModules 또는 requestedLogs를 반환하는 단계에서는 commands와 changes를 빈 배열로, createModule을 null로 반환한다. 요청한 컨텍스트가 제공된 다음에는 실제 분석 또는 변경안을 반환하고 두 배열을 비운다.',
|
||||||
'workspace.runtimeLogs에는 학생이 로그 확인을 요청한 시점의 모듈별 최근 Docker 로그가 제공될 수 있다.',
|
'workspace.runtimeLogs에는 학생이 로그 확인을 요청한 시점의 모듈별 최근 Docker 로그가 제공될 수 있다.',
|
||||||
'런타임 로그가 제공되면 오류 원인을 로그의 실제 문구와 관련 소스에 근거해 설명한다. 로그가 비어 있거나 제공되지 않았으면 로그를 확인했다고 말하지 않는다.',
|
'런타임 로그가 제공되면 오류 원인을 로그의 실제 문구와 관련 소스에 근거해 설명한다. 로그가 비어 있거나 제공되지 않았으면 로그를 확인했다고 말하지 않는다.',
|
||||||
'제공되지 않은 파일 내용은 추측하지 말고 필요한 최소 변경만 제안한다.',
|
'제공되지 않은 파일 내용은 추측하지 말고 필요한 최소 변경만 제안한다.',
|
||||||
@@ -222,7 +753,7 @@ export class AgentService {
|
|||||||
'위험한 셸 명령, 시스템 경로, 비밀키, 네트워크 자격증명을 생성하거나 요청하지 않는다.',
|
'위험한 셸 명령, 시스템 경로, 비밀키, 네트워크 자격증명을 생성하거나 요청하지 않는다.',
|
||||||
'summary, explanation, reason은 한국어로 작성한다.',
|
'summary, explanation, reason은 한국어로 작성한다.',
|
||||||
'반드시 다음 JSON 객체만 반환한다:',
|
'반드시 다음 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"} 객체다.',
|
'새 모듈이면 createModule은 {"name","category","folderName","dockerImage","packageName","launchFile"} 객체다.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export type AgentCommand = {
|
|||||||
export type AgentPlan = {
|
export type AgentPlan = {
|
||||||
summary: string;
|
summary: string;
|
||||||
explanation: string;
|
explanation: string;
|
||||||
|
requestedModules: string[];
|
||||||
|
requestedLogs: string[];
|
||||||
actionPlan: string[];
|
actionPlan: string[];
|
||||||
commands: AgentCommand[];
|
commands: AgentCommand[];
|
||||||
changes: AgentFileChange[];
|
changes: AgentFileChange[];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<string, unknown>>;
|
||||||
|
}
|
||||||
@@ -21,6 +21,16 @@ class AgentWorkspaceModuleDto {
|
|||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
folderName?: string;
|
folderName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
state?: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMaxSize(24)
|
@ArrayMaxSize(24)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|||||||
+5
-1
@@ -1,11 +1,15 @@
|
|||||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { json, urlencoded } from 'express';
|
||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
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 configService = app.get(ConfigService);
|
||||||
const port = configService.get<number>('PORT', 4400);
|
const port = configService.get<number>('PORT', 4400);
|
||||||
const corsOrigin = configService.get<string>(
|
const corsOrigin = configService.get<string>(
|
||||||
|
|||||||
Reference in New Issue
Block a user