Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ jobs:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: GHCR_TOKEN,GHCR_USER,DOMAIN,ACME_EMAIL,CORS_ORIGIN,RTC_MIN_PORT,RTC_MAX_PORT,MEDIASOUP_WORKER_NUM,STT_MODEL_SIZE,MONGO_DB_NAME,IMAGE_TAG,GEMINI_API_KEY,GEMINI_MODEL,MONGO_URI
envs: GHCR_TOKEN,GHCR_USER,DOMAIN,ACME_EMAIL,CORS_ORIGIN,RTC_MIN_PORT,RTC_MAX_PORT,MEDIASOUP_WORKER_NUM,STT_MODEL_SIZE,MONGO_DB_NAME,IMAGE_TAG,GEMINI_API_KEY,GEMINI_MODEL,MONGO_URI,ADMIN_API_TOKEN
script: |
set -e
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
Expand All @@ -158,6 +158,7 @@ jobs:
echo "GEMINI_MODEL=$GEMINI_MODEL"
echo "MONGO_URI=$MONGO_URI"
echo "MONGO_DB_NAME=${MONGO_DB_NAME:-convene-prod}"
echo "ADMIN_API_TOKEN=$ADMIN_API_TOKEN"
} > .env

docker compose -f docker-compose.prod.yml pull
Expand All @@ -179,6 +180,7 @@ jobs:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
MONGO_URI: ${{ secrets.MONGO_URI }}
ADMIN_API_TOKEN: ${{ secrets.ADMIN_API_TOKEN }}

# 5) 프론트 배포 (정적 export → S3 → CloudFront). FRONTEND_DEPLOY_ENABLED 게이트.
deploy-frontend:
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ MONGO_URI=mongodb://localhost:27017
# 회의록 저장 database 이름. 환경별로 분리(dev/prod). 기본: convene-dev
MONGO_DB_NAME=convene-dev

# 관리자 재요약 엔드포인트(POST /reports/:id/resummarize) 인증 토큰.
# Authorization: Bearer <이 값> 으로 호출을 가른다. 비우면 엔드포인트가 비활성(403)된다.
# 임의의 랜덤 문자열을 직접 생성해 넣는다(예: openssl rand -hex 32).
ADMIN_API_TOKEN=

# ===== 선택(미설정 시 기본값) =====

# HTTP/WS 포트. 기본 5000 (frontend dev 가 3000 을 쓰므로 backend 는 5000)
Expand Down
15 changes: 15 additions & 0 deletions apps/backend/src/config/admin.config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { resolveAdminConfig } from './admin.config';

describe('resolveAdminConfig', () => {
it('ADMIN_API_TOKEN 이 없거나 비어 있으면 null(엔드포인트 비활성 신호)', () => {
expect(resolveAdminConfig({})).toBeNull();
expect(resolveAdminConfig({ ADMIN_API_TOKEN: '' })).toBeNull();
expect(resolveAdminConfig({ ADMIN_API_TOKEN: ' ' })).toBeNull();
});

it('ADMIN_API_TOKEN 이 있으면 trim 한 token 을 돌려준다', () => {
expect(resolveAdminConfig({ ADMIN_API_TOKEN: ' secret-123 ' })).toEqual({
token: 'secret-123',
});
});
});
22 changes: 22 additions & 0 deletions apps/backend/src/config/admin.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* 관리자 전용 엔드포인트(예: 회의록 재요약)의 인증 토큰을 해석하는 모듈.
*
* `gemini.config.ts` 등과 동일하게 부트스트랩 단계의 env 해석만 담당하는 순수 함수.
*
* - 회원/계정 개념이 없는 제품이라 단일 운영자 시크릿(`ADMIN_API_TOKEN`) 으로
* 관리자 호출을 가른다. `Authorization: Bearer <token>` 헤더로 검증한다.
* - 토큰이 비어 있으면 `null` 을 반환한다. 이때 `AdminGuard` 는 관리자 엔드포인트를
* 비활성(403)으로 막아 실수로 무방비 노출되는 일이 없게 한다.
*/

export interface AdminConfig {
readonly token: string;
}

export function resolveAdminConfig(
env: NodeJS.ProcessEnv = process.env,
): AdminConfig | null {
const token = env.ADMIN_API_TOKEN?.trim();
if (token === undefined || token.length === 0) return null;
return { token };
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from '@/shared-kernel/domain/value-objects';

import { MeetingReport } from '../domain/meeting-report';
import { ReportNotFoundError } from './report.errors';
import { ReportNotFoundError, ReportNotResummarizableError } from './report.errors';
import { ReportFinalizationService } from './report-finalization.service';

interface CapturedEvent {
Expand Down Expand Up @@ -281,6 +281,164 @@ describe('ReportFinalizationService.completeTranscription', () => {
});
});

describe('ReportFinalizationService.resummarize', () => {
const startedAt = new Date('2026-01-01T00:00:00Z');
const endedAt = new Date('2026-01-01T00:30:00Z');
const now = new Date('2026-01-01T01:00:00Z');
const reportId = 'rep_resum';
const chat = [chatEntry({ nickname: '준', text: '회의 시작', sentAt: startedAt })];
const transcript = [transcriptSegment({ text: '안녕하세요', startMs: 0, endMs: 1000 })];
const firstSummary: ReportSummary = reportSummary({
title: '1차 요약',
overview: '처음 요약',
decisions: [],
actionItems: [],
keyTopics: [],
});
const newSummary: ReportSummary = reportSummary({
title: '재요약',
overview: '새 프롬프트 요약',
decisions: ['결정 1'],
actionItems: [],
keyTopics: [],
});

/** STT done + summary done/failed 상태의 회의록을 만든다. */
const makeReport = (summaryState: 'done' | 'failed' | 'pending'): MeetingReport => {
const report = MeetingReport.fromEndedMeeting({
id: reportId,
meetingId: 'mtg_x',
code: 'code-x',
source: 'web',
externalReference: NO_EXTERNAL_REFERENCE,
startedAt,
endedAt,
participants: [],
chat,
});
report.applyTranscript(transcript);
if (summaryState === 'done') report.applySummary(firstSummary);
else if (summaryState === 'failed') report.markSummaryFailed('llm boom', endedAt);
return report;
};

const makeService = (
report: MeetingReport | null,
opts: { summarizerResult?: ReportSummary; summarizerError?: Error } = {},
) => {
const store = new Map<string, MeetingReport>();
if (report) store.set(report.id, report);
const saves: string[] = [];
const { events, publisher } = makeEventPublisher();
const summarizer = {
summarize: jest.fn(async () => {
if (opts.summarizerError) throw opts.summarizerError;
return opts.summarizerResult ?? newSummary;
}),
};
const service = new ReportFinalizationService({
repository: {
save: async (r) => {
store.set(r.id, r);
saves.push(r.id);
},
findById: async (id) => store.get(id) ?? null,
findByMeetingId: async () => null,
listRecent: async () => [],
},
summarizer,
notion: noopNotion(),
idGenerator: { next: () => 'unused' },
clock: { now: () => now },
eventPublisher: publisher,
});
return { service, store, saves, events, summarizer };
};

it('존재하지 않는 reportId면 ReportNotFoundError를 던진다', async () => {
const { service } = makeService(null);
await expect(service.resummarize('unknown')).rejects.toThrow(ReportNotFoundError);
});

it('summary 가 pending(파이프라인 진행 중)이면 ReportNotResummarizableError', async () => {
const { service, summarizer } = makeService(makeReport('pending'));
await expect(service.resummarize(reportId)).rejects.toThrow(ReportNotResummarizableError);
expect(summarizer.summarize).not.toHaveBeenCalled();
});

it('저장된 transcript+chat+meta 를 Summarizer 에 그대로 전달한다', async () => {
const { service, summarizer } = makeService(makeReport('done'));
await service.resummarize(reportId);
expect(summarizer.summarize).toHaveBeenCalledWith({
transcript,
chat,
meta: { meetingId: 'mtg_x', code: 'code-x', startedAt, endedAt },
});
});

it('성공 시 기존 summary 를 새 결과로 교체하고 summaryStatus=done 유지', async () => {
const { service, store } = makeService(makeReport('done'));
await service.resummarize(reportId);
const after = store.get(reportId)!;
expect(after.summary).toEqual(newSummary);
expect(after.pipeline.summaryStatus).toBe('done');
});

it('실패했던 회의록을 재요약하면 done 으로 복구된다', async () => {
const { service, store } = makeService(makeReport('failed'));
await service.resummarize(reportId);
const after = store.get(reportId)!;
expect(after.summary).toEqual(newSummary);
expect(after.pipeline.summaryStatus).toBe('done');
});

it('성공 시 summary.completed → finalized 순으로 발행한다', async () => {
const { service, events } = makeService(makeReport('done'));
await service.resummarize(reportId);
expect(events.map((e) => e.name)).toEqual([
REPORT_EVENTS.SUMMARY_COMPLETED,
REPORT_EVENTS.FINALIZED,
]);
expect(events[0].payload).toEqual({ reportId });
});

it('재요약 Summarizer 가 throw 하면 에러를 그대로 전파한다(동기 HTTP → 5xx)', async () => {
const { service } = makeService(makeReport('done'), {
summarizerError: new Error('LLM 503'),
});
await expect(service.resummarize(reportId)).rejects.toThrow('LLM 503');
});

it('done 회의록 재요약 실패 시 done 상태를 보존하고 저장/이벤트를 하지 않는다(격하 방지)', async () => {
const { service, store, saves, events } = makeService(makeReport('done'), {
summarizerError: new Error('LLM 503'),
});
await expect(service.resummarize(reportId)).rejects.toThrow();
const after = store.get(reportId)!;
expect(after.pipeline.summaryStatus).toBe('done');
expect(after.summary).toEqual(firstSummary);
expect(saves).toEqual([]);
expect(events).toEqual([]);
});

it('failed 회의록 재요약 실패 시 기존 failed 상태/failures 를 그대로 둔다', async () => {
const { service, store, saves } = makeService(makeReport('failed'), {
summarizerError: new Error('LLM 503'),
});
await expect(service.resummarize(reportId)).rejects.toThrow();
const after = store.get(reportId)!;
expect(after.pipeline.summaryStatus).toBe('failed');
expect(after.pipeline.failures).toEqual([{ stage: 'summary', error: 'llm boom', at: endedAt }]);
expect(saves).toEqual([]);
});

it('갱신된 MeetingReport 를 반환한다', async () => {
const { service } = makeService(makeReport('done'));
const result = await service.resummarize(reportId);
expect(result.summary).toEqual(newSummary);
});
});

describe('ReportFinalizationService.listRecent', () => {
const startedAt = new Date('2026-01-01T00:00:00Z');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { Clock, DomainEventPublisher } from '@/shared-kernel/domain/ports';
import { ChatEntry, ExternalReference, Source } from '@/shared-kernel/domain/value-objects';

import { ReportNotFoundError } from './report.errors';
import { ReportNotFoundError, ReportNotResummarizableError } from './report.errors';

/**
* Reports Bounded Context의 Application Service.
Expand Down Expand Up @@ -120,6 +120,49 @@ export class ReportFinalizationService {
}
}

/**
* 관리자 재요약. 저장된 transcript+chat 을 SummarizerPort 로 다시 요약해 기존
* summary 를 교체한다(요약 실패 회의록 재시도 / 새 프롬프트 A/B 비교 용도).
*
* 1차 요약이 끝난(done/failed) 회의록만 대상으로 한다. 진행 중(pending)이면
* `ReportNotResummarizableError`.
*
* 동기 HTTP 요청이므로 요약기 실패는 삼키지 않고 그대로 전파한다(상위에서 5xx).
* 실패 시 **상태를 바꾸거나 저장하지 않는다** — 이미 done 이던 회의록이 일시적
* LLM 장애로 failed 로 격하되어 "요약 실패" 로 보이는 UX 퇴행을 막기 위함이다.
* 성공했을 때만 중간 pending 없이 done 으로 교체하므로 영구 pending 으로도 갇히지 않는다.
*/
async resummarize(reportId: string): Promise<MeetingReport> {
const report = await this.requireReport(reportId);
if (report.pipeline.summaryStatus === 'pending') {
throw new ReportNotResummarizableError(reportId, report.pipeline.summaryStatus);
}
Comment thread
yuchem2 marked this conversation as resolved.

// summarize 실패는 catch 하지 않고 전파한다. 이 시점 이전에는 어떤 상태 변경/저장도
// 하지 않았으므로 기존 회의록 상태(done/failed)는 그대로 보존된다.
const summary = await this.deps.summarizer.summarize({
transcript: report.transcript,
chat: report.chat,
meta: {
meetingId: report.meetingId,
code: report.code,
startedAt: report.startedAt,
endedAt: report.endedAt,
},
});
report.replaceSummary(summary);
await this.deps.repository.save(report);
await this.deps.eventPublisher.publish(REPORT_EVENTS.SUMMARY_COMPLETED, {
reportId: report.id,
});
// 재요약 대상은 이미 STT 가 끝난 회의록이므로 pipeline 은 항상 final 상태다.
// 1차 파이프라인과 동일하게 finalized 이벤트를 발행한다.
await this.deps.eventPublisher.publish(REPORT_EVENTS.FINALIZED, {
reportId: report.id,
});
return report;
}

async listRecent(limit: number): Promise<MeetingReport[]> {
return this.deps.repository.listRecent(limit);
}
Expand Down
16 changes: 16 additions & 0 deletions apps/backend/src/reports/application/report.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,19 @@ export class ReportNotFoundError extends Error {
this.name = 'ReportNotFoundError';
}
}

/**
* 재요약 대상이 아닌 회의록에 재요약을 요청했을 때 발생한다.
*
* 재요약은 1차 요약이 이미 끝난(done/failed) 회의록만 대상으로 한다. summary 가
* 아직 pending(파이프라인 진행 중)이면 거부한다. Interface layer 가 409 Conflict 로 매핑.
*/
export class ReportNotResummarizableError extends Error {
constructor(
public readonly reportId: string,
public readonly summaryStatus: string,
) {
super(`Report "${reportId}" cannot be resummarized while summary is "${summaryStatus}"`);
this.name = 'ReportNotResummarizableError';
}
}
33 changes: 33 additions & 0 deletions apps/backend/src/reports/domain/meeting-report.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,39 @@ describe('MeetingReport (Aggregate Root)', () => {
});
});

describe('replaceSummary / markResummaryFailed (관리자 재요약)', () => {
const newSummary = () =>
reportSummary({
title: '재요약된 주간 회의',
overview: '새 프롬프트 요약',
decisions: ['결정 A'],
actionItems: [],
keyTopics: [],
});

it('성공했던 회의록을 새 summary 로 교체하고 summary=done 을 유지한다', () => {
const r = MeetingReport.fromEndedMeeting(baseInput());
r.applyTranscript([transcriptSegment({ text: '안녕', startMs: 0, endMs: 500 })]);
r.applySummary(validSummary());
const replacement = newSummary();
r.replaceSummary(replacement);
expect(r.summary).toBe(replacement);
expect(r.pipeline.summaryStatus).toBe('done');
});

it('실패했던 회의록을 재요약으로 done 으로 복구한다', () => {
const r = MeetingReport.fromEndedMeeting(baseInput());
r.markSummaryFailed('llm boom', failAt);
r.replaceSummary(newSummary());
expect(r.pipeline.summaryStatus).toBe('done');
});

it('아직 요약 전(pending)이면 재요약을 거부한다', () => {
const r = MeetingReport.fromEndedMeeting(baseInput());
expect(() => r.replaceSummary(newSummary())).toThrow(/pending/);
});
});

describe('attachNotionPushResult', () => {
it('pipeline이 final 상태가 아니면 거부한다', () => {
const r = MeetingReport.fromEndedMeeting(baseInput());
Expand Down
13 changes: 13 additions & 0 deletions apps/backend/src/reports/domain/meeting-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,19 @@ export class MeetingReport {
this._pipeline = this._pipeline.markSummaryFailed(error, at);
}

/**
* 관리자 재요약 성공 결과를 반영한다. 기존 summary 를 새 summary 로 교체하고
* summary stage 를 done 으로 전이한다(이미 끝난 stage 대상, ARCHITECTURE.md §5).
*
* 재요약 실패 시에는 호출하지 않는다 — Application 이 에러를 전파하고 기존 상태를
* 보존하므로 done 회의록이 failed 로 격하되지 않는다.
*/
replaceSummary(summary: ReportSummary): void {
const next = this._pipeline.resummarizeDone();
this._summary = summary;
this._pipeline = next;
}

attachNotionPushResult(result: NotionPushResult): void {
if (!this._pipeline.isFinal) {
throw new Error('Cannot attach Notion push result before pipeline is final');
Expand Down
Loading
Loading