Repository navigation
Expand file tree
/
Copy pathreport-finalization.service.ts
More file actions
235 lines (215 loc) · 9.01 KB
/
Copy pathreport-finalization.service.ts
File metadata and controls
235 lines (215 loc) · 9.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
import { randomUUID } from 'node:crypto';
import { REPORT_EVENTS } from '@convene/shared-interfaces';
import { Inject, Injectable } from '@nestjs/common';
import { ParticipantEntry } from '@/reports/domain/entries/participant-entry';
import { TranscriptSegment } from '@/reports/domain/entries/transcript-segment';
import { MeetingReport } from '@/reports/domain/meeting-report';
import { REPORT_REPOSITORY, ReportRepository } from '@/reports/domain/ports/report.repository';
import { SUMMARIZER, SummarizerPort } from '@/reports/domain/ports/summarizer.port';
import { notionPushResult } from '@/reports/domain/value-objects/notion-push-result';
import { Page, ReportListCriteria } from '@/reports/domain/value-objects/report-list-query';
import { ChatEntry } from '@/shared-kernel/domain/value-objects/chat-entry';
import { ExternalReference } from '@/shared-kernel/domain/value-objects/external-reference';
import { MeetingType } from '@/shared-kernel/domain/value-objects/meeting-type';
import { Source } from '@/shared-kernel/domain/value-objects/source';
import { NestEventBusDomainEventPublisher } from '@/shared-kernel/infrastructure/nest-event-bus.publisher';
import { PinoLoggerAdapter } from '@/shared-kernel/infrastructure/pino-logger.adapter';
import { SystemClock } from '@/shared-kernel/infrastructure/system.clock';
import { ReportNotFoundError, ReportNotResummarizableError } from './report.errors';
export interface CreateDraftCommand {
meetingId: string;
code: string;
source: Source;
meetingType: MeetingType;
externalReference: ExternalReference;
startedAt: Date;
endedAt: Date;
participants: ReadonlyArray<ParticipantEntry>;
chat: ReadonlyArray<ChatEntry>;
/** 회의 생성 시 지정한 제목. 생략하면 null. */
title?: string | null;
}
export interface CompleteTranscriptionCommand {
reportId: string;
transcript: ReadonlyArray<TranscriptSegment>;
}
export interface FailTranscriptionCommand {
reportId: string;
error: string;
}
export interface RecordNotionPushCommand {
reportId: string;
pageId: string;
at: Date;
}
/**
* 회의록 생성·후처리 파이프라인을 처리하는 서비스.
*
* `meeting.ended` 이벤트를 받아 회의록 draft를 생성한 뒤 STT/Summary 파이프라인을 따라 `MeetingReport`를 진행시키고,
* 그 결과를 도메인 이벤트로 발행한다.
*/
@Injectable()
export class ReportFinalizationService {
constructor(
@Inject(REPORT_REPOSITORY) private readonly repository: ReportRepository,
@Inject(SUMMARIZER) private readonly summarizer: SummarizerPort,
private readonly clock: SystemClock,
private readonly eventPublisher: NestEventBusDomainEventPublisher,
private readonly logger: PinoLoggerAdapter,
) {}
async createDraft(command: CreateDraftCommand): Promise<MeetingReport> {
const report = MeetingReport.fromEndedMeeting({
id: randomUUID(),
meetingId: command.meetingId,
code: command.code,
source: command.source,
meetingType: command.meetingType,
externalReference: command.externalReference,
startedAt: command.startedAt,
endedAt: command.endedAt,
participants: command.participants,
chat: command.chat,
title: command.title ?? null,
});
await this.repository.save(report);
// STT가 speaker를 nickname으로 채울 수 있도록 participantId→nickname 매핑을 함께 발행한다.
const participantNames: Record<string, string> = {};
for (const p of report.participants) {
participantNames[p.id] = p.nickname;
}
await this.eventPublisher.publish(REPORT_EVENTS.TRANSCRIPTION_REQUESTED, {
reportId: report.id,
meetingId: report.meetingId,
code: report.code,
meetingStartedAtMs: report.startedAt.getTime(),
participantNames,
});
this.logger.info(
{ reportId: report.id, meetingCode: report.code },
'report draft created',
);
return report;
}
async completeTranscription(command: CompleteTranscriptionCommand): Promise<void> {
const report = await this.requireReport(command.reportId);
report.applyTranscript(command.transcript);
await this.repository.save(report);
try {
const summary = await this.summarizer.summarize({
transcript: command.transcript,
chat: report.chat,
meta: {
meetingId: report.meetingId,
code: report.code,
startedAt: report.startedAt,
endedAt: report.endedAt,
},
});
report.applySummary(summary);
await this.repository.save(report);
await this.eventPublisher.publish(REPORT_EVENTS.SUMMARY_COMPLETED, {
reportId: report.id,
});
this.logger.info({ reportId: report.id }, 'report summary completed');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
report.markSummaryFailed(message, this.clock.now());
await this.repository.save(report);
this.logger.error({ reportId: report.id, err }, 'report summary failed');
}
if (report.isFinalized) {
await this.eventPublisher.publish(REPORT_EVENTS.FINALIZED, {
reportId: report.id,
});
this.logger.info({ reportId: report.id }, 'report finalized');
}
}
/**
* 관리자 재요약. 저장된 transcript+chat을 SummarizerPort로 다시 요약해 기존 summary를 교체한다.
*
* 재요약 조건:
* - STT 완료로 transcript가 있고, 1차 요약이 끝난 (summaryStatus=done/failed) 회의록.
* - STT가 실패/미완료면 transcript가 비어 빈 입력으로 LLM을 호출하게 되므로 거부하고, 요약이 진행 중이어도 거부한다
* (둘 다 `ReportNotResummarizableError` → 409).
*
* 실패 시 **상태를 바꾸거나 저장하지 않는다** — 이미 done 이던 회의록이 일시적 LLM 장애로 "요약 실패" 로 보이는 문제를 막기 위함이다.
*/
async resummarize(reportId: string): Promise<MeetingReport> {
const report = await this.requireReport(reportId);
if (report.pipeline.sttStatus !== 'done') {
throw new ReportNotResummarizableError(
reportId,
`STT가 완료되지 않아(sttStatus=${report.pipeline.sttStatus}) 재요약할 transcript가 없습니다`,
);
}
if (report.pipeline.summaryStatus === 'pending') {
throw new ReportNotResummarizableError(
reportId,
'요약이 진행 중입니다(summaryStatus=pending)',
);
}
// summarize 실패는 catch 하지 않고 전파한다.
// 이 시점 이전에는 어떤 상태 변경/저장도 하지 않았으므로 기존 회의록 상태(done/failed)는 그대로 보존된다.
const summary = await this.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.repository.save(report);
await this.eventPublisher.publish(REPORT_EVENTS.SUMMARY_COMPLETED, {
reportId: report.id,
});
// 재요약 대상은 이미 STT가 끝난 회의록이므로 pipeline은 항상 final 상태다.
// 1차 파이프라인과 동일하게 finalized 이벤트를 발행한다.
await this.eventPublisher.publish(REPORT_EVENTS.FINALIZED, {
reportId: report.id,
});
this.logger.info({ reportId }, 'report resummarized');
return report;
}
async listPage(criteria: ReportListCriteria): Promise<Page<MeetingReport>> {
return this.repository.findPage(criteria);
}
async getById(reportId: string): Promise<MeetingReport> {
return this.requireReport(reportId);
}
async failTranscription(command: FailTranscriptionCommand): Promise<void> {
const report = await this.requireReport(command.reportId);
const at = this.clock.now();
report.markTranscriptionFailed(command.error, at);
// STT가 실패하면 summary 입력이 없으므로 cascade로 종료 처리(재시도 없음).
report.markSummaryFailed(`Skipped due to transcription failure: ${command.error}`, at);
await this.repository.save(report);
this.logger.warn(
{ reportId: command.reportId, error: command.error },
'report transcription failed',
);
if (report.isFinalized) {
await this.eventPublisher.publish(REPORT_EVENTS.FINALIZED, {
reportId: report.id,
});
this.logger.info({ reportId: report.id }, 'report finalized');
}
}
async recordNotionPush(command: RecordNotionPushCommand): Promise<void> {
const report = await this.requireReport(command.reportId);
report.attachNotionPushResult(
notionPushResult({ pageId: command.pageId, at: command.at }),
);
await this.repository.save(report);
this.logger.info({ reportId: command.reportId }, 'report notion push recorded');
}
private async requireReport(reportId: string): Promise<MeetingReport> {
const report = await this.repository.findById(reportId);
if (!report) {
throw new ReportNotFoundError(reportId);
}
return report;
}
}