feat(ai): persist agent sessions and request context
This commit is contained in:
@@ -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 { 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;
|
||||
|
||||
@@ -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<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 { 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<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 {
|
||||
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<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 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');
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export type AgentCommand = {
|
||||
export type AgentPlan = {
|
||||
summary: string;
|
||||
explanation: string;
|
||||
requestedModules: string[];
|
||||
requestedLogs: string[];
|
||||
actionPlan: string[];
|
||||
commands: AgentCommand[];
|
||||
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)
|
||||
folderName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
state?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMaxSize(24)
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
+5
-1
@@ -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<number>('PORT', 4400);
|
||||
const corsOrigin = configService.get<string>(
|
||||
|
||||
Reference in New Issue
Block a user