diff --git a/src/ai/agent-images.service.spec.ts b/src/ai/agent-images.service.spec.ts index c6a8edf..0ccc293 100644 --- a/src/ai/agent-images.service.spec.ts +++ b/src/ai/agent-images.service.spec.ts @@ -16,10 +16,41 @@ function setup() { const credentials = new AiCredentialService(new ConfigService({ AI_CREDENTIAL_ENCRYPTION_KEY: 'fixture-only-encryption-key-123456789' })); const service = new AgentImagesService(storage as unknown as R2PublicFileService, credentials); const scope = { studentId: 'student', courseId: 'course', week: 3 }; - return { objects, storage, service, scope }; + return { objects, storage, service, scope, credentials }; } describe('AgentImagesService', () => { + it('repairs multipart UTF-8 filenames without changing correctly decoded names', async () => { + const h = setup(); + const buffer = await sharp({ create: { width: 2, height: 2, channels: 3, background: 'white' } }).png().toBuffer(); + const koreanName = '스크린샷 2026-10-19 14-30-07.png'; + for (const [originalname, expected] of [ + [Buffer.from(koreanName, 'utf8').toString('latin1'), koreanName], + [koreanName, koreanName], + ['screen.png', 'screen.png'], + ['caf\u00e9.png', 'caf\u00e9.png'], + ['\uD83D\uDCF7.png', '\uD83D\uDCF7.png'], + ['../folder\\screen\u0000.png', '.._folder_screen_.png'], + ]) { + const reference = await h.service.upload(h.scope, { originalname, buffer }); + expect(reference.name).toBe(expected); + expect(h.service.references([reference], h.scope)[0].name).toBe(expected); + } + }); + + it('restores legacy filename metadata without changing the token or stored image', async () => { + const h = setup(); + const buffer = await sharp({ create: { width: 2, height: 2, channels: 3, background: 'white' } }).png().toBuffer(); + const name = '스크린샷 2026-10-19 14-30-07.png'; + const reference = await h.service.upload(h.scope, { originalname: name, buffer }); + const claim = JSON.parse(h.credentials.decrypt(reference.token)); + claim.name = Buffer.from(name, 'utf8').toString('latin1'); + const token = h.credentials.encrypt(JSON.stringify(claim)); + const restored = h.service.references([{ token }], h.scope); + expect(restored).toEqual([{ ...reference, name, token }]); + expect((await h.service.load(restored))[0].data).toBe(h.objects.get(claim.key)!.toString('base64')); + }); + it('reuses the R2 prefix, strips metadata and returns opaque scoped references', async () => { const h = setup(); const buffer = await sharp({ create: { width: 24, height: 16, channels: 3, background: '#336699' } }).withMetadata().png().toBuffer(); diff --git a/src/ai/agent-images.service.ts b/src/ai/agent-images.service.ts index 9a5896a..7ec15b1 100644 --- a/src/ai/agent-images.service.ts +++ b/src/ai/agent-images.service.ts @@ -10,6 +10,14 @@ export type AgentImageScope = { studentId: string; courseId: string; week: numbe export type AgentImageReference = { token: string; name: string; mimeType: 'image/png'; size: number; url: string }; type ImageClaim = AgentImageScope & { type: 'agent-image'; id: string; name: string; key: string; size: number; sha256: string }; +function normalizeImageFileName(fileName: string): string { + if (/^[\u0000-\u00ff]*$/.test(fileName)) { + try { fileName = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(fileName, 'latin1')); } + catch { /* Preserve genuine Latin-1 names that are not misdecoded UTF-8. */ } + } + return fileName.replace(/[\\/\u0000-\u001f]/g, '_').slice(0, 255) || 'image.png'; +} + export function supportsAgentImages(provider: string, model: string): boolean { return provider === 'QWEN' && ['qwen3.7-plus', 'qwen3.7-flash', 'qwen3.8-max', 'qwen3.8-flash', 'qwen-vl-plus', 'qwen-vl-max'] .some(id => model === id || model.startsWith(`${id}-`)); @@ -35,7 +43,7 @@ export class AgentImagesService { } const id = randomUUID(); const key = this.storage.createLmsAgentImageKey({ courseId: scope.courseId, userId: scope.studentId, imageId: id }); - const name = file.originalname.replace(/[\\/\u0000-\u001f]/g, '_').slice(0, 255) || 'image.png'; + const name = normalizeImageFileName(file.originalname); const claim: ImageClaim = { ...scope, type: 'agent-image', id, name, key, size: body.length, sha256: createHash('sha256').update(body).digest('hex') }; const token = this.credentials.encrypt(JSON.stringify(claim)); const url = await this.storage.uploadPublicFile({ key, body, contentType: 'image/png' }).catch(() => { throw new BadGatewayException('첨부 이미지 업로드에 실패했습니다. 잠시 후 다시 전송해주세요.'); }); @@ -61,7 +69,7 @@ export class AgentImagesService { seen.add(claim.id); const url = this.storage.publicUrl(claim.key); if (!url) throw new BadRequestException('첨부 이미지 주소를 찾을 수 없습니다.'); - return { token: item.token, name: claim.name, mimeType: 'image/png', size: claim.size, url }; + return { token: item.token, name: normalizeImageFileName(claim.name), mimeType: 'image/png', size: claim.size, url }; }); } diff --git a/src/ai/agent.service.spec.ts b/src/ai/agent.service.spec.ts index f6583bb..3dd014f 100644 --- a/src/ai/agent.service.spec.ts +++ b/src/ai/agent.service.spec.ts @@ -38,6 +38,12 @@ describe('AgentService', () => { const restored = service.toSessionResponse({ id: 'session', messages: [{ ...message, clientMessageId: message.id, summary: null, explanation: null, actions }] }); expect(restored.messages[0].imageAttachments).toEqual([reference]); expect(JSON.stringify(actions)).not.toContain('base64'); + const legacy = { ...reference, name: Buffer.from('스크린샷.png', 'utf8').toString('latin1') }; + const normalized = { ...reference, name: '스크린샷.png' }; + service.imagesService.references.mockReturnValue([normalized]); + const history = service.toSessionResponse({ id: 'session', messages: [{ ...message, clientMessageId: message.id, summary: null, explanation: null, actions: [{ type: 'IMAGE_ATTACHMENT', payload: legacy }] }] }); + expect(service.imagesService.references).toHaveBeenLastCalledWith([legacy], undefined); + expect(history.messages[0].imageAttachments).toEqual([normalized]); }); const parsePlan = (content: Record) => { diff --git a/src/ai/agent.service.ts b/src/ai/agent.service.ts index a04a6d4..8cb7f88 100644 --- a/src/ai/agent.service.ts +++ b/src/ai/agent.service.ts @@ -702,13 +702,14 @@ export class AgentService { ); const hasPlan = message.summary !== null && message.explanation !== null; + const imageAttachments = this.imageReferences(message.actions.filter(action => action.type === 'IMAGE_ATTACHMENT').map(action => action.payload)); return { id: message.clientMessageId, role: message.role, content: message.content, provider: message.provider ?? undefined, model: message.model ?? undefined, - ...(message.actions.some(action => action.type === 'IMAGE_ATTACHMENT') ? { imageAttachments: message.actions.filter(action => action.type === 'IMAGE_ATTACHMENT').map(action => action.payload) } : {}), + ...(imageAttachments.length ? { imageAttachments } : {}), usage: message.usage && typeof message.usage === 'object' ? message.usage diff --git a/src/ai/ai-provider.service.ts b/src/ai/ai-provider.service.ts index 8cc02b0..9cd9d5c 100644 --- a/src/ai/ai-provider.service.ts +++ b/src/ai/ai-provider.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import type { AiCompletionResult, AiProvider } from './ai.types'; import { getCopilotConfiguration, requestFutureDriveChatCompletion } from './copilot-backend'; +import { normalizeCopilotPlanResponse, prepareCopilotMessages } from './copilot-agent-format'; export type CompletionInput = { provider: AiProvider; @@ -37,10 +38,10 @@ export class AiProviderService { const configuration = getCopilotConfiguration(this.configService); if (!configuration.configured) throw new BadGatewayException('A-Copilot 서버 연결이 설정되어 있지 않습니다.'); if (configuration.backend === 'futuredrive') { - return requestFutureDriveChatCompletion(this.configService, [ - { role: 'user', content: input.system }, - ...input.messages.map(({ role, content }) => ({ role, content })), - ]); + const response = await requestFutureDriveChatCompletion(this.configService, prepareCopilotMessages( + input.system, input.messages.map(({ role, content }) => ({ role, content })), input.json === true, + )); + return input.json ? { ...response, content: normalizeCopilotPlanResponse(response.content) } : response; } return this.openAi({ ...input, diff --git a/src/ai/copilot-agent-format.spec.ts b/src/ai/copilot-agent-format.spec.ts new file mode 100644 index 0000000..36c23ef --- /dev/null +++ b/src/ai/copilot-agent-format.spec.ts @@ -0,0 +1,50 @@ +import { COPILOT_MESSAGE_CHAR_LIMIT, normalizeCopilotPlanResponse, prepareCopilotMessages } from './copilot-agent-format'; + +const plan = { summary: '모듈 확인', explanation: '실제 구성표를 확인합니다.', toolRequests: [{ id: 'inspect', name: 'practice.inspect', args: { profile: 'mkmini-2d' } }], changes: [], commands: [], createModule: null }; + +describe('A-Copilot agent response protocol', () => { + it('keeps the student question last and explicitly delivers agent JSON inside the RAG answer', () => { + const request = 'MK-mini에서 2D SLAM 실행 모듈 알려줘'; + const messages = prepareCopilotMessages('에이전트 지침', [ + { role: 'user', content: JSON.stringify({ request, workspace: { modules: [] } }) }, + { role: 'assistant', content: '잘못된 이전 형식' }, + { role: 'user', content: JSON.stringify({ formatError: 'JSON 오류', instruction: '다시 반환' }) }, + ], true); + expect(messages[0].content).toContain('에이전트 지침'); + expect(messages.at(-1)!.content).toContain(request); + expect(messages.at(-1)!.content).toContain('answer 본문'); + expect(messages[3].content).toContain('formatError'); + }); + + it('splits long context and surrogate pairs without dropping or truncating content', () => { + const request = '설정 확인'; + const context = JSON.stringify({ request, workspace: { source: 'x'.repeat(100_000) } }); + const messages = prepareCopilotMessages('지침', [{ role: 'user', content: context }], true); + expect(messages.every(message => message.content.length <= COPILOT_MESSAGE_CHAR_LIMIT)).toBe(true); + expect(messages.slice(1, -1).map(message => message.content).join('')).toBe(context); + const text = 'x'.repeat(COPILOT_MESSAGE_CHAR_LIMIT - 1) + String.fromCodePoint(0x1f680) + 'end'; + const chunks = prepareCopilotMessages('system', [{ role: 'assistant', content: text }], false).slice(1); + expect(chunks.map(message => message.content).join('')).toBe(text); + expect(chunks.every(message => !/[\uD800-\uDBFF]$|^[\uDC00-\uDFFF]/.test(message.content))).toBe(true); + expect(chunks.every(message => message.role === 'assistant')).toBe(true); + }); + + it.each([ + JSON.stringify(plan), + `\`\`\`json\n${JSON.stringify(plan)}\n\`\`\``, + `${JSON.stringify(plan)}\n\`\`\``, + `설명입니다.\n\n\`\`\`json\n${JSON.stringify(plan)}\n\`\`\`\n다음 단계입니다.`, + `설명\n\`\`\`json\r\n${JSON.stringify(plan)}\r\n\`\`\``, + ])('extracts a single complete JSON plan from observed RAG output formats', content => { + expect(JSON.parse(normalizeCopilotPlanResponse(content))).toEqual(plan); + }); + + it('does not fabricate plans from natural-language answers or choose among ambiguous plans', () => { + for (const content of [ + '설명만 반환했습니다.', + '설명\n```json\n{invalid}\n```', + '설명\n```json\n{"unrelated":true}\n```', + `설명\n\`\`\`json\n${JSON.stringify(plan)}\n\`\`\`\n다른 제안\n\`\`\`json\n${JSON.stringify({ ...plan, summary: '다른 계획' })}\n\`\`\``, + ]) expect(normalizeCopilotPlanResponse(content)).toBe(content); + }); +}); diff --git a/src/ai/copilot-agent-format.ts b/src/ai/copilot-agent-format.ts new file mode 100644 index 0000000..d11d229 --- /dev/null +++ b/src/ai/copilot-agent-format.ts @@ -0,0 +1,51 @@ +import type { CopilotMessage } from './copilot-backend'; + +export const COPILOT_MESSAGE_CHAR_LIMIT = 32_000; + +export function prepareCopilotMessages(system: string, messages: CopilotMessage[], json: boolean): CopilotMessage[] { + const prepared: CopilotMessage[] = [{ role: 'user', content: system }, ...messages]; + if (json) { + let request = messages.findLast(message => message.role === 'user')?.content ?? ''; + for (const message of messages) { + if (message.role !== 'user') continue; + try { + const context = JSON.parse(message.content); + if (context && typeof context.request === 'string') { request = context.request; break; } + } catch { /* Plain text is also a valid chat message. */ } + } + prepared[0] = { role: 'user', content: `${system}\n\n긴 문맥 메시지는 API의 메시지별 길이 제한에 맞게 나누어 전달될 수 있습니다. 연속된 같은 역할의 문맥 조각은 순서대로 이어 읽으세요.` }; + prepared.push({ role: 'user', content: [ + `학생 요청: ${request}`, + '이 API의 일반 응답 answer 본문 안에 위 에이전트 규칙에 맞는 JSON 코드블록 하나를 넣어 답변하세요.', + '에이전트 JSON을 API 최상위 필드로 출력하지 마세요. answer를 비우지 말고 코드블록 밖의 설명은 생략하세요.', + '자료가 부족하면 추측하거나 실행하지 말고 위 스키마의 자료 조회 요청을 반환하세요.', + ].join('\n\n') }); + } + return prepared.flatMap(message => { + const chunks: CopilotMessage[] = []; + let offset = 0; + do { + let end = Math.min(offset + COPILOT_MESSAGE_CHAR_LIMIT, message.content.length); + const last = message.content.charCodeAt(end - 1); + const next = message.content.charCodeAt(end); + if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end--; + chunks.push({ role: message.role, content: message.content.slice(offset, end) }); + offset = end; + } while (offset < message.content.length); + return chunks; + }); +} + +export function normalizeCopilotPlanResponse(content: string): string { + const normalized = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); + try { JSON.parse(normalized); return normalized; } catch { /* RAG answers can include prose around a JSON code block. */ } + const candidates: string[] = []; + for (const match of content.matchAll(/(?:^|\n)[ \t]*```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*```(?=\r?\n|$)/g)) { + try { + const value = JSON.parse(match[1]); + if (value && typeof value === 'object' && !Array.isArray(value) + && typeof value.summary === 'string' && typeof value.explanation === 'string') candidates.push(JSON.stringify(value)); + } catch { /* Non-plan code blocks do not constitute an agent response. */ } + } + return candidates.length === 1 ? candidates[0] : content; +} diff --git a/src/ai/copilot-backend.spec.ts b/src/ai/copilot-backend.spec.ts index e0ead31..410a422 100644 --- a/src/ai/copilot-backend.spec.ts +++ b/src/ai/copilot-backend.spec.ts @@ -35,9 +35,10 @@ describe('managed A-Copilot backend', () => { expect(request.body).not.toMatch(/server-only-secret|untrusted/); } expect(JSON.parse(fetchMock.mock.calls[0][1].body).messages[0].content).toBe('공식 교육 내용'); - expect(JSON.parse(fetchMock.mock.calls[1][1].body).messages).toEqual([ - { role: 'user', content: 'Return an agent JSON plan' }, { role: 'user', content: '워크스페이스 문맥' }, - ]); + const messages = JSON.parse(fetchMock.mock.calls[1][1].body).messages; + expect(messages[0].content).toContain('Return an agent JSON plan'); + expect(messages[1]).toEqual({ role: 'user', content: '워크스페이스 문맥' }); + expect(messages.at(-1).content).toContain('answer 본문'); }); it('follows the AI tab server selection when OpenAI is configured, without accepting course overrides', async () => { @@ -76,6 +77,29 @@ describe('managed A-Copilot backend', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('normalizes a real RAG-style answer containing prose and one agent plan without a repair call', async () => { + const answer = `모듈 구성표를 먼저 조회합니다.\n\n\`\`\`json\n${JSON.stringify(plan)}\n\`\`\``; + const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ answer }) }); + global.fetch = fetchMock as typeof fetch; + const provider = new AiProviderService(config(configuration)); + const result = await provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: '', system: 'JSON plan only', json: true, messages: [{ role: 'user', content: JSON.stringify({ request: '모듈 목록 확인' }) }] }); + expect(JSON.parse(result.content)).toEqual(plan); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('distinguishes an empty upstream answer and retains its request ID without inventing a response', async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ answer: '', request_id: 'fixture-request-123' }) }) as typeof fetch; + const provider = new AiProviderService(config(configuration)); + try { + await provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: '', system: 'test', messages: [] }); + throw Error('Expected an upstream error'); + } catch (error) { + expect((error as { getResponse: () => unknown }).getResponse()).toEqual(expect.objectContaining({ + code: 'COPILOT_EMPTY_ANSWER', upstreamRequestId: 'fixture-request-123', message: expect.stringContaining('API 연결은 성공'), + })); + } + }); + it('creates and repairs an agent JSON plan using server credentials, without a course credential', async () => { const fetchMock = jest.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ answer: 'invalid plan JSON' }) }) diff --git a/src/ai/copilot-backend.ts b/src/ai/copilot-backend.ts index bcdb7ba..e899236 100644 --- a/src/ai/copilot-backend.ts +++ b/src/ai/copilot-backend.ts @@ -24,9 +24,20 @@ export async function requestFutureDriveChatCompletion(config: ConfigService, me headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': '1' }, body: JSON.stringify({ messages, lang }), }); - const data = await response.json().catch(() => ({})) as { answer?: string; error?: { message?: string }; message?: string }; + const data = await response.json().catch(() => { + throw new BadGatewayException(`A-Copilot 서버가 JSON 형식이 아닌 응답을 반환했습니다. (HTTP ${response.status})`); + }) as { answer?: string; error?: { message?: string }; message?: string; request_id?: string }; + if (!data || typeof data !== 'object' || Array.isArray(data)) throw new BadGatewayException('A-Copilot 서버 응답이 JSON 객체가 아닙니다.'); if (!response.ok) throw new BadGatewayException(data.error?.message ?? data.message ?? `A-Copilot request failed: ${response.status}`); - const content = typeof data.answer === 'string' ? data.answer.trim() : ''; - if (!content) throw new BadGatewayException('A-Copilot 응답이 비어 있습니다.'); + if (typeof data.answer !== 'string') throw new BadGatewayException('A-Copilot 서버 응답에 answer 문자열이 없습니다. 서버의 응답 형식을 확인해주세요.'); + const content = data.answer.trim(); + if (!content) { + const requestId = typeof data.request_id === 'string' && /^[A-Za-z0-9_-]{1,100}$/.test(data.request_id) ? data.request_id : undefined; + throw new BadGatewayException({ + message: 'A-Copilot API 연결은 성공했지만 서버가 빈 answer를 반환했습니다. 공용 AI 서버의 생성 로그를 확인해주세요.', + code: 'COPILOT_EMPTY_ANSWER', + ...(requestId ? { upstreamRequestId: requestId } : {}), + }); + } return { content, usage: { promptTokens: undefined, completionTokens: undefined, totalTokens: undefined } }; }