diff --git a/ai/app/domains/meeting_analysis/schemas.py b/ai/app/domains/meeting_analysis/schemas.py index 00fb7d5..d2ffa03 100644 --- a/ai/app/domains/meeting_analysis/schemas.py +++ b/ai/app/domains/meeting_analysis/schemas.py @@ -44,6 +44,13 @@ class ApplicationTimelineItem(BaseModel): utterance: str = Field(description="실제 발화 원문") +class ApplicationContextItem(BaseModel): + timestamp: str = Field(description="해당 발화 시각(HH:MM:SS)") + member_id: int | None = None + content: str = Field(description="해당 발화의 요약 한 문장") + utterance: str = Field(description="실제 발화 원문") + + class Application(BaseModel): application_id: int | None = Field( default=None, @@ -62,6 +69,13 @@ class Application(BaseModel): default_factory=list, description="적용사항 도출 타임라인(이슈제기→대안논의→적용합의)", ) + context_items: list[ApplicationContextItem] = Field( + default_factory=list, + description=( + "적용사항과 관련된 개별 발화별 요약 목록. " + "timeline과 달리 관련된 발화를 빠짐없이 담는다." + ), + ) class MeetingAnalysisResult(BaseModel): diff --git a/ai/app/domains/meeting_analysis/services/extraction.py b/ai/app/domains/meeting_analysis/services/extraction.py index b235ef9..2a5fb5a 100644 --- a/ai/app/domains/meeting_analysis/services/extraction.py +++ b/ai/app/domains/meeting_analysis/services/extraction.py @@ -12,6 +12,8 @@ from app.core.gemini import generate_content_with_retry from app.domains.meeting_analysis.schemas import ( Application, + ApplicationContextItem, + ApplicationTimelineItem, MeetingAnalysis, MeetingAnalysisResult, ) @@ -138,6 +140,15 @@ "4. timeline의 content는 간략한 한 문장으로 작성한다.", *CONCRETE_APPLICATION_RULE, *TIMELINE_EVIDENCE_ALIGNMENT_RULE, + "", + "[정책 4: 원문 맥락 정책]", + "1. context_items에는 해당 적용사항과 관련된 발화를 빠짐없이 담는다.", + " timeline의 대표 3단계와 달리 관련된 모든 발화를 포함하며,", + " timeline에 이미 들어간 발화도 다시 포함할 수 있다.", + "2. 각 항목은 실제 발화(utterance) 원문과 그 발화 하나에 대한", + " 간략한 한 문장 요약(content)을 함께 담는다.", + "3. member_id는 STT 데이터의 member_id 값을 사용하고, 없으면 null로 둔다.", + "4. 적용사항과 무관한 잡담·감정표현 발화는 포함하지 않는다.", "------------------------------------------------------------", "", "[출력 JSON 구조]:", @@ -166,6 +177,14 @@ ' "content": "간략 요약 한 문장",', ' "utterance": "실제 발화 원문"', " }", + " ],", + ' "context_items": [', + " {", + ' "timestamp": "...",', + ' "member_id": 1,', + ' "content": "해당 발화 요약 한 문장",', + ' "utterance": "실제 발화 원문"', + " }", " ]", " }", " ],", @@ -233,6 +252,15 @@ *CONCRETE_APPLICATION_RULE, *TIMELINE_EVIDENCE_ALIGNMENT_RULE, "", + "[정책 4: 원문 맥락 정책]", + "1. context_items에는 해당 적용사항과 관련된 발화를 빠짐없이 담는다.", + " timeline의 대표 3단계와 달리 관련된 모든 발화를 포함하며,", + " timeline에 이미 들어간 발화도 다시 포함할 수 있다.", + "2. 각 항목은 실제 발화(utterance) 원문과 그 발화 하나에 대한", + " 간략한 한 문장 요약(content)을 함께 담는다.", + "3. member_id는 STT 데이터의 member_id 값을 사용하고, 없으면 null로 둔다.", + "4. 적용사항과 무관한 잡담·감정표현 발화는 포함하지 않는다.", + "", "[출력 JSON 구조]", "{", ' "applications": [', @@ -250,6 +278,14 @@ ' "content": "간략 요약 한 문장",', ' "utterance": "실제 발화 원문"', " }", + " ],", + ' "context_items": [', + " {", + ' "timestamp": "...",', + ' "member_id": 1,', + ' "content": "해당 발화 요약 한 문장",', + ' "utterance": "실제 발화 원문"', + " }", " ]", " }", " ],", @@ -568,11 +604,42 @@ def _infer_timeline_member_id( return None +def _normalize_items_member_ids( + items: list[ApplicationTimelineItem | ApplicationContextItem], + segments: list[TranscribeSegment], + valid_member_ids: set[int], +) -> tuple[int, int]: + # LLM이 생성한 발화 항목의 member_id를 전사 세그먼트 기준으로 검증/보정 + corrected_count = 0 + unresolved_count = 0 + + for item in items: + if item.member_id in valid_member_ids: + continue + + inferred = None + if valid_member_ids: + inferred = _infer_timeline_member_id( + utterance=item.utterance, + timestamp=item.timestamp, + segments=segments, + ) + if inferred is not None: + item.member_id = inferred + corrected_count += 1 + else: + # 추정이 불가능하면 임의 member_id를 만들지 않고 None으로 둔다. + item.member_id = None + unresolved_count += 1 + + return corrected_count, unresolved_count + + def _normalize_timeline_member_ids( result: MeetingAnalysisResult, segments: list[TranscribeSegment], ) -> None: - # LLM이 생성한 member_id를 전사 세그먼트 기준으로 검증/보정 + # LLM이 생성한 timeline/context_items의 member_id를 전사 세그먼트 기준으로 검증/보정 valid_member_ids = { segment.member_id for segment in segments if segment.member_id is not None } @@ -580,33 +647,23 @@ def _normalize_timeline_member_ids( unresolved_count = 0 for application in result.applications: - for item in application.timeline: - if item.member_id in valid_member_ids: - continue - - inferred = None - if valid_member_ids: - inferred = _infer_timeline_member_id( - utterance=item.utterance, - timestamp=item.timestamp, - segments=segments, - ) - if inferred is not None: - item.member_id = inferred - corrected_count += 1 - else: - # 추정이 불가능하면 임의 member_id를 만들지 않고 None으로 둔다. - item.member_id = None - unresolved_count += 1 + for items in (application.timeline, application.context_items): + corrected, unresolved = _normalize_items_member_ids( + items, + segments, + valid_member_ids, + ) + corrected_count += corrected + unresolved_count += unresolved if corrected_count > 0: logger.warning( - "Normalized %s invalid timeline member_id values.", + "Normalized %s invalid application item member_id values.", corrected_count, ) if unresolved_count > 0: logger.warning( - "Could not infer %s timeline member_id values.", + "Could not infer %s application item member_id values.", unresolved_count, ) diff --git a/ai/tests/test_timeline_member_id.py b/ai/tests/test_timeline_member_id.py index 8376882..4419942 100644 --- a/ai/tests/test_timeline_member_id.py +++ b/ai/tests/test_timeline_member_id.py @@ -1,5 +1,6 @@ from app.domains.meeting_analysis.schemas import ( Application, + ApplicationContextItem, ApplicationTimelineItem, MeetingAnalysisResult, ) @@ -16,8 +17,42 @@ def test_timeline_prompt_uses_member_id_not_legacy_speaker_field(self): prompt = f"{APPLICATION_POLICY_PROMPT}\n{APPLICATIONS_ONLY_PROMPT}" assert '"member_id": 1' in prompt + assert '"context_items"' in prompt assert "speaker" + "_id" not in prompt + def test_context_item_member_id_is_inferred_from_transcript_segment(self): + result = MeetingAnalysisResult( + applications=[ + Application( + application_title="Swagger 에러 응답 예시 문서화", + application_reasons=[], + context_items=[ + ApplicationContextItem( + timestamp="00:00:03", + member_id=None, + content="ApiErrorCodeExample 추가 제안", + utterance="ApiErrorCodeExample을 추가하는 걸로 하죠.", + ) + ], + ) + ] + ) + segments = [ + TranscribeSegment( + message_id=1, + speaker="김준용", + member_id=7, + start_time="00:00:00", + end_time="00:00:05", + text="ApiErrorCodeExample을 추가하는 걸로 하죠.", + is_final=True, + ) + ] + + _normalize_timeline_member_ids(result, segments) + + assert result.applications[0].context_items[0].member_id == 7 + def test_timeline_member_id_is_inferred_from_transcript_segment(self): result = MeetingAnalysisResult( applications=[ diff --git a/docs/pr-reviews/PR-14.md b/docs/pr-reviews/PR-14.md new file mode 100644 index 0000000..cf36d5d --- /dev/null +++ b/docs/pr-reviews/PR-14.md @@ -0,0 +1,36 @@ +# PR-14 AI 리뷰 기록 + +- PR: https://github.com/WhyLog-App/WhyLog/pull/14 +- 제목: feat(web, server): 적용사항 대시보드 개선 +- 브랜치: `develop` ← `feat/application-tabs` +- HEAD: `e9c2b3298c4954f27f3057b2e45dbe1f1bd96005` +- 입력 digest: `846908196bad8f5e12a1aa02d7279ed8661d5d58353a7ba6d26d9610ce149bdf` +- 모델: Google `gemini-3.6-flash` +- 상태: **PASS** +- 생성 시각(UTC): 2026-08-21T20:30:19+00:00 + +## 요약 + +적용사항 대시보드 탭 재구성 및 원문 맥락(context_items) 백엔드, AI, 프론트엔드 연동이 저장소 규칙에 맞게 차단 요소 없이 잘 구현되었습니다. + +## 이번 실행에서 새로 발견됨 + +없음 + +## 이전 실행부터 계속 남아있음 + +없음 + +## 현재까지 사라짐(자동 추정) + +없음 + +## 실행 이력 + +|HEAD|상태|모델|전체|신규|계속|해결|시각| +|---|---|---|---:|---:|---:|---:|---| +|e9c2b3298c49|PASS|Google gemini-3.6-flash|||||2026-08-21T20:30:19+00:00| +|ccf27d61531b|PASS|Google gemini-3.6-flash|||||2026-08-15T08:51:38+00:00| + + + diff --git a/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java b/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java index 9f0dd8f..069d529 100644 --- a/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java +++ b/server/src/main/java/com/whylog/server/domain/decision/dto/ApplicationResponse.java @@ -1,17 +1,15 @@ package com.whylog.server.domain.decision.dto; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; -import java.util.List; - public class ApplicationResponse { - @Getter @NoArgsConstructor @AllArgsConstructor @@ -70,7 +68,6 @@ public static class DecisionTimelineItemDTO { @Schema(description = "타임라인 내용", example = "장애 이슈 제기") private String content; - } @Getter @@ -89,14 +86,19 @@ public static class DecisionContextItemDTO { @Schema(description = "발화자 이름", example = "김주뇽", nullable = true) private String memberName; - @Schema(description = "발화자 프로필 사진", example = "https://example.com/profile.jpg", nullable = true) + @Schema( + description = "발화자 프로필 사진", + example = "https://example.com/profile.jpg", + nullable = true) private String profileImage; + @Schema(description = "발화 요약", example = "로그인 API 검증 로직 추가를 합의함", nullable = true) + private String content; + @Schema(description = "대화 내용", example = "아니 우리 이거 버그난다니까?!?@??@") private String dialogueContent; } - @Getter @NoArgsConstructor @AllArgsConstructor @@ -109,7 +111,6 @@ public static class DecisionReasonItemDTO { @Schema(description = "근거 내용", example = "운영복잡 우려로 보류") private String title; - } @Getter @@ -159,6 +160,15 @@ public static class RecommendedCommitDTO { @Schema(description = "커밋 메시지", example = "feat: API 구현") private String message; + @Schema(description = "작성자 이름", example = "홍길동") + private String authorName; + + @Schema(description = "추가된 라인 수", example = "120") + private Integer addedLines; + + @Schema(description = "삭제된 라인 수", example = "30") + private Integer deletedLines; + @Schema(description = "추천 사유", example = "이 커밋은 관련된 이슈를 해결하는 커밋입니다.") private String reason; @@ -199,6 +209,15 @@ public static class ConnectedCommitDTO { @Schema(description = "커밋 메시지", example = "feat: API 구현") private String message; + @Schema(description = "작성자 이름", example = "홍길동") + private String authorName; + + @Schema(description = "추가된 라인 수", example = "120") + private Integer addedLines; + + @Schema(description = "삭제된 라인 수", example = "30") + private Integer deletedLines; + @Schema(description = "커밋 날짜", example = "2026-03-24T10:30:00") private LocalDateTime committedDate; } @@ -216,5 +235,4 @@ public static class CommitConnectionResponseDTO { @Schema(description = "연결된 커밋 ID 목록", example = "[1, 2, 3]") private List commitIds; } - } diff --git a/server/src/main/java/com/whylog/server/domain/decision/entity/ApplicationContext.java b/server/src/main/java/com/whylog/server/domain/decision/entity/ApplicationContext.java new file mode 100644 index 0000000..5e95fa8 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/decision/entity/ApplicationContext.java @@ -0,0 +1,42 @@ +package com.whylog.server.domain.decision.entity; + +import com.whylog.server.global.entity.BaseEntity; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "application_context") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ApplicationContext extends BaseEntity { + + @EmbeddedId private ApplicationContextId id; + + @MapsId("applicationId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "application_id", nullable = false) + private Application application; + + @MapsId("decisionContextPk") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "decision_context_pk", nullable = false) + private DecisionContext decisionContext; + + public static ApplicationContext create( + Application application, DecisionContext decisionContext) { + ApplicationContext applicationContext = new ApplicationContext(); + applicationContext.id = + new ApplicationContextId(application.getId(), decisionContext.getId()); + applicationContext.application = application; + applicationContext.decisionContext = decisionContext; + return applicationContext; + } +} diff --git a/server/src/main/java/com/whylog/server/domain/decision/entity/ApplicationContextId.java b/server/src/main/java/com/whylog/server/domain/decision/entity/ApplicationContextId.java new file mode 100644 index 0000000..2d390b0 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/decision/entity/ApplicationContextId.java @@ -0,0 +1,24 @@ +package com.whylog.server.domain.decision.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ApplicationContextId implements Serializable { + + @Column(name = "application_id") + private Long applicationId; + + @Column(name = "decision_context_pk") + private Long decisionContextPk; +} diff --git a/server/src/main/java/com/whylog/server/domain/decision/entity/DecisionContext.java b/server/src/main/java/com/whylog/server/domain/decision/entity/DecisionContext.java new file mode 100644 index 0000000..dfbcdd6 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/decision/entity/DecisionContext.java @@ -0,0 +1,54 @@ +package com.whylog.server.domain.decision.entity; + +import com.whylog.server.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "decision_context") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DecisionContext extends BaseEntity { + + @Id + @Column(name = "decision_context_pk") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "decision_id", nullable = false) + private Decision decision; + + @Column(name = "timestamp", length = 30) + private String timestamp; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "member_id") + private Long memberId; + + @Column(name = "utterance", columnDefinition = "TEXT") + private String utterance; + + public static DecisionContext create( + Decision decision, String timestamp, String content, Long memberId, String utterance) { + DecisionContext decisionContext = new DecisionContext(); + decisionContext.decision = decision; + decisionContext.timestamp = timestamp; + decisionContext.content = content; + decisionContext.memberId = memberId; + decisionContext.utterance = utterance; + return decisionContext; + } +} diff --git a/server/src/main/java/com/whylog/server/domain/decision/repository/ApplicationContextRepository.java b/server/src/main/java/com/whylog/server/domain/decision/repository/ApplicationContextRepository.java new file mode 100644 index 0000000..80d31d3 --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/decision/repository/ApplicationContextRepository.java @@ -0,0 +1,33 @@ +package com.whylog.server.domain.decision.repository; + +import com.whylog.server.domain.decision.entity.ApplicationContext; +import com.whylog.server.domain.decision.entity.ApplicationContextId; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ApplicationContextRepository + extends JpaRepository { + + @Query( + """ + SELECT ac + FROM ApplicationContext ac + JOIN FETCH ac.decisionContext dc + WHERE ac.application.id = :applicationId + ORDER BY dc.timestamp ASC, dc.id ASC + """) + List findByApplicationId(@Param("applicationId") Long applicationId); + + @Modifying + @Query( + "DELETE FROM ApplicationContext ac WHERE ac.application.decision.meeting.team.id = :teamId") + void deleteByTeamId(@Param("teamId") Long teamId); + + @Modifying + @Query( + "DELETE FROM ApplicationContext ac WHERE ac.application.decision.meeting.id = :meetingId") + void deleteByMeetingId(@Param("meetingId") Long meetingId); +} diff --git a/server/src/main/java/com/whylog/server/domain/decision/repository/DecisionContextRepository.java b/server/src/main/java/com/whylog/server/domain/decision/repository/DecisionContextRepository.java new file mode 100644 index 0000000..71e96dd --- /dev/null +++ b/server/src/main/java/com/whylog/server/domain/decision/repository/DecisionContextRepository.java @@ -0,0 +1,18 @@ +package com.whylog.server.domain.decision.repository; + +import com.whylog.server.domain.decision.entity.DecisionContext; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface DecisionContextRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM DecisionContext dc WHERE dc.decision.meeting.team.id = :teamId") + void deleteByTeamId(@Param("teamId") Long teamId); + + @Modifying + @Query("DELETE FROM DecisionContext dc WHERE dc.decision.meeting.id = :meetingId") + void deleteByMeetingId(@Param("meetingId") Long meetingId); +} diff --git a/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java b/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java index a138b26..a713585 100644 --- a/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java +++ b/server/src/main/java/com/whylog/server/domain/decision/service/ApplicationQueryService.java @@ -4,10 +4,12 @@ import com.whylog.server.domain.decision.entity.Application; import com.whylog.server.domain.decision.entity.ApplicationBase; import com.whylog.server.domain.decision.entity.ApplicationCommits; +import com.whylog.server.domain.decision.entity.ApplicationContext; import com.whylog.server.domain.decision.entity.ApplicationTimeline; import com.whylog.server.domain.decision.exception.ApplicationNotFoundException; import com.whylog.server.domain.decision.repository.ApplicationBaseRepository; import com.whylog.server.domain.decision.repository.ApplicationCommitsRepository; +import com.whylog.server.domain.decision.repository.ApplicationContextRepository; import com.whylog.server.domain.decision.repository.ApplicationRepository; import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; import com.whylog.server.domain.git.entity.Commit; @@ -22,6 +24,7 @@ import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,6 +36,7 @@ public class ApplicationQueryService { private final ApplicationRepository applicationRepository; private final ApplicationBaseRepository applicationBaseRepository; + private final ApplicationContextRepository applicationContextRepository; private final ApplicationTimelineRepository applicationTimelineRepository; private final ApplicationCommitsRepository applicationCommitsRepository; private final CommitRepository commitRepository; @@ -41,13 +45,19 @@ public class ApplicationQueryService { // 적용사항 상세 조회에 필요한 제목, 타임라인, 원문 맥락, 결정근거를 조회 public ApplicationResponse.ApplicationDetailDTO getApplicationDetail(Long applicationId) { - Application application = applicationRepository.findById(applicationId) - .orElseThrow(ApplicationNotFoundException::new); + Application application = + applicationRepository + .findById(applicationId) + .orElseThrow(ApplicationNotFoundException::new); - // 적용사항에 연결된 근거/타임라인 원본 엔티티를 각각 조회 - List applicationBases = applicationBaseRepository.findByApplicationId(applicationId); - List applicationTimelines = applicationTimelineRepository.findByApplicationId(applicationId); - Map membersById = findMembersById(applicationTimelines); + // 적용사항에 연결된 근거/타임라인/원문 맥락 원본 엔티티를 각각 조회 + List applicationBases = + applicationBaseRepository.findByApplicationId(applicationId); + List applicationTimelines = + applicationTimelineRepository.findByApplicationId(applicationId); + List applicationContexts = + applicationContextRepository.findByApplicationId(applicationId); + Map membersById = findMembersById(applicationTimelines, applicationContexts); return ApplicationResponse.ApplicationDetailDTO.builder() .applicationId(application.getId()) @@ -55,7 +65,7 @@ public ApplicationResponse.ApplicationDetailDTO getApplicationDetail(Long applic .name(application.getName()) .decisionReasons(toDecisionReasonItems(applicationBases)) .decisionTimelines(toDecisionTimelineItems(applicationTimelines)) - .decisionContexts(toDecisionContextItems(applicationTimelines, membersById)) + .decisionContexts(toDecisionContextItems(applicationContexts, membersById)) .decisionReasonCount(applicationBases.size()) .build(); } @@ -63,22 +73,44 @@ public ApplicationResponse.ApplicationDetailDTO getApplicationDetail(Long applic // 적용사항에 연결된 커밋 목록을 조회 public ApplicationResponse.ConnectedCommitListDTO getConnectedCommits(Long applicationId) { // 적용사항 존재 여부검증 - applicationRepository.findById(applicationId) + applicationRepository + .findById(applicationId) .orElseThrow(ApplicationNotFoundException::new); // 적용사항에 사용자가 연결한 커밋 목록을 조회 - List commitConnections = commitConnectionRepository.findByApplicationId(applicationId); + List commitConnections = + commitConnectionRepository.findByApplicationId(applicationId); // 연결된 커밋 엔티티를 응답 DTO로 변환 - List commits = commitConnections.stream() - .map(commitConnection -> ApplicationResponse.ConnectedCommitDTO.builder() - .commitId(commitConnection.getCommit().getId()) - .repositoryName(commitConnection.getCommit().getRepository().getName()) - .commitHash(commitConnection.getCommit().getHash()) - .message(commitConnection.getCommit().getMessage()) - .committedDate(commitConnection.getCommit().getDateTime()) - .build()) - .toList(); + List commits = + commitConnections.stream() + .map( + commitConnection -> + ApplicationResponse.ConnectedCommitDTO.builder() + .commitId(commitConnection.getCommit().getId()) + .repositoryName( + commitConnection + .getCommit() + .getRepository() + .getName()) + .commitHash(commitConnection.getCommit().getHash()) + .message(commitConnection.getCommit().getMessage()) + .authorName( + commitConnection + .getCommit() + .getAuthorName()) + .addedLines( + commitConnection + .getCommit() + .getAddedLines()) + .deletedLines( + commitConnection + .getCommit() + .getDeletedLines()) + .committedDate( + commitConnection.getCommit().getDateTime()) + .build()) + .toList(); return ApplicationResponse.ConnectedCommitListDTO.builder() .commitCount(commits.size()) @@ -89,19 +121,25 @@ public ApplicationResponse.ConnectedCommitListDTO getConnectedCommits(Long appli // 적용사항의 적용현황 요약 정보를 조회 public ApplicationResponse.ApplicationStatusDTO getApplicationStatus(Long applicationId) { // 적용사항 존재 여부 검증 - applicationRepository.findById(applicationId) + applicationRepository + .findById(applicationId) .orElseThrow(ApplicationNotFoundException::new); // 적용사항에 사용자가 연결한 커밋 목록을 조회 - List commitConnections = commitConnectionRepository.findByApplicationId(applicationId); + List commitConnections = + commitConnectionRepository.findByApplicationId(applicationId); // 연결된 커밋 목록을 적용현황 응답 형식으로 변환 - List commits = commitConnections.stream() - .map(commitConnection -> ApplicationResponse.ApplicationBaseItemDTO.builder() - .commitHash(commitConnection.getCommit().getHash()) - .commitMessage(commitConnection.getCommit().getMessage()) - .build()) - .toList(); + List commits = + commitConnections.stream() + .map( + commitConnection -> + ApplicationResponse.ApplicationBaseItemDTO.builder() + .commitHash(commitConnection.getCommit().getHash()) + .commitMessage( + commitConnection.getCommit().getMessage()) + .build()) + .toList(); return ApplicationResponse.ApplicationStatusDTO.builder() .commitCount(commits.size()) @@ -110,20 +148,26 @@ public ApplicationResponse.ApplicationStatusDTO getApplicationStatus(Long applic } // 적용사항에 추천된 커밋 목록을 조회 - public List getRecommendedCommits(Long applicationId) { + public List getRecommendedCommits( + Long applicationId) { // 적용사항 존재 여부 검증 - applicationRepository.findById(applicationId) + applicationRepository + .findById(applicationId) .orElseThrow(ApplicationNotFoundException::new); // 적용사항과 연결된 추천 커밋 원본 정보를 조회 - List applicationCommits = applicationCommitsRepository.findByApplicationId(applicationId); + List applicationCommits = + applicationCommitsRepository.findByApplicationId(applicationId); // 추천 원본이 들고 있는 commitId 목록으로 실제 커밋 정보를 조회 Map commitsById = findCommitsById(applicationCommits); // 추천 원본과 커밋 정보를 합쳐 응답 DTO로 변환 return applicationCommits.stream() - .filter(applicationCommit -> commitsById.containsKey(applicationCommit.getDecisionCommits().getCommitId())) + .filter( + applicationCommit -> + commitsById.containsKey( + applicationCommit.getDecisionCommits().getCommitId())) .map(applicationCommit -> toRecommendedCommitDTO(applicationCommit, commitsById)) .toList(); } @@ -131,17 +175,21 @@ public List getRecommendedCommits(Long // 추천 커밋 ID 목록에 해당하는 커밋 정보를 조회 private Map findCommitsById(List applicationCommits) { // 추천 커밋 원본에서 커밋 ID 목록을 추출 - List commitIds = applicationCommits.stream() - .map(applicationCommit -> applicationCommit.getDecisionCommits().getCommitId()) - .toList(); + List commitIds = + applicationCommits.stream() + .map( + applicationCommit -> + applicationCommit.getDecisionCommits().getCommitId()) + .toList(); if (commitIds.isEmpty()) { return Map.of(); } - //응답에 필요한 커밋 정보와 레포 이름을 함께 조회 - Map commitsById = commitRepository.findAllWithRepositoryByIdIn(commitIds).stream() - .collect(Collectors.toMap(Commit::getId, Function.identity())); + // 응답에 필요한 커밋 정보와 레포 이름을 함께 조회 + Map commitsById = + commitRepository.findAllWithRepositoryByIdIn(commitIds).stream() + .collect(Collectors.toMap(Commit::getId, Function.identity())); // 추천 원본이 존재하지 않는 커밋을 참조하는 경우 if (commitsById.size() != commitIds.size()) { @@ -152,8 +200,8 @@ private Map findCommitsById(List applicationCo } // 추천 커밋 연결 정보를 응답 DTO로 변환 - private ApplicationResponse.RecommendedCommitDTO toRecommendedCommitDTO(ApplicationCommits applicationCommit, - Map commitsById) { + private ApplicationResponse.RecommendedCommitDTO toRecommendedCommitDTO( + ApplicationCommits applicationCommit, Map commitsById) { Commit commit = commitsById.get(applicationCommit.getDecisionCommits().getCommitId()); return ApplicationResponse.RecommendedCommitDTO.builder() @@ -161,58 +209,92 @@ private ApplicationResponse.RecommendedCommitDTO toRecommendedCommitDTO(Applicat .commitId(String.valueOf(commit.getId())) .commitHash(commit.getHash()) .message(commit.getMessage()) + .authorName(commit.getAuthorName()) + .addedLines(commit.getAddedLines()) + .deletedLines(commit.getDeletedLines()) .reason(applicationCommit.getReason()) .confidence(applicationCommit.getConfidence()) .build(); } // 연결 테이블을 따라 적용사항에 속한 결정근거 목록을 응답 DTO로 변환 - private List toDecisionReasonItems(List applicationBases) { + private List toDecisionReasonItems( + List applicationBases) { return applicationBases.stream() - .map(applicationBase -> ApplicationResponse.DecisionReasonItemDTO.builder() - .reasonId(String.valueOf(applicationBase.getDecisionBase().getId())) - .title(applicationBase.getDecisionBase().getContent()) - .build()) + .map( + applicationBase -> + ApplicationResponse.DecisionReasonItemDTO.builder() + .reasonId( + String.valueOf( + applicationBase.getDecisionBase().getId())) + .title(applicationBase.getDecisionBase().getContent()) + .build()) .toList(); } // 타임라인 요약 정보 - private List toDecisionTimelineItems(List applicationTimelines) { + private List toDecisionTimelineItems( + List applicationTimelines) { return applicationTimelines.stream() - .map(applicationTimeline -> ApplicationResponse.DecisionTimelineItemDTO.builder() - .time(applicationTimeline.getDecisionTimeline().getTimestamp()) - .step(applicationTimeline.getDecisionTimeline().getStep()) - .content(applicationTimeline.getDecisionTimeline().getContent()) - .build()) + .map( + applicationTimeline -> + ApplicationResponse.DecisionTimelineItemDTO.builder() + .time( + applicationTimeline + .getDecisionTimeline() + .getTimestamp()) + .step(applicationTimeline.getDecisionTimeline().getStep()) + .content( + applicationTimeline + .getDecisionTimeline() + .getContent()) + .build()) .toList(); } // 원문 맥락은 발화자 정보와 원문 발화를 함께 내려줌 - private List toDecisionContextItems(List applicationTimelines, - Map membersById) { - return applicationTimelines.stream() - .map(applicationTimeline -> { - Long memberId = applicationTimeline.getDecisionTimeline().getMemberId(); - Member member = memberId != null ? membersById.get(memberId) : null; - - return ApplicationResponse.DecisionContextItemDTO.builder() - .time(applicationTimeline.getDecisionTimeline().getTimestamp()) - .memberId(memberId) - .memberName(member != null ? member.getName() : null) - .profileImage(memberUseCase.getProfileImageUrl(member) ) - .dialogueContent(applicationTimeline.getDecisionTimeline().getUtterance()) - .build(); - }) + private List toDecisionContextItems( + List applicationContexts, Map membersById) { + return applicationContexts.stream() + .map( + applicationContext -> { + Long memberId = applicationContext.getDecisionContext().getMemberId(); + Member member = memberId != null ? membersById.get(memberId) : null; + + return ApplicationResponse.DecisionContextItemDTO.builder() + .time(applicationContext.getDecisionContext().getTimestamp()) + .memberId(memberId) + .memberName(member != null ? member.getName() : null) + .profileImage(memberUseCase.getProfileImageUrl(member)) + .content(applicationContext.getDecisionContext().getContent()) + .dialogueContent( + applicationContext.getDecisionContext().getUtterance()) + .build(); + }) .toList(); } - // 타임라인에 포함된 발화자들을 한 번에 조회해 memberId 기준 맵으로 구성한다. - private Map findMembersById(List applicationTimelines) { - List memberIds = applicationTimelines.stream() - .map(applicationTimeline -> applicationTimeline.getDecisionTimeline().getMemberId()) - .filter(memberId -> memberId != null) - .distinct() - .toList(); + // 타임라인과 원문 맥락에 포함된 발화자들을 한 번에 조회해 memberId 기준 맵으로 구성한다. + private Map findMembersById( + List applicationTimelines, + List applicationContexts) { + List memberIds = + Stream.concat( + applicationTimelines.stream() + .map( + applicationTimeline -> + applicationTimeline + .getDecisionTimeline() + .getMemberId()), + applicationContexts.stream() + .map( + applicationContext -> + applicationContext + .getDecisionContext() + .getMemberId())) + .filter(memberId -> memberId != null) + .distinct() + .toList(); return memberUseCase.findMembersByIds(memberIds).stream() .collect(Collectors.toMap(Member::getId, Function.identity())); diff --git a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java index cca9c13..95f0019 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java @@ -4,29 +4,35 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.whylog.server.domain.decision.entity.Application; import com.whylog.server.domain.decision.entity.ApplicationBase; +import com.whylog.server.domain.decision.entity.ApplicationContext; import com.whylog.server.domain.decision.entity.ApplicationTimeline; import com.whylog.server.domain.decision.entity.Decision; import com.whylog.server.domain.decision.entity.DecisionBase; +import com.whylog.server.domain.decision.entity.DecisionContext; import com.whylog.server.domain.decision.entity.DecisionTimeline; import com.whylog.server.domain.decision.repository.ApplicationBaseRepository; -import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; +import com.whylog.server.domain.decision.repository.ApplicationContextRepository; import com.whylog.server.domain.decision.repository.ApplicationRepository; +import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; import com.whylog.server.domain.decision.repository.DecisionBaseRepository; -import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; +import com.whylog.server.domain.decision.repository.DecisionContextRepository; import com.whylog.server.domain.decision.repository.DecisionRepository; +import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; import com.whylog.server.domain.decision.service.DecisionCommitMatchService; import com.whylog.server.domain.meeting.dto.MeetingResponse; import com.whylog.server.domain.meeting.entity.Dialogue; import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingAnalysis; import com.whylog.server.domain.meeting.entity.MeetingMember; -import com.whylog.server.domain.meeting.exception.MeetingInvalidMemberException; import com.whylog.server.domain.meeting.exception.MeetingAudioNotReadyException; +import com.whylog.server.domain.meeting.exception.MeetingInvalidMemberException; import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.user.entity.Member; -import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import com.whylog.server.global.apiPayload.exception.GeneralException; import com.whylog.server.global.external.fast.client.FastApiMeetingAnalysisClient; +import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; import com.whylog.server.global.external.fast.dto.FastApiResponse; import com.whylog.server.global.external.fast.dto.request.ApplicationEmbeddingsRequest; import com.whylog.server.global.external.fast.dto.response.ApplicationEmbeddingsResponse; @@ -34,16 +40,14 @@ import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunResponse; import com.whylog.server.global.external.fast.exception.FastApiErrorCode; import com.whylog.server.global.external.fast.exception.FastApiException; -import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; -import com.whylog.server.global.apiPayload.exception.GeneralException; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Map; import java.util.List; -import java.util.function.Function; +import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -71,8 +75,10 @@ public class MeetingAnalysisService { private final FastApiMeetingAnalysisClient fastApiMeetingAnalysisClient; private final ApplicationRepository applicationRepository; private final ApplicationBaseRepository applicationBaseRepository; + private final ApplicationContextRepository applicationContextRepository; private final ApplicationTimelineRepository applicationTimelineRepository; private final DecisionBaseRepository decisionBaseRepository; + private final DecisionContextRepository decisionContextRepository; private final DecisionTimelineRepository decisionTimelineRepository; private final DecisionRepository decisionRepository; private final MeetingAnalysisRepository meetingAnalysisRepository; @@ -93,9 +99,10 @@ public void analyzeMeetingAudio(Long meetingId) { } // FastAPI 응답 JSON을 받아 저장 로직을 테스트한다. - public void persistTestMeetingAnalysis(Long memberId, - Long meetingId, - com.whylog.server.domain.meeting.dto.MeetingRequest.MeetingAnalysisTestDTO request) { + public void persistTestMeetingAnalysis( + Long memberId, + Long meetingId, + com.whylog.server.domain.meeting.dto.MeetingRequest.MeetingAnalysisTestDTO request) { if (!meetingMemberRepository.existsByMemberIdAndMeetingId(memberId, meetingId)) { throw new MeetingInvalidMemberException(); } @@ -129,22 +136,23 @@ private MeetingResponse.AudioDTO resolveAudioWithRetry(Meeting meeting) { } // FastAPI 전사용 비동기 실행(run)을 생성하고 runId를 반환한다. - private String createTranscribeApplicationRun(Meeting meeting, MeetingResponse.AudioDTO audioResponse) { + private String createTranscribeApplicationRun( + Meeting meeting, MeetingResponse.AudioDTO audioResponse) { String audioKey = audioResponse.getAudioKey(); String audioUrl = audioResponse.getAudioUrl(); String audioFilename = meetingAudioFileService.extractFileName(audioKey); String contentType = meetingAudioFileService.resolveResponseContentType(audioKey); String liveMessagesJson = meetingLiveMessageBundleService.buildLiveMessagesJson(meeting); - FastApiResponse createResponse = fastApiTranscribeClient.createTranscribeApplicationRun( - buildAudioResource(audioUrl), - audioFilename, - contentType, - null, - String.valueOf(meeting.getId()), - null, - liveMessagesJson - ); + FastApiResponse createResponse = + fastApiTranscribeClient.createTranscribeApplicationRun( + buildAudioResource(audioUrl), + audioFilename, + contentType, + null, + String.valueOf(meeting.getId()), + null, + liveMessagesJson); String runId = requireResult(createResponse).runId(); log.info("회의 오디오 분석 run 생성 완료: meetingId={}, runId={}", meeting.getId(), runId); @@ -157,7 +165,8 @@ private TranscribeApplicationRunResponse pollTranscribeApplicationRun(String run for (int attempt = 1; attempt <= MAX_RUN_POLL_ATTEMPTS; attempt++) { try { - FastApiResponse response = fastApiTranscribeClient.getTranscribeApplicationRun(runId); + FastApiResponse response = + fastApiTranscribeClient.getTranscribeApplicationRun(runId); TranscribeApplicationRunResponse runResponse = requireResult(response); if (isFailed(runResponse)) { @@ -192,7 +201,8 @@ private boolean isCompleted(TranscribeApplicationRunResponse response) { // run이 실패 상태인지 확인한다. private boolean isFailed(TranscribeApplicationRunResponse response) { - return "failed".equalsIgnoreCase(response.status()) || "failed".equalsIgnoreCase(response.phase()); + return "failed".equalsIgnoreCase(response.status()) + || "failed".equalsIgnoreCase(response.phase()); } // FastAPI run not found가 일시적 상태인지 판별한다. @@ -200,11 +210,13 @@ private boolean isTransientRunNotFound(FastApiException exception) { Throwable cause = exception.getCause(); while (cause != null) { if (cause instanceof HttpClientErrorException httpClientErrorException - && httpClientErrorException.getStatusCode().value() == HttpStatus.NOT_FOUND.value()) { + && httpClientErrorException.getStatusCode().value() + == HttpStatus.NOT_FOUND.value()) { return true; } if (cause instanceof RestClientResponseException restClientResponseException - && restClientResponseException.getStatusCode().value() == HttpStatus.NOT_FOUND.value()) { + && restClientResponseException.getStatusCode().value() + == HttpStatus.NOT_FOUND.value()) { return true; } cause = cause.getCause(); @@ -213,87 +225,124 @@ private boolean isTransientRunNotFound(FastApiException exception) { } // FastAPI 분석 결과를 회의 도메인 엔티티와 대화 목록으로 저장한다. - private void persistMeetingAnalysis(Meeting meeting, TranscribeApplicationRunResponse response) { - TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = response.result(); + private void persistMeetingAnalysis( + Meeting meeting, TranscribeApplicationRunResponse response) { + TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = + response.result(); if (runResult == null) { throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); } List transcriptSegments = Optional.ofNullable(runResult.transcriptSegments()).orElseGet(List::of); - TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult = runResult.analysisResult(); + TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult = + runResult.analysisResult(); TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis = analysisResult != null ? analysisResult.overallAnalysis() : null; List applications = - analysisResult != null && analysisResult.applications() != null ? analysisResult.applications() : List.of(); - MeetingAnalysis.MeetingAnalysisPayload payload = buildMeetingAnalysisPayload(overallAnalysis); - - SavedApplications savedApplications = transactionTemplate.execute(status -> { - Meeting managedMeeting = meetingUseCase.findMeetingWithMembersById(meeting.getId()); - MeetingAnalysis meetingAnalysis = meetingAnalysisRepository.findByMeetingId(managedMeeting.getId()) - .map(existingMeetingAnalysis -> { - existingMeetingAnalysis.updateAnalysis(payload); - return existingMeetingAnalysis; - }) - .orElseGet(() -> MeetingAnalysis.create(managedMeeting, payload)); - meetingAnalysisRepository.save(meetingAnalysis); - managedMeeting.attachMeetingAnalysis(meetingAnalysis); - - List dialogues = buildDialogues(managedMeeting, transcriptSegments); - managedMeeting.getDialogues().clear(); - managedMeeting.getDialogues().addAll(dialogues); - - Decision decision = createDecisionIfAbsent(managedMeeting); - return replaceApplications(managedMeeting.getId(), decision, applications); - }); + analysisResult != null && analysisResult.applications() != null + ? analysisResult.applications() + : List.of(); + MeetingAnalysis.MeetingAnalysisPayload payload = + buildMeetingAnalysisPayload(overallAnalysis); + + SavedApplications savedApplications = + transactionTemplate.execute( + status -> { + Meeting managedMeeting = + meetingUseCase.findMeetingWithMembersById(meeting.getId()); + MeetingAnalysis meetingAnalysis = + meetingAnalysisRepository + .findByMeetingId(managedMeeting.getId()) + .map( + existingMeetingAnalysis -> { + existingMeetingAnalysis.updateAnalysis( + payload); + return existingMeetingAnalysis; + }) + .orElseGet( + () -> + MeetingAnalysis.create( + managedMeeting, payload)); + meetingAnalysisRepository.save(meetingAnalysis); + managedMeeting.attachMeetingAnalysis(meetingAnalysis); + + List dialogues = + buildDialogues(managedMeeting, transcriptSegments); + managedMeeting.getDialogues().clear(); + managedMeeting.getDialogues().addAll(dialogues); + + Decision decision = createDecisionIfAbsent(managedMeeting); + return replaceApplications( + managedMeeting.getId(), decision, applications); + }); if (savedApplications == null) { savedApplications = SavedApplications.empty(null); } - log.info("회의 오디오 분석 저장 완료: meetingId={}, transcriptSegmentCount={}", meeting.getId(), transcriptSegments.size()); + log.info( + "회의 오디오 분석 저장 완료: meetingId={}, transcriptSegmentCount={}", + meeting.getId(), + transcriptSegments.size()); sendApplicationEmbeddingsSafely(meeting, response, savedApplications); matchApplicationCommitsSafely(meeting, savedApplications); - } // Decision이 없을 때 새로 생성한다. private Decision createDecisionIfAbsent(Meeting meeting) { - return decisionRepository.findByMeetingId(meeting.getId()) - .orElseGet(() -> { - Decision decision = decisionRepository.save(Decision.create(meeting, true)); - log.info("결정사항 저장 완료: meetingId={}, decisionId={}", meeting.getId(), decision.getId()); - return decision; - }); + return decisionRepository + .findByMeetingId(meeting.getId()) + .orElseGet( + () -> { + Decision decision = + decisionRepository.save(Decision.create(meeting, true)); + log.info( + "결정사항 저장 완료: meetingId={}, decisionId={}", + meeting.getId(), + decision.getId()); + return decision; + }); } // 분석 결과의 적용사항 제목 목록을 저장한다. - private SavedApplications replaceApplications(Long meetingId, - Decision decision, - List applications) { + private SavedApplications replaceApplications( + Long meetingId, + Decision decision, + List applications) { applicationBaseRepository.deleteByMeetingId(meetingId); + applicationContextRepository.deleteByMeetingId(meetingId); applicationTimelineRepository.deleteByMeetingId(meetingId); decisionBaseRepository.deleteByMeetingId(meetingId); + decisionContextRepository.deleteByMeetingId(meetingId); decisionTimelineRepository.deleteByMeetingId(meetingId); applicationRepository.deleteByMeetingId(meetingId); - List validApplications = applications.stream() - .filter(application -> application != null - && application.applicationTitle() != null - && !application.applicationTitle().isBlank()) - .toList(); - - List newApplications = validApplications.stream() - .map(application -> Application.create( - decision, - application.applicationTitle().trim() - )) - .toList(); + List validApplications = + applications.stream() + .filter( + application -> + application != null + && application.applicationTitle() != null + && !application.applicationTitle().isBlank()) + .toList(); + + List newApplications = + validApplications.stream() + .map( + application -> + Application.create( + decision, application.applicationTitle().trim())) + .toList(); if (!newApplications.isEmpty()) { - List savedApplications = applicationRepository.saveAllAndFlush(newApplications); + List savedApplications = + applicationRepository.saveAllAndFlush(newApplications); persistApplicationDetails(savedApplications, validApplications); - log.info("적용사항 저장 완료: meetingId={}, decisionId={}, applicationCount={}", - meetingId, decision.getId(), newApplications.size()); + log.info( + "적용사항 저장 완료: meetingId={}, decisionId={}, applicationCount={}", + meetingId, + decision.getId(), + newApplications.size()); return new SavedApplications(decision.getId(), savedApplications, validApplications); } @@ -301,13 +350,16 @@ private SavedApplications replaceApplications(Long meetingId, } // 저장된 적용사항 엔티티에 reason/timeline 세부 정보를 순서대로 연결 저장한다. - private void persistApplicationDetails(List applications, - List applicationResponses) { + private void persistApplicationDetails( + List applications, + List applicationResponses) { for (int index = 0; index < applications.size(); index++) { Application application = applications.get(index); - TranscribeApplicationRunResponse.ApplicationResponse response = applicationResponses.get(index); + TranscribeApplicationRunResponse.ApplicationResponse response = + applicationResponses.get(index); persistApplicationReasons(application, response.applicationReasons()); persistApplicationTimelines(application, response.timeline()); + persistApplicationContexts(application, response.contextItems()); } } @@ -318,141 +370,214 @@ private void persistApplicationReasons(Application application, List rea return; } - List decisionBases = validReasons.stream() - .map(reason -> DecisionBase.create(application.getDecision(), reason.trim())) - .toList(); - List savedDecisionBases = decisionBaseRepository.saveAllAndFlush(decisionBases); - - List applicationBases = savedDecisionBases.stream() - .map(decisionBase -> ApplicationBase.create(application, decisionBase)) - .toList(); + List decisionBases = + validReasons.stream() + .map( + reason -> + DecisionBase.create( + application.getDecision(), reason.trim())) + .toList(); + List savedDecisionBases = + decisionBaseRepository.saveAllAndFlush(decisionBases); + + List applicationBases = + savedDecisionBases.stream() + .map(decisionBase -> ApplicationBase.create(application, decisionBase)) + .toList(); applicationBaseRepository.saveAllAndFlush(applicationBases); } // 적용사항 timeline 목록을 DecisionTimeline/ApplicationTimeline으로 분리 저장한다. - private void persistApplicationTimelines(Application application, - List timelines) { + private void persistApplicationTimelines( + Application application, + List timelines) { if (timelines == null || timelines.isEmpty()) { return; } - List decisionTimelines = timelines.stream() - .filter(timeline -> timeline != null) - .map(timeline -> DecisionTimeline.create( - application.getDecision(), - timeline.timestamp(), - timeline.step(), - timeline.content(), - timeline.memberId(), - timeline.utterance() - )) - .toList(); + List decisionTimelines = + timelines.stream() + .filter(timeline -> timeline != null) + .map( + timeline -> + DecisionTimeline.create( + application.getDecision(), + timeline.timestamp(), + timeline.step(), + timeline.content(), + timeline.memberId(), + timeline.utterance())) + .toList(); if (decisionTimelines.isEmpty()) { return; } - List savedDecisionTimelines = decisionTimelineRepository.saveAllAndFlush(decisionTimelines); - List applicationTimelines = savedDecisionTimelines.stream() - .map(decisionTimeline -> ApplicationTimeline.create(application, decisionTimeline)) - .toList(); + List savedDecisionTimelines = + decisionTimelineRepository.saveAllAndFlush(decisionTimelines); + List applicationTimelines = + savedDecisionTimelines.stream() + .map( + decisionTimeline -> + ApplicationTimeline.create(application, decisionTimeline)) + .toList(); applicationTimelineRepository.saveAllAndFlush(applicationTimelines); } + // 적용사항 원문 맥락 목록을 DecisionContext/ApplicationContext로 분리 저장한다. + private void persistApplicationContexts( + Application application, + List contextItems) { + if (contextItems == null || contextItems.isEmpty()) { + return; + } + + List decisionContexts = + contextItems.stream() + .filter(contextItem -> contextItem != null) + .map( + contextItem -> + DecisionContext.create( + application.getDecision(), + contextItem.timestamp(), + contextItem.content(), + contextItem.memberId(), + contextItem.utterance())) + .toList(); + + if (decisionContexts.isEmpty()) { + return; + } + + List savedDecisionContexts = + decisionContextRepository.saveAllAndFlush(decisionContexts); + List applicationContexts = + savedDecisionContexts.stream() + .map( + decisionContext -> + ApplicationContext.create(application, decisionContext)) + .toList(); + applicationContextRepository.saveAllAndFlush(applicationContexts); + } + // 저장된 적용사항을 FastAPI 임베딩 요청으로 전달한다. - private void sendApplicationEmbeddingsSafely(Meeting meeting, - TranscribeApplicationRunResponse runResponse, - SavedApplications savedApplications) { + private void sendApplicationEmbeddingsSafely( + Meeting meeting, + TranscribeApplicationRunResponse runResponse, + SavedApplications savedApplications) { if (savedApplications.savedApplications().isEmpty()) { log.info("저장된 적용사항이 없어 임베딩 호출을 생략한다: meetingId={}", meeting.getId()); return; } try { - ApplicationEmbeddingsRequest request = buildEmbeddingsRequest(meeting, runResponse, savedApplications); - FastApiResponse response = fastApiMeetingAnalysisClient.createApplicationEmbeddings(request); - Integer totalDocuments = response != null && response.result() != null ? response.result().totalDocuments() : null; - log.info("적용사항 임베딩 저장 완료: meetingId={}, totalDocuments={}", meeting.getId(), totalDocuments); + ApplicationEmbeddingsRequest request = + buildEmbeddingsRequest(meeting, runResponse, savedApplications); + FastApiResponse response = + fastApiMeetingAnalysisClient.createApplicationEmbeddings(request); + Integer totalDocuments = + response != null && response.result() != null + ? response.result().totalDocuments() + : null; + log.info( + "적용사항 임베딩 저장 완료: meetingId={}, totalDocuments={}", + meeting.getId(), + totalDocuments); } catch (Exception exception) { log.error("적용사항 임베딩 호출 실패: meetingId={}", meeting.getId(), exception); } } // 회의 분석 저장 후 적용사항-커밋 추천 매칭을 자동 실행한다. - private void matchApplicationCommitsSafely(Meeting meeting, SavedApplications savedApplications) { - if (savedApplications.decisionId() == null || savedApplications.savedApplications().isEmpty()) { + private void matchApplicationCommitsSafely( + Meeting meeting, SavedApplications savedApplications) { + if (savedApplications.decisionId() == null + || savedApplications.savedApplications().isEmpty()) { log.info("저장된 적용사항이 없어 커밋 추천 매칭을 생략한다: meetingId={}", meeting.getId()); return; } try { decisionCommitMatchService.matchApplicationCommits(savedApplications.decisionId()); - log.info("적용사항-커밋 추천 매칭 완료: meetingId={}, decisionId={}", - meeting.getId(), savedApplications.decisionId()); + log.info( + "적용사항-커밋 추천 매칭 완료: meetingId={}, decisionId={}", + meeting.getId(), + savedApplications.decisionId()); } catch (GeneralException exception) { ErrorReasonDTO reason = exception.getErrorReason(); - log.error("적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}, errorCode={}, message={}", - meeting.getId(), savedApplications.decisionId(), reason.getCode(), reason.getMessage(), exception); + log.error( + "적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}, errorCode={}, message={}", + meeting.getId(), + savedApplications.decisionId(), + reason.getCode(), + reason.getMessage(), + exception); } catch (Exception exception) { - log.error("적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}", - meeting.getId(), savedApplications.decisionId(), exception); + log.error( + "적용사항-커밋 추천 매칭 실패: meetingId={}, decisionId={}", + meeting.getId(), + savedApplications.decisionId(), + exception); } } // FastAPI 임베딩 요청을 생성한다. - private ApplicationEmbeddingsRequest buildEmbeddingsRequest(Meeting meeting, - TranscribeApplicationRunResponse runResponse, - SavedApplications savedApplications) { - TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = runResponse.result(); + private ApplicationEmbeddingsRequest buildEmbeddingsRequest( + Meeting meeting, + TranscribeApplicationRunResponse runResponse, + SavedApplications savedApplications) { + TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = + runResponse.result(); TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult = runResult != null ? runResult.analysisResult() : null; TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis = analysisResult != null ? analysisResult.overallAnalysis() : null; List otherMentions = - analysisResult != null && analysisResult.otherMentions() != null ? analysisResult.otherMentions() : List.of(); + analysisResult != null && analysisResult.otherMentions() != null + ? analysisResult.otherMentions() + : List.of(); - List applicationPayloads = new ArrayList<>(); + List applicationPayloads = + new ArrayList<>(); List applications = savedApplications.savedApplications(); - List sourceApplications = savedApplications.sourceApplications(); + List sourceApplications = + savedApplications.sourceApplications(); for (int index = 0; index < applications.size(); index++) { Application savedApplication = applications.get(index); - TranscribeApplicationRunResponse.ApplicationResponse sourceApplication = sourceApplications.get(index); - applicationPayloads.add(new ApplicationEmbeddingsRequest.ApplicationPayload( - savedApplication.getId(), - sourceApplication.applicationTitle(), - sourceApplication.applicationReasons(), - mapTimelinePayloads(sourceApplication.timeline()) - )); + TranscribeApplicationRunResponse.ApplicationResponse sourceApplication = + sourceApplications.get(index); + applicationPayloads.add( + new ApplicationEmbeddingsRequest.ApplicationPayload( + savedApplication.getId(), + sourceApplication.applicationTitle(), + sourceApplication.applicationReasons(), + mapTimelinePayloads(sourceApplication.timeline()))); } return new ApplicationEmbeddingsRequest( String.valueOf(meeting.getId()), null, new ApplicationEmbeddingsRequest.AnalysisResultPayload( - overallAnalysis, - applicationPayloads, - otherMentions - ) - ); + overallAnalysis, applicationPayloads, otherMentions)); } // FastAPI 응답 타임라인을 임베딩 요청 payload로 변환한다. private List mapTimelinePayloads( - List timelines - ) { + List timelines) { if (timelines == null || timelines.isEmpty()) { return List.of(); } return timelines.stream() .filter(timeline -> timeline != null) - .map(timeline -> new ApplicationEmbeddingsRequest.TimelinePayload( - timeline.timestamp(), - timeline.step(), - timeline.memberId(), - timeline.content(), - timeline.utterance() - )) + .map( + timeline -> + new ApplicationEmbeddingsRequest.TimelinePayload( + timeline.timestamp(), + timeline.step(), + timeline.memberId(), + timeline.content(), + timeline.utterance())) .toList(); } @@ -462,33 +587,30 @@ private List safeStrings(List values) { return List.of(); } - return values.stream() - .filter(value -> value != null && !value.isBlank()) - .toList(); + return values.stream().filter(value -> value != null && !value.isBlank()).toList(); } - // OverallAnalysis를 MeetingAnalysis 저장용 payload로 변환한다. private MeetingAnalysis.MeetingAnalysisPayload buildMeetingAnalysisPayload( - TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis - ) { + TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis) { if (overallAnalysis == null) { return new MeetingAnalysis.MeetingAnalysisPayload( - null, - null, - null, - null, - null, - null, - null, - null - ); + null, null, null, null, null, null, null, null); } String analysisContent = serializeOverallAnalysis(overallAnalysis); - String meetingTitle = overallAnalysis.meetingInfo() != null ? overallAnalysis.meetingInfo().title() : null; - String meetingPurpose = overallAnalysis.meetingInfo() != null ? overallAnalysis.meetingInfo().purpose() : null; - String meetingDuration = overallAnalysis.meetingInfo() != null ? overallAnalysis.meetingInfo().duration() : null; + String meetingTitle = + overallAnalysis.meetingInfo() != null + ? overallAnalysis.meetingInfo().title() + : null; + String meetingPurpose = + overallAnalysis.meetingInfo() != null + ? overallAnalysis.meetingInfo().purpose() + : null; + String meetingDuration = + overallAnalysis.meetingInfo() != null + ? overallAnalysis.meetingInfo().duration() + : null; List topics = overallAnalysis.topics(); List coreContext = overallAnalysis.coreContext(); List applicationTitles = overallAnalysis.applicationTitles(); @@ -502,12 +624,12 @@ private MeetingAnalysis.MeetingAnalysisPayload buildMeetingAnalysisPayload( topics, coreContext, applicationTitles, - applicationReasons - ); + applicationReasons); } // OverallAnalysis를 원본 JSON 문자열로 직렬화한다. - private String serializeOverallAnalysis(TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis) { + private String serializeOverallAnalysis( + TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis) { if (overallAnalysis == null) { return null; } @@ -521,15 +643,17 @@ private String serializeOverallAnalysis(TranscribeApplicationRunResponse.Overall } // 전사 세그먼트를 회의 참여자와 매칭해 Dialogue 목록으로 변환한다. - private List buildDialogues(Meeting meeting, - List transcriptSegments) { + private List buildDialogues( + Meeting meeting, + List transcriptSegments) { if (transcriptSegments == null || transcriptSegments.isEmpty()) { return List.of(); } - Map membersById = meeting.getMeetingMembers().stream() - .map(MeetingMember::getMember) - .collect(Collectors.toMap(Member::getId, Function.identity())); + Map membersById = + meeting.getMeetingMembers().stream() + .map(MeetingMember::getMember) + .collect(Collectors.toMap(Member::getId, Function.identity())); if (membersById.isEmpty()) { return List.of(); @@ -537,7 +661,8 @@ private List buildDialogues(Meeting meeting, List dialogues = new ArrayList<>(); for (int index = 0; index < transcriptSegments.size(); index++) { - TranscribeApplicationRunResponse.TranscriptSegmentResponse segment = transcriptSegments.get(index); + TranscribeApplicationRunResponse.TranscriptSegmentResponse segment = + transcriptSegments.get(index); if (segment == null || segment.text() == null || segment.text().isBlank()) { continue; } @@ -547,7 +672,8 @@ private List buildDialogues(Meeting meeting, continue; } - LocalDateTime speechDateTime = resolveSpeechDateTime(meeting.getStartDateTime(), segment.startTime(), index); + LocalDateTime speechDateTime = + resolveSpeechDateTime(meeting.getStartDateTime(), segment.startTime(), index); dialogues.add(Dialogue.create(meeting, member, segment.text().trim(), speechDateTime)); } @@ -555,7 +681,8 @@ private List buildDialogues(Meeting meeting, } // 전사 시작 시각 offset을 회의 시작 시각 기준 LocalDateTime으로 변환한다. - private LocalDateTime resolveSpeechDateTime(LocalDateTime meetingStartDateTime, String offset, int fallbackIndex) { + private LocalDateTime resolveSpeechDateTime( + LocalDateTime meetingStartDateTime, String offset, int fallbackIndex) { if (meetingStartDateTime == null) { return LocalDateTime.now(); } @@ -591,9 +718,10 @@ private Duration parseDuration(String offset) { } } - private record SavedApplications(Long decisionId, - List savedApplications, - List sourceApplications) { + private record SavedApplications( + Long decisionId, + List savedApplications, + List sourceApplications) { private static SavedApplications empty(Long decisionId) { return new SavedApplications(decisionId, List.of(), List.of()); diff --git a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java index 485c85d..4f19341 100644 --- a/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java +++ b/server/src/main/java/com/whylog/server/domain/meeting/service/MeetingCleanupService.java @@ -1,11 +1,13 @@ package com.whylog.server.domain.meeting.service; -import com.whylog.server.domain.decision.repository.ApplicationRepository; import com.whylog.server.domain.decision.repository.ApplicationBaseRepository; import com.whylog.server.domain.decision.repository.ApplicationCommitsRepository; +import com.whylog.server.domain.decision.repository.ApplicationContextRepository; +import com.whylog.server.domain.decision.repository.ApplicationRepository; import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; import com.whylog.server.domain.decision.repository.DecisionBaseRepository; import com.whylog.server.domain.decision.repository.DecisionCommitsRepository; +import com.whylog.server.domain.decision.repository.DecisionContextRepository; import com.whylog.server.domain.decision.repository.DecisionRepository; import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; import com.whylog.server.domain.meeting.repository.DialogueRepository; @@ -24,9 +26,11 @@ public class MeetingCleanupService { private final ApplicationRepository applicationRepository; private final ApplicationBaseRepository applicationBaseRepository; private final ApplicationCommitsRepository applicationCommitsRepository; + private final ApplicationContextRepository applicationContextRepository; private final ApplicationTimelineRepository applicationTimelineRepository; private final DecisionBaseRepository decisionBaseRepository; private final DecisionCommitsRepository decisionCommitsRepository; + private final DecisionContextRepository decisionContextRepository; private final DecisionRepository decisionRepository; private final DecisionTimelineRepository decisionTimelineRepository; private final MeetingAnalysisRepository meetingAnalysisRepository; @@ -51,9 +55,11 @@ public void deleteByMeetingId(Long meetingId) { private void deleteChildrenByTeamId(Long teamId, List meetingIds) { applicationCommitsRepository.deleteByTeamId(teamId); applicationTimelineRepository.deleteByTeamId(teamId); + applicationContextRepository.deleteByTeamId(teamId); applicationBaseRepository.deleteByTeamId(teamId); decisionCommitsRepository.deleteByTeamId(teamId); decisionTimelineRepository.deleteByTeamId(teamId); + decisionContextRepository.deleteByTeamId(teamId); decisionBaseRepository.deleteByTeamId(teamId); applicationRepository.deleteByTeamId(teamId); decisionRepository.deleteByTeamId(teamId); @@ -65,9 +71,11 @@ private void deleteChildrenByTeamId(Long teamId, List meetingIds) { private void deleteChildrenByMeetingId(Long meetingId) { applicationCommitsRepository.deleteByMeetingId(meetingId); applicationTimelineRepository.deleteByMeetingId(meetingId); + applicationContextRepository.deleteByMeetingId(meetingId); applicationBaseRepository.deleteByMeetingId(meetingId); decisionCommitsRepository.deleteByMeetingId(meetingId); decisionTimelineRepository.deleteByMeetingId(meetingId); + decisionContextRepository.deleteByMeetingId(meetingId); decisionBaseRepository.deleteByMeetingId(meetingId); applicationRepository.deleteByMeetingId(meetingId); decisionRepository.deleteByMeetingId(meetingId); diff --git a/server/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java b/server/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java index 53192b3..18c1ccd 100644 --- a/server/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java +++ b/server/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java @@ -11,133 +11,99 @@ @Schema(description = "FastAPI 전사+회의 분석 비동기 실행 상태 조회 응답") public record TranscribeApplicationRunResponse( @Schema(description = "비동기 실행 ID", example = "550e8400-e29b-41d4-a716-446655440000") - String runId, - @Schema(description = "실행 상태", example = "completed") - String status, - @Schema(description = "실행 단계", example = "applications_ready") - String phase, - @Schema(description = "회의 ID", example = "meeting-123", nullable = true) - String meetingId, - @Schema(description = "프로젝트 ID", example = "project-abc", nullable = true) - String projectId, - @Schema(description = "제출 시각", nullable = true) - OffsetDateTime submittedAt, - @Schema(description = "시작 시각", nullable = true) - OffsetDateTime startedAt, - @Schema(description = "종료 시각", nullable = true) - OffsetDateTime finishedAt, - @Schema(description = "실행 실패 사유", nullable = true) - String error, + String runId, + @Schema(description = "실행 상태", example = "completed") String status, + @Schema(description = "실행 단계", example = "applications_ready") String phase, + @Schema(description = "회의 ID", example = "meeting-123", nullable = true) String meetingId, + @Schema(description = "프로젝트 ID", example = "project-abc", nullable = true) String projectId, + @Schema(description = "제출 시각", nullable = true) OffsetDateTime submittedAt, + @Schema(description = "시작 시각", nullable = true) OffsetDateTime startedAt, + @Schema(description = "종료 시각", nullable = true) OffsetDateTime finishedAt, + @Schema(description = "실행 실패 사유", nullable = true) String error, @Schema(description = "최종 또는 중간 결과", nullable = true) - TranscribeApplicationRunResult result -) { + TranscribeApplicationRunResult result) { @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonIgnoreProperties(ignoreUnknown = true) @Schema(description = "전사+회의 분석 실행 결과") public record TranscribeApplicationRunResult( @Schema(description = "회의 ID", example = "meeting-123", nullable = true) - String meetingId, + String meetingId, @Schema(description = "프로젝트 ID", example = "project-abc", nullable = true) - String projectId, + String projectId, @Schema(description = "전사 세그먼트 목록", nullable = true) - List transcriptSegments, + List transcriptSegments, @Schema(description = "회의 분석 결과", nullable = true) - AnalysisResultResponse analysisResult - ) { - } + AnalysisResultResponse analysisResult) {} @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonIgnoreProperties(ignoreUnknown = true) @Schema(description = "전사 세그먼트") public record TranscriptSegmentResponse( - @Schema(description = "메시지 ID", example = "1", nullable = true) - Long messageId, - @Schema(description = "발화자 멤버 ID", example = "1", nullable = true) - Long memberId, - @Schema(description = "시작 시각", example = "00:00:00", nullable = true) - String startTime, - @Schema(description = "종료 시각", example = "00:00:04", nullable = true) - String endTime, + @Schema(description = "메시지 ID", example = "1", nullable = true) Long messageId, + @Schema(description = "발화자 멤버 ID", example = "1", nullable = true) Long memberId, + @Schema(description = "시작 시각", example = "00:00:00", nullable = true) String startTime, + @Schema(description = "종료 시각", example = "00:00:04", nullable = true) String endTime, @Schema(description = "전사 텍스트", example = "Swagger 에러 응답 예시가 부족합니다.", nullable = true) - String text, - @Schema(description = "최종 전사 여부", example = "true", nullable = true) - Boolean isFinal - ) { - } + String text, + @Schema(description = "최종 전사 여부", example = "true", nullable = true) Boolean isFinal) {} @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonIgnoreProperties(ignoreUnknown = true) @Schema(description = "회의 분석 결과") public record AnalysisResultResponse( @Schema(description = "전체 분석 결과", nullable = true) - OverallAnalysisResponse overallAnalysis, + OverallAnalysisResponse overallAnalysis, @Schema(description = "적용사항 목록", nullable = true) - List applications, - @Schema(description = "기타 언급 목록", nullable = true) - List otherMentions - ) { - } + List applications, + @Schema(description = "기타 언급 목록", nullable = true) List otherMentions) {} @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonIgnoreProperties(ignoreUnknown = true) @Schema(description = "전체 분석 결과") public record OverallAnalysisResponse( - @Schema(description = "회의 정보", nullable = true) - MeetingInfoResponse meetingInfo, - @Schema(description = "토픽 목록", nullable = true) - List topics, - @Schema(description = "핵심 맥락", nullable = true) - List coreContext, - @Schema(description = "적용사항 제목 목록", nullable = true) - List applicationTitles, - @Schema(description = "적용사항 사유 목록", nullable = true) - List applicationReasons - ) { - } + @Schema(description = "회의 정보", nullable = true) MeetingInfoResponse meetingInfo, + @Schema(description = "토픽 목록", nullable = true) List topics, + @Schema(description = "핵심 맥락", nullable = true) List coreContext, + @Schema(description = "적용사항 제목 목록", nullable = true) List applicationTitles, + @Schema(description = "적용사항 사유 목록", nullable = true) List applicationReasons) {} @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonIgnoreProperties(ignoreUnknown = true) @Schema(description = "회의 정보") public record MeetingInfoResponse( - @Schema(description = "회의 제목", nullable = true) - String title, - @Schema(description = "회의 목적", nullable = true) - String purpose, + @Schema(description = "회의 제목", nullable = true) String title, + @Schema(description = "회의 목적", nullable = true) String purpose, @Schema(description = "회의 길이", example = "00:12:30", nullable = true) - String duration - ) { - } + String duration) {} @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonIgnoreProperties(ignoreUnknown = true) @Schema(description = "적용사항") public record ApplicationResponse( - @Schema(description = "적용사항 ID", example = "null", nullable = true) - Long applicationId, - @Schema(description = "적용사항 제목", nullable = true) - String applicationTitle, - @Schema(description = "적용사항 사유 목록", nullable = true) - List applicationReasons, - @Schema(description = "타임라인", nullable = true) - List timeline - ) { - } + @Schema(description = "적용사항 ID", example = "null", nullable = true) Long applicationId, + @Schema(description = "적용사항 제목", nullable = true) String applicationTitle, + @Schema(description = "적용사항 사유 목록", nullable = true) List applicationReasons, + @Schema(description = "타임라인", nullable = true) List timeline, + @Schema(description = "원문 맥락 목록", nullable = true) + List contextItems) {} @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonIgnoreProperties(ignoreUnknown = true) @Schema(description = "적용사항 타임라인") public record TimelineResponse( - @Schema(description = "타임스탬프", example = "00:03:12", nullable = true) - String timestamp, - @Schema(description = "단계", example = "이슈제기", nullable = true) - String step, - @Schema(description = "발화자 멤버 ID", example = "1", nullable = true) - Long memberId, - @Schema(description = "내용", nullable = true) - String content, - @Schema(description = "원문 발화", nullable = true) - String utterance - ) { - } + @Schema(description = "타임스탬프", example = "00:03:12", nullable = true) String timestamp, + @Schema(description = "단계", example = "이슈제기", nullable = true) String step, + @Schema(description = "발화자 멤버 ID", example = "1", nullable = true) Long memberId, + @Schema(description = "내용", nullable = true) String content, + @Schema(description = "원문 발화", nullable = true) String utterance) {} + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + @Schema(description = "적용사항 원문 맥락") + public record ContextItemResponse( + @Schema(description = "타임스탬프", example = "00:03:12", nullable = true) String timestamp, + @Schema(description = "발화자 멤버 ID", example = "1", nullable = true) Long memberId, + @Schema(description = "발화 요약", nullable = true) String content, + @Schema(description = "원문 발화", nullable = true) String utterance) {} } diff --git a/server/src/main/resources/db/migration/V2__add_decision_context.sql b/server/src/main/resources/db/migration/V2__add_decision_context.sql new file mode 100644 index 0000000..f1f9dde --- /dev/null +++ b/server/src/main/resources/db/migration/V2__add_decision_context.sql @@ -0,0 +1,27 @@ +create table decision_context ( + decision_context_pk bigint not null auto_increment, + created_at datetime(6), + updated_at datetime(6), + decision_id bigint not null, + timestamp varchar(30), + content TEXT, + member_id bigint, + utterance TEXT, + primary key (decision_context_pk) +) engine=InnoDB; + +create table application_context ( + application_id bigint not null, + decision_context_pk bigint not null, + created_at datetime(6), + updated_at datetime(6), + primary key (application_id, decision_context_pk) +) engine=InnoDB; + +alter table decision_context + add constraint fk_decision_context_decision foreign key (decision_id) references decision (decision_id); + +alter table application_context + add constraint fk_application_context_application foreign key (application_id) references application (application_id); +alter table application_context + add constraint fk_application_context_decision_context foreign key (decision_context_pk) references decision_context (decision_context_pk); diff --git a/server/src/main/resources/db/mock/mock.sql b/server/src/main/resources/db/mock/mock.sql index 5f41dd1..48fe13d 100644 --- a/server/src/main/resources/db/mock/mock.sql +++ b/server/src/main/resources/db/mock/mock.sql @@ -7,11 +7,13 @@ SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE commit_connection; +TRUNCATE TABLE application_context; TRUNCATE TABLE application_timeline; TRUNCATE TABLE application_commits; TRUNCATE TABLE application_base; TRUNCATE TABLE application; TRUNCATE TABLE decision_timeline; +TRUNCATE TABLE decision_context; TRUNCATE TABLE decision_commits; TRUNCATE TABLE decision_base; TRUNCATE TABLE decision; @@ -46,7 +48,7 @@ INSERT INTO team_member (team_id, member_id, created_at, updated_at, is_active, (1, 3, NOW(), NOW(), 1, 'MEMBER'), (1, 4, NOW(), NOW(), 1, 'MEMBER'); --- repository & commits (10건) +-- repository & commits (16건) INSERT INTO repository (repository_id, created_at, updated_at, name, url, last_synced_at, team_id) VALUES (1, NOW(), NOW(), 'whylog-server', 'https://github.com/WhyLog-App/whylog-server', NOW(), 1); @@ -60,7 +62,13 @@ INSERT INTO commits (commit_id, created_at, updated_at, repository_id, hash, mes (7, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000007', 'perf: 회의 요약 캐싱 적용', '목데이터3', 'mock3@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 2 DAY), 70, 15), (8, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000008', 'fix: GitHub 웹훅 서명 검증 버그 수정', '목데이터4', 'mock4@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 1 DAY), 25, 8), (9, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000009', 'chore: Flyway 마이그레이션 도입', '목데이터1', 'mock1@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 1 DAY), 350, 0), - (10, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000010', 'docs: PR 리뷰 기록 문서화', '목데이터2', 'mock2@gmail.com', 'https://placehold.co/64x64', NOW(), 80, 2); + (10, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000010', 'docs: PR 리뷰 기록 문서화', '목데이터2', 'mock2@gmail.com', 'https://placehold.co/64x64', NOW(), 80, 2), + (11, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000011', 'feat: 적용사항 직접 연결 목록 조회 추가', '목데이터3', 'mock3@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 11 DAY), 110, 8), + (12, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000012', 'fix: 커밋 목록 페이징 중복 조회 수정', '목데이터4', 'mock4@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 10 DAY), 24, 14), + (13, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000013', 'refactor: Git 조회 응답 변환 로직 정리', '목데이터1', 'mock1@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 9 DAY), 75, 53), + (14, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000014', 'chore: GitHub 동기화 로그 레벨 조정', '목데이터2', 'mock2@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 8 DAY), 18, 6), + (15, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000015', 'test: 커밋 직접 연결 목록 조회 테스트 추가', '목데이터3', 'mock3@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 7 DAY), 96, 4), + (16, NOW(), NOW(), 1, 'a1b2c3d4e5f60000000000000000000000000016', 'docs: 커밋 연결 API 사용 예시 보완', '목데이터4', 'mock4@gmail.com', 'https://placehold.co/64x64', DATE_SUB(NOW(), INTERVAL 6 DAY), 42, 1); INSERT INTO changed_file (changed_file_id, created_at, updated_at, commit_id, file_name) VALUES (1, NOW(), NOW(), 1, 'src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java'), @@ -74,7 +82,16 @@ INSERT INTO changed_file (changed_file_id, created_at, updated_at, commit_id, fi (9, NOW(), NOW(), 7, 'src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java'), (10, NOW(), NOW(), 8, 'src/main/java/com/whylog/server/global/external/github/GithubWebhookVerifier.java'), (11, NOW(), NOW(), 9, 'src/main/resources/db/migration/V1__init_schema.sql'), - (12, NOW(), NOW(), 10, 'docs/pr-reviews/2026-08.md'); + (12, NOW(), NOW(), 10, 'docs/pr-reviews/2026-08.md'), + (13, NOW(), NOW(), 11, 'src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java'), + (14, NOW(), NOW(), 11, 'src/main/java/com/whylog/server/domain/git/dto/GitResponse.java'), + (15, NOW(), NOW(), 12, 'src/main/java/com/whylog/server/domain/git/repository/CommitRepository.java'), + (16, NOW(), NOW(), 13, 'src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java'), + (17, NOW(), NOW(), 13, 'src/main/java/com/whylog/server/domain/git/entity/Commit.java'), + (18, NOW(), NOW(), 14, 'src/main/java/com/whylog/server/global/external/github/GithubClient.java'), + (19, NOW(), NOW(), 15, 'src/test/java/com/whylog/server/domain/git/service/GitQueryServiceImplTest.java'), + (20, NOW(), NOW(), 15, 'src/main/java/com/whylog/server/domain/git/controller/GitController.java'), + (21, NOW(), NOW(), 16, 'src/main/java/com/whylog/server/domain/git/controller/GitController.java'); INSERT INTO commit_analysis (commit_Analysis_id, created_at, updated_at, commit_id, summary, embedding_ready) VALUES (1, NOW(), NOW(), 1, '로그인 API에 이메일/비밀번호 검증 로직을 추가했다.', 1), @@ -133,6 +150,9 @@ INSERT INTO decision (decision_id, created_at, updated_at, meeting_id, is_create INSERT INTO decision_base (decision_base_pk, created_at, updated_at, decision_id, content) VALUES (1, NOW(), NOW(), 1, '로그인 기능은 이번 스프린트 내 배포한다.'), (2, NOW(), NOW(), 1, '회의 종료 시간 버그와 결정 근거 조회 성능 문제를 이번 스프린트 최우선으로 수정한다.'), + (4, NOW(), NOW(), 1, '적용사항별 결정 근거 조회는 fetch join과 배치 조회를 조합해 N+1 쿼리를 제거한다.'), + (5, NOW(), NOW(), 1, '조회 결과는 필요한 필드만 projection으로 가져와 응답 크기와 객체 생성 비용을 줄인다.'), + (6, NOW(), NOW(), 1, '리팩터링 전후의 쿼리 수와 응답 시간을 측정해 성능 개선 효과를 검증한다.'), (3, NOW(), NOW(), 2, '커밋 추천 정확도를 높이기 위해 임베딩 파이프라인과 회의 요약 캐싱을 우선 적용한다.'); INSERT INTO decision_commits (decision_commits_pk, created_at, updated_at, decision_id, commit_id) VALUES @@ -142,7 +162,9 @@ INSERT INTO decision_commits (decision_commits_pk, created_at, updated_at, decis (4, NOW(), NOW(), 2, 4), (5, NOW(), NOW(), 2, 5), (6, NOW(), NOW(), 2, 7), - (7, NOW(), NOW(), 2, 8); + (7, NOW(), NOW(), 2, 8), + (8, NOW(), NOW(), 1, 11), + (9, NOW(), NOW(), 1, 13); INSERT INTO decision_timeline (decision_timeline_pk, created_at, updated_at, decision_id, timestamp, step, content, member_id, utterance) VALUES (1, NOW(), NOW(), 1, '00:01:00', '문제 제기', '로그인 기능과 회의 종료 버그를 같이 보기로 했다.', 1, '오늘은 로그인 기능 마무리하고 회의 종료 버그를 같이 볼게요.'), @@ -153,7 +175,14 @@ INSERT INTO decision_timeline (decision_timeline_pk, created_at, updated_at, dec (6, NOW(), NOW(), 2, '00:01:00', '문제 제기', '커밋 추천 정확도를 다시 점검했다.', 1, '지난 회의에서 결정한 커밋 추천 정확도를 다시 점검해봤어요.'), (7, NOW(), NOW(), 2, '00:07:00', '원인 분석', '키워드 매칭만으로는 관련 없는 커밋도 추천된다는 점이 지적됐다.', 4, '커밋 메시지만으로는 관련 없는 커밋도 추천되는 경우가 있더라고요.'), (8, NOW(), NOW(), 2, '00:13:00', '대안 검토', '임베딩 기반 유사도 계산이 대안으로 제시됐다.', 3, '임베딩 기반으로 유사도를 계산하면 나아질 것 같아요.'), - (9, NOW(), NOW(), 2, '00:25:00', '결정', '임베딩 파이프라인과 캐싱을 이번 주 안에 적용하기로 했다.', 4, '그럼 임베딩 파이프라인이랑 캐싱을 이번 주 안에 붙여볼게요.'); + (9, NOW(), NOW(), 2, '00:25:00', '결정', '임베딩 파이프라인과 캐싱을 이번 주 안에 적용하기로 했다.', 4, '그럼 임베딩 파이프라인이랑 캐싱을 이번 주 안에 붙여볼게요.'), + (10, NOW(), NOW(), 1, '00:18:00', '원인 분석', '적용사항별 조회 과정에서 연관 데이터를 반복 조회하는 문제가 확인됐다.', 1, '적용사항 하나를 열 때마다 결정 근거와 타임라인을 각각 다시 조회해서, 목록이 늘어나면 쿼리 수가 너무 많이 늘어나요.'), + (11, NOW(), NOW(), 1, '00:19:30', '대안 검토', 'fetch join과 배치 조회를 조합하는 방안을 검토했다.', 4, '기본 정보는 fetch join으로 가져오고, 컬렉션은 배치 조회로 분리하면 중복 행도 피하면서 N+1을 줄일 수 있을 것 같아요.'), + (12, NOW(), NOW(), 1, '00:20:30', '대안 검토', 'projection으로 응답 데이터를 줄이자는 의견이 제시됐다.', 3, '상세 화면에서 필요한 필드만 projection으로 조회하면 엔티티를 전부 만드는 비용도 줄고 응답도 더 가벼워질 거예요.'), + (13, NOW(), NOW(), 1, '00:22:00', '결정', '조회 쿼리를 리팩터링하고 성능을 측정하기로 결정했다.', 2, '좋습니다. fetch join과 projection을 적용해서 쿼리 수와 응답 시간을 같이 비교해보고 이번 스프린트에 반영하죠.'), + (14, NOW(), NOW(), 1, '00:17:00', '현황 확인', '리팩터링 전 조회 성능을 측정하기로 했다.', 2, '우선 현재 화면을 열 때 쿼리가 몇 번 나가는지 로그로 확인해보고, 응답 시간도 기준값을 남겨두면 비교하기 좋겠어요.'), + (15, NOW(), NOW(), 1, '00:20:00', '대안 검토', '컬렉션 fetch join의 페이징 제약을 고려했다.', 1, '컬렉션까지 한 번에 fetch join하면 페이징이 깨질 수 있으니, 목록과 상세 조회의 전략은 분리하는 게 안전해 보여요.'), + (16, NOW(), NOW(), 1, '00:21:15', '검증 계획', '중복 데이터와 조회 결과 일관성을 함께 점검하기로 했다.', 4, '쿼리 수만 줄어도 중복된 적용사항이 내려오면 안 되니까, 테스트 데이터로 결과 개수와 정렬도 같이 확인해야 합니다.'); INSERT INTO application (application_id, created_at, updated_at, decision_id, name) VALUES (1, NOW(), NOW(), 1, '로그인 기능 배포'), @@ -165,7 +194,9 @@ INSERT INTO application (application_id, created_at, updated_at, decision_id, na INSERT INTO application_base (application_id, decision_base_pk, created_at, updated_at) VALUES (1, 1, NOW(), NOW()), (2, 2, NOW(), NOW()), - (3, 2, NOW(), NOW()), + (3, 4, NOW(), NOW()), + (3, 5, NOW(), NOW()), + (3, 6, NOW(), NOW()), (4, 3, NOW(), NOW()), (5, 3, NOW(), NOW()); @@ -173,6 +204,8 @@ INSERT INTO application_commits (application_id, decision_commits_pk, created_at (1, 1, NOW(), NOW(), '로그인 API 구현 커밋이 결정 내용과 직접 연관된다.', 90), (2, 2, NOW(), NOW(), '회의 종료 시간 버그 수정 커밋이 결정 내용과 직접 연관된다.', 88), (3, 3, NOW(), NOW(), '결정 근거 조회 쿼리 리팩터링 커밋이 결정 내용과 직접 연관된다.', 84), + (3, 8, NOW(), NOW(), '적용사항 직접 연결 목록 조회를 추가해 조회 흐름과 연관된다.', 81), + (3, 9, NOW(), NOW(), 'Git 조회 응답 변환 로직을 정리해 projection 적용과 연관된다.', 79), (4, 4, NOW(), NOW(), '임베딩 파이프라인 구현 커밋이 결정 내용과 직접 연관된다.', 92), (5, 6, NOW(), NOW(), '회의 요약 캐싱 적용 커밋이 결정 내용과 직접 연관된다.', 86); @@ -180,12 +213,61 @@ INSERT INTO application_timeline (application_id, decision_timeline_pk, created_ (1, 2, NOW(), NOW()), (2, 3, NOW(), NOW()), (3, 4, NOW(), NOW()), + (3, 14, NOW(), NOW()), + (3, 10, NOW(), NOW()), + (3, 11, NOW(), NOW()), + (3, 15, NOW(), NOW()), + (3, 12, NOW(), NOW()), + (3, 16, NOW(), NOW()), + (3, 13, NOW(), NOW()), (4, 7, NOW(), NOW()), (5, 8, NOW(), NOW()); +INSERT INTO decision_context (decision_context_pk, created_at, updated_at, decision_id, timestamp, content, member_id, utterance) VALUES + (1, NOW(), NOW(), 1, '00:01:00', '로그인 기능을 이번 스프린트에 마무리하기로 논의함', 1, '오늘은 로그인 기능 마무리하고 회의 종료 버그를 같이 볼게요.'), + (2, NOW(), NOW(), 1, '00:11:00', '로그인 API 검증 기준을 점검하기로 함', 1, '로그인 API의 이메일과 비밀번호 검증도 이번 배포 전에 다시 확인해볼게요.'), + (3, NOW(), NOW(), 1, '00:26:00', '로그인 API 구현 완료 후 리뷰를 요청하기로 함', 1, '로그인 API는 오늘 리뷰 요청 올리겠습니다.'), + (4, NOW(), NOW(), 1, '00:06:00', '회의 종료 시간이 어긋나는 문제를 공유함', 2, '회의 종료 시간이 UTC로 저장돼서 어긋나는 것 같아요.'), + (5, NOW(), NOW(), 1, '00:11:00', '타임존 설정 누락을 원인으로 분석함', 1, 'JPA 설정에 타임존을 명시하지 않은 게 원인 같습니다.'), + (6, NOW(), NOW(), 1, '00:16:00', '회의 종료 시간과 조회 성능 이슈를 함께 정리하자고 제안함', 3, '결정 근거 조회 쿼리도 N+1이 나서 같이 정리하면 좋겠어요.'), + (7, NOW(), NOW(), 1, '00:21:00', '회의 종료 시간 버그를 최우선 과제로 반영하기로 합의함', 2, '그럼 이번 스프린트 최우선 과제로 올릴게요.'), + (8, NOW(), NOW(), 1, '00:17:00', '리팩터링 전 조회 성능 기준을 측정하기로 함', 2, '우선 현재 화면을 열 때 쿼리가 몇 번 나가는지 로그로 확인해보고, 응답 시간도 기준값을 남겨두면 비교하기 좋겠어요.'), + (9, NOW(), NOW(), 1, '00:18:00', '반복 조회로 인한 N+1 문제를 확인함', 1, '적용사항 하나를 열 때마다 결정 근거와 타임라인을 각각 다시 조회해서, 목록이 늘어나면 쿼리 수가 너무 많이 늘어나요.'), + (10, NOW(), NOW(), 1, '00:19:30', 'fetch join과 배치 조회 조합을 대안으로 검토함', 4, '기본 정보는 fetch join으로 가져오고, 컬렉션은 배치 조회로 분리하면 중복 행도 피하면서 N+1을 줄일 수 있을 것 같아요.'), + (11, NOW(), NOW(), 1, '00:22:00', '조회 쿼리 리팩터링과 성능 측정을 합의함', 2, '좋습니다. fetch join과 projection을 적용해서 쿼리 수와 응답 시간을 같이 비교해보고 이번 스프린트에 반영하죠.'), + (12, NOW(), NOW(), 2, '00:01:00', '커밋 추천 정확도를 다시 점검함', 1, '지난 회의에서 결정한 커밋 추천 정확도를 다시 점검해봤어요.'), + (13, NOW(), NOW(), 2, '00:07:00', '키워드 매칭만으로는 관련 없는 커밋도 추천되는 문제를 공유함', 4, '커밋 메시지만으로는 관련 없는 커밋도 추천되는 경우가 있더라고요.'), + (14, NOW(), NOW(), 2, '00:13:00', '임베딩 기반 유사도 계산을 대안으로 제시함', 3, '임베딩 기반으로 유사도를 계산하면 나아질 것 같아요.'), + (15, NOW(), NOW(), 2, '00:25:00', '임베딩 파이프라인을 이번 주 안에 적용하기로 합의함', 4, '그럼 임베딩 파이프라인이랑 캐싱을 이번 주 안에 붙여볼게요.'), + (16, NOW(), NOW(), 2, '00:19:00', '회의 요약 재계산 비용을 캐싱으로 줄이자고 제안함', 1, '회의 요약도 매번 다시 계산돼서 느려요. 캐싱을 붙이면 좋겠어요.'), + (17, NOW(), NOW(), 2, '00:25:00', '회의 요약 캐싱을 이번 주 안에 적용하기로 합의함', 4, '그럼 임베딩 파이프라인이랑 캐싱을 이번 주 안에 붙여볼게요.'), + (18, NOW(), NOW(), 2, '00:30:00', '다음 회고에서 추천 정확도 변화를 확인하기로 함', 3, '다음 회고 때 추천 정확도 변화도 같이 확인해봐요.'); + +INSERT INTO application_context (application_id, decision_context_pk, created_at, updated_at) VALUES + (1, 1, NOW(), NOW()), + (1, 2, NOW(), NOW()), + (1, 3, NOW(), NOW()), + (2, 4, NOW(), NOW()), + (2, 5, NOW(), NOW()), + (2, 6, NOW(), NOW()), + (2, 7, NOW(), NOW()), + (3, 8, NOW(), NOW()), + (3, 9, NOW(), NOW()), + (3, 10, NOW(), NOW()), + (3, 11, NOW(), NOW()), + (4, 12, NOW(), NOW()), + (4, 13, NOW(), NOW()), + (4, 14, NOW(), NOW()), + (4, 15, NOW(), NOW()), + (5, 16, NOW(), NOW()), + (5, 17, NOW(), NOW()), + (5, 18, NOW(), NOW()); + INSERT INTO commit_connection (application_id, commit_id, created_at, updated_at) VALUES (1, 1, NOW(), NOW()), (2, 2, NOW(), NOW()), (3, 3, NOW(), NOW()), + (3, 11, NOW(), NOW()), + (3, 13, NOW(), NOW()), (4, 4, NOW(), NOW()), (5, 7, NOW(), NOW()); diff --git a/web/src/pages/decisions/DecisionDetailPage.tsx b/web/src/pages/decisions/DecisionDetailPage.tsx index 83567b4..93a068b 100644 --- a/web/src/pages/decisions/DecisionDetailPage.tsx +++ b/web/src/pages/decisions/DecisionDetailPage.tsx @@ -1,49 +1,42 @@ +import { useState } from "react"; import type { DecisionDetailViewModel } from "@/types/decision"; -import ApplicationStatusCard from "./components/ApplicationStatusCard"; -import CommitTableCard from "./components/CommitTableCard"; -import ContextCard from "./components/ContextCard"; +import ApplicationDetailTabs, { + type ApplicationDetailTab, +} from "./components/ApplicationDetailTabs"; +import CodeApplicationTab from "./components/CodeApplicationTab"; import DecisionHeader from "./components/DecisionHeader"; -import ReasonsCard from "./components/ReasonsCard"; -import TimelineCard from "./components/TimelineCard"; +import OriginalContextTab from "./components/OriginalContextTab"; interface DecisionDetailPageProps { vm: DecisionDetailViewModel; - decisionId: number; } -const DecisionDetailPage = ({ vm, decisionId }: DecisionDetailPageProps) => { +const DecisionDetailPage = ({ vm }: DecisionDetailPageProps) => { + const [activeTab, setActiveTab] = useState("code"); + return ( -
- + + { + if (tab === "code" || tab === "context") setActiveTab(tab); + }} /> - -
-
- - - -
- -
- - -
-
+ {activeTab === "context" ? ( + + ) : ( + reason.title)} + recommendedCommits={vm.recommended_commits} + linkedCommits={vm.linked_commits} + /> + )}
); }; diff --git a/web/src/pages/decisions/DecisionsRoutePage.tsx b/web/src/pages/decisions/DecisionsRoutePage.tsx index 22abde7..84cbcaf 100644 --- a/web/src/pages/decisions/DecisionsRoutePage.tsx +++ b/web/src/pages/decisions/DecisionsRoutePage.tsx @@ -81,7 +81,7 @@ const DecisionsRoutePage = () => { linked_commits: connectedQuery.data?.commits ?? [], }; - return ; + return ; }; export default DecisionsRoutePage; diff --git a/web/src/pages/decisions/components/ApplicationDetailTabs.tsx b/web/src/pages/decisions/components/ApplicationDetailTabs.tsx new file mode 100644 index 0000000..962d318 --- /dev/null +++ b/web/src/pages/decisions/components/ApplicationDetailTabs.tsx @@ -0,0 +1,44 @@ +export type ApplicationDetailTab = "overview" | "relation" | "context" | "code"; + +const TABS: { id: ApplicationDetailTab; label: string }[] = [ + { id: "overview", label: "개요" }, + { id: "relation", label: "관계도" }, + { id: "context", label: "원문 맥락" }, + { id: "code", label: "코드 반영" }, +]; + +interface ApplicationDetailTabsProps { + activeTab: ApplicationDetailTab; + onTabChange?: (tab: ApplicationDetailTab) => void; +} + +const ApplicationDetailTabs = ({ + activeTab, + onTabChange, +}: ApplicationDetailTabsProps) => ( + +); + +export default ApplicationDetailTabs; diff --git a/web/src/pages/decisions/components/CodeApplicationTab.tsx b/web/src/pages/decisions/components/CodeApplicationTab.tsx new file mode 100644 index 0000000..929a261 --- /dev/null +++ b/web/src/pages/decisions/components/CodeApplicationTab.tsx @@ -0,0 +1,376 @@ +import { useEffect, useMemo, useState } from "react"; +import { useCurrentTeam } from "@/hooks/useCurrentTeam"; +import type { + ApplicationConnectedCommit, + ApplicationRecommendedCommit, +} from "@/types/application"; +import { useLinkCommit } from "../hooks/useLinkCommit"; +import { useRepositories } from "../hooks/useRepositories"; +import { useRepositoryCommitsInfinite } from "../hooks/useRepositoryCommitsInfinite"; +import { useUnlinkCommit } from "../hooks/useUnlinkCommit"; +import CommitCard from "./CommitCard"; +import CommitDetailPanel, { + type CommitDetailCommit, +} from "./CommitDetailPanel"; +import DirectCommitList from "./DirectCommitList"; + +interface CodeApplicationTabProps { + applicationId: number; + applicationName: string; + keywords: string[]; + recommendedCommits: ApplicationRecommendedCommit[]; + linkedCommits: ApplicationConnectedCommit[]; +} + +interface DetailCommitInput { + repositoryName: string; + repositoryId?: number; + hash: string; + message: string; + commitId?: number; + isConnected: boolean; + reason?: string; + authorName?: string; + committedDate?: string; +} + +const toDetailCommit = ({ + repositoryName, + repositoryId, + hash, + message, + commitId, + isConnected, + reason, + authorName, + committedDate, +}: DetailCommitInput): CommitDetailCommit => ({ + repositoryName, + repositoryId, + hash, + message, + commitId, + isConnected, + reason, + authorName, + committedDate, +}); + +const CodeApplicationTab = ({ + applicationId, + applicationName, + keywords, + recommendedCommits, + linkedCommits, +}: CodeApplicationTabProps) => { + const [sourceTab, setSourceTab] = useState<"recommended" | "direct">( + "recommended", + ); + const [selectedCommit, setSelectedCommit] = + useState(null); + const [detailCollapsed, setDetailCollapsed] = useState(true); + const [isDraggingCommit, setIsDraggingCommit] = useState(false); + const [draggedCommitId, setDraggedCommitId] = useState(null); + const [selectedRepositoryId, setSelectedRepositoryId] = useState< + number | null + >(null); + const { teamId } = useCurrentTeam(); + const { data: repositories = [] } = useRepositories(teamId); + const { + commits: directCommits, + hasNextPage: hasNextDirectPage, + isFetchingNextPage: isFetchingNextDirectPage, + fetchNextPage: fetchNextDirectPage, + } = useRepositoryCommitsInfinite(selectedRepositoryId, { + enabled: selectedRepositoryId != null, + }); + const repositoryNameById = useMemo( + () => + new Map( + repositories.map((repository) => [ + repository.repository_id, + repository.name, + ]), + ), + [repositories], + ); + const repositoryIdByName = useMemo( + () => + new Map( + repositories.map((repository) => [ + repository.name, + repository.repository_id, + ]), + ), + [repositories], + ); + const linkedCommitHashes = useMemo( + () => new Set(linkedCommits.map((commit) => commit.commit_hash)), + [linkedCommits], + ); + const { + linkCommits, + errorMessage: linkErrorMessage, + isPending: isLinkPending, + } = useLinkCommit(applicationId, { + onSuccess: (commitIds) => + setSelectedCommit((commit) => + commit && commit.commitId != null && commitIds.includes(commit.commitId) + ? { ...commit, isConnected: true } + : commit, + ), + }); + const { + unlinkCommit, + errorMessage: unlinkErrorMessage, + isPending: isUnlinkPending, + } = useUnlinkCommit(applicationId, { + onSuccess: (commitId) => + setSelectedCommit((commit) => + commit?.commitId === commitId + ? { ...commit, isConnected: false } + : commit, + ), + }); + + useEffect(() => { + if (selectedRepositoryId == null && repositories[0]) { + setSelectedRepositoryId(repositories[0].repository_id); + } + }, [repositories, selectedRepositoryId]); + + const showDetail = (commit: CommitDetailCommit) => { + setSelectedCommit(commit); + setDetailCollapsed(false); + }; + const startDragging = (commitId: string | number) => { + const numericId = Number(commitId); + setDraggedCommitId(Number.isInteger(numericId) ? numericId : null); + setIsDraggingCommit(true); + }; + const finishDragging = () => { + setDraggedCommitId(null); + setIsDraggingCommit(false); + }; + const handleDrop = () => { + if (draggedCommitId != null) linkCommits([draggedCommitId]); + finishDragging(); + }; + return ( +
+
+ + + 추천{" "} + + {recommendedCommits.length} + + + + + + 연결됨{" "} + + {linkedCommits.length} + + + + 전체 {recommendedCommits.length + linkedCommits.length} + +
+
+
+
+
+
+ +

+ 커밋 가져오기 +

+
+
+ + +
+
+
+ {sourceTab === "recommended" ? ( + recommendedCommits.map((commit) => { + return ( + + showDetail( + toDetailCommit({ + repositoryName: commit.repository_name, + repositoryId: repositoryIdByName.get( + commit.repository_name, + ), + hash: commit.commit_hash, + message: commit.message, + commitId: Number(commit.commit_id), + isConnected: linkedCommitHashes.has( + commit.commit_hash, + ), + reason: commit.reason, + authorName: commit.author_name, + }), + ) + } + onDragStart={() => startDragging(commit.commit_id)} + onDragEnd={finishDragging} + /> + ); + }) + ) : ( + { + void fetchNextDirectPage(); + }} + onSelectCommit={(commit) => + showDetail( + toDetailCommit({ + repositoryName: + selectedRepositoryId == null + ? "" + : (repositoryNameById.get(selectedRepositoryId) ?? + ""), + repositoryId: selectedRepositoryId ?? undefined, + hash: commit.hash, + message: commit.message, + commitId: commit.commit_id, + isConnected: linkedCommitHashes.has(commit.hash), + authorName: commit.author_name, + committedDate: commit.date_time, + }), + ) + } + onDragStart={startDragging} + onDragEnd={finishDragging} + /> + )} +
+
+
+
+
+ +

+ 연결된 커밋 ({linkedCommits.length}) +

+
+
+ {linkErrorMessage ? ( +

+ {linkErrorMessage} +

+ ) : null} + {unlinkErrorMessage ? ( +

+ {unlinkErrorMessage} +

+ ) : null} +
{ + event.preventDefault(); + setIsDraggingCommit(true); + }} + onDrop={handleDrop} + className="application-scroll relative flex flex-1 flex-col overflow-y-auto" + > + {linkedCommits.map((commit) => { + return ( + + showDetail( + toDetailCommit({ + repositoryName: commit.repository_name, + hash: commit.commit_hash, + message: commit.message, + commitId: commit.commit_id, + isConnected: true, + repositoryId: repositoryIdByName.get( + commit.repository_name, + ), + authorName: commit.author_name, + committedDate: commit.committed_date, + }), + ) + } + /> + ); + })} + {isDraggingCommit ? ( +
+

+ 여기에 드래그하여 연결 +

+

+ 커밋을 이 영역에 드롭하면 연결됩니다 +

+
+ ) : null} +
+
+
+ setDetailCollapsed((value) => !value)} + onConnect={(commitId) => linkCommits([commitId])} + onUnlink={unlinkCommit} + isPending={isLinkPending || isUnlinkPending} + /> +
+
+ ); +}; + +export default CodeApplicationTab; diff --git a/web/src/pages/decisions/components/CommitCard.tsx b/web/src/pages/decisions/components/CommitCard.tsx new file mode 100644 index 0000000..de2fc58 --- /dev/null +++ b/web/src/pages/decisions/components/CommitCard.tsx @@ -0,0 +1,98 @@ +import { formatCommittedDate } from "@/utils/date"; +import CommitHashBadge from "./CommitHashBadge"; + +export type CommitCardVariant = "recommended" | "direct" | "linked"; + +interface CommitCardProps { + hash: string; + message: string; + repositoryName: string; + variant: CommitCardVariant; + reason?: string; + committedDate?: string; + authorName?: string; + addedLines?: number; + removedLines?: number; + selected?: boolean; + onClick?: () => void; + onDragStart?: () => void; + onDragEnd?: () => void; +} + +const CommitCard = ({ + hash, + message, + repositoryName, + variant, + reason, + committedDate, + authorName, + addedLines, + removedLines, + selected = false, + onClick, + onDragStart, + onDragEnd, +}: CommitCardProps) => ( + +); + +export default CommitCard; diff --git a/web/src/pages/decisions/components/CommitDetailPanel.tsx b/web/src/pages/decisions/components/CommitDetailPanel.tsx new file mode 100644 index 0000000..3306a23 --- /dev/null +++ b/web/src/pages/decisions/components/CommitDetailPanel.tsx @@ -0,0 +1,219 @@ +import IconArrowRight from "@/assets/icons/arrow/ic_arrow_right_md.svg?react"; +import { Icon } from "@/components/common/Icon"; +import { useGetCommitDetail } from "@/pages/git/hooks/useGetCommitDetail"; +import { formatCommittedDate } from "@/utils/date"; +import CommitHashBadge from "./CommitHashBadge"; + +export interface CommitDetailCommit { + repositoryName: string; + hash: string; + message: string; + commitId?: number; + isConnected: boolean; + reason?: string; + repositoryId?: number; + authorName?: string; + committedDate?: string; +} + +interface CommitDetailPanelProps { + applicationName: string; + keywords: string[]; + commit: CommitDetailCommit | null; + collapsed: boolean; + onToggle: () => void; + onConnect: (commitId: number) => void; + onUnlink: (commitId: number) => void; + isPending: boolean; +} + +const CommitDetailPanel = ({ + applicationName, + keywords, + commit, + collapsed, + onToggle, + onConnect, + onUnlink, + isPending, +}: CommitDetailPanelProps) => { + const detailQuery = useGetCommitDetail( + collapsed ? null : (commit?.repositoryId ?? null), + commit?.hash, + ); + const detail = detailQuery.data; + + return ( + + ); +}; + +export default CommitDetailPanel; diff --git a/web/src/pages/decisions/components/CommitHashBadge.tsx b/web/src/pages/decisions/components/CommitHashBadge.tsx index 97c2c07..bdeb124 100644 --- a/web/src/pages/decisions/components/CommitHashBadge.tsx +++ b/web/src/pages/decisions/components/CommitHashBadge.tsx @@ -6,7 +6,7 @@ interface CommitHashBadgeProps { const CommitHashBadge = ({ hash, className = "" }: CommitHashBadgeProps) => ( {hash.slice(0, 6)} diff --git a/web/src/pages/decisions/components/DecisionHeader.tsx b/web/src/pages/decisions/components/DecisionHeader.tsx index 2e092b1..9fcec9d 100644 --- a/web/src/pages/decisions/components/DecisionHeader.tsx +++ b/web/src/pages/decisions/components/DecisionHeader.tsx @@ -1,11 +1,8 @@ import IconCircleUser from "@/assets/icons/user/ic_circle_user.svg?react"; import { Icon } from "@/components/common/Icon"; -import type { DecisionConfidence, DecisionMeetingMeta } from "@/types/decision"; -import ConfidenceBadge from "./ConfidenceBadge"; +import type { DecisionMeetingMeta } from "@/types/decision"; interface DecisionHeaderProps { - name: string; - confidence: DecisionConfidence; meta: DecisionMeetingMeta; } @@ -13,18 +10,15 @@ const Dot = () => ( · ); -const DecisionHeader = ({ name, confidence, meta }: DecisionHeaderProps) => { +const DecisionHeader = ({ meta }: DecisionHeaderProps) => { const visibleAvatars = meta.participants.slice(0, 5); return ( -
-
-

{name}

- -
- +
- {meta.meeting_name} + + {meta.meeting_name} + {meta.meeting_date} diff --git a/web/src/pages/decisions/components/DirectCommitList.tsx b/web/src/pages/decisions/components/DirectCommitList.tsx new file mode 100644 index 0000000..0c5b32c --- /dev/null +++ b/web/src/pages/decisions/components/DirectCommitList.tsx @@ -0,0 +1,122 @@ +import { useRef } from "react"; +import type { RepositoryCommitItem } from "@/types/git"; +import CommitCard from "./CommitCard"; + +interface DirectCommitListProps { + repositories: { repository_id: number; name: string }[]; + selectedRepositoryId: number | null; + repositoryName: string; + onSelectRepository: (repositoryId: number) => void; + commits: RepositoryCommitItem[]; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; + onSelectCommit: (commit: RepositoryCommitItem) => void; + onDragStart: (commitId: number) => void; + onDragEnd: () => void; +} + +const RepositoryTabs = ({ + repositories, + selectedRepositoryId, + onSelectRepository, +}: Pick< + DirectCommitListProps, + "repositories" | "selectedRepositoryId" | "onSelectRepository" +>) => { + const scrollRef = useRef(null); + const dragStartRef = useRef<{ x: number; scrollLeft: number } | null>(null); + + return ( +
{ + dragStartRef.current = { + x: event.clientX, + scrollLeft: scrollRef.current?.scrollLeft ?? 0, + }; + }} + onMouseMove={(event) => { + if (dragStartRef.current && scrollRef.current) { + scrollRef.current.scrollLeft = + dragStartRef.current.scrollLeft - + (event.clientX - dragStartRef.current.x); + } + }} + onMouseUp={() => { + dragStartRef.current = null; + }} + onMouseLeave={() => { + dragStartRef.current = null; + }} + className="flex min-h-10 gap-1 overflow-x-auto px-0 py-1" + > + {repositories.map((repository) => ( + + ))} +
+ ); +}; + +const DirectCommitList = ({ + repositories, + selectedRepositoryId, + repositoryName, + onSelectRepository, + commits, + hasNextPage, + isFetchingNextPage, + onLoadMore, + onSelectCommit, + onDragStart, + onDragEnd, +}: DirectCommitListProps) => ( + <> + +
{ + const element = event.currentTarget; + const remaining = + element.scrollHeight - element.scrollTop - element.clientHeight; + if (hasNextPage && !isFetchingNextPage && remaining < 80) onLoadMore(); + }} + className="flex flex-1 flex-col overflow-y-auto" + > + {commits.map((commit) => ( + onSelectCommit(commit)} + onDragStart={() => onDragStart(commit.commit_id)} + onDragEnd={onDragEnd} + /> + ))} + {isFetchingNextPage ? ( +

+ 커밋을 더 불러오는 중입니다 +

+ ) : null} +
+ +); + +export default DirectCommitList; diff --git a/web/src/pages/decisions/components/OriginalContextTab.tsx b/web/src/pages/decisions/components/OriginalContextTab.tsx new file mode 100644 index 0000000..e0a05b7 --- /dev/null +++ b/web/src/pages/decisions/components/OriginalContextTab.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from "react"; +import IconCircleUser from "@/assets/icons/user/ic_circle_user.svg?react"; +import { Icon } from "@/components/common/Icon"; +import type { + DecisionContextMessage, + DecisionReasonItem, +} from "@/types/decision"; +import GlassCard from "./GlassCard"; + +interface OriginalContextTabProps { + messages: DecisionContextMessage[]; + reasons: DecisionReasonItem[]; +} + +const ContextMessage = ({ + message, + selected, + onSelect, +}: { + message: DecisionContextMessage; + selected: boolean; + onSelect: () => void; +}) => ( + +); + +const OriginalContextTab = ({ messages, reasons }: OriginalContextTabProps) => { + const [selectedIndex, setSelectedIndex] = useState(0); + // biome-ignore lint/correctness/useExhaustiveDependencies: messages 참조 변경을 트리거로 사용 + useEffect(() => { + setSelectedIndex(0); + }, [messages]); + const safeIndex = selectedIndex < messages.length ? selectedIndex : 0; + const selectedMessage = messages[safeIndex]; + const relatedMessages = messages + .map((message, index) => ({ message, index })) + .filter(({ index }) => index !== safeIndex); + const primaryReason = reasons[0]?.title ?? "결정 핵심 근거"; + + if (!selectedMessage) { + return ( + +

+ 표시할 원문 맥락이 없습니다. +

+
+ ); + } + + return ( +
+ +

+ 맥락 목록 +

+
+ {messages.map((message, index) => ( + setSelectedIndex(index)} + /> + ))} +
+
+ +
+ +

+ {selectedMessage.time} · {selectedMessage.member_name} +

+

+ {selectedMessage.content || primaryReason} +

+
+ “{selectedMessage.dialogue_content}” +
+
+ + +

+ 관련 맥락 +

+ {relatedMessages.map(({ message, index }) => ( + + ))} +
+ +
+

✨ 요약 (AI 생성)

+

+ 선택한 발언은 {primaryReason}와 관련된 결정의 맥락을 보여줍니다. +

+
+
+
+ ); +}; + +export default OriginalContextTab; diff --git a/web/src/pages/decisions/hooks/useLinkCommit.ts b/web/src/pages/decisions/hooks/useLinkCommit.ts index 5c44d17..0591f9e 100644 --- a/web/src/pages/decisions/hooks/useLinkCommit.ts +++ b/web/src/pages/decisions/hooks/useLinkCommit.ts @@ -7,7 +7,7 @@ import { APPLICATION_CONNECTED_COMMITS_QUERY_KEY } from "./useConnectedCommits"; import { APPLICATION_RECOMMENDED_COMMITS_QUERY_KEY } from "./useRecommendedCommits"; interface UseLinkCommitOptions { - onSuccess?: () => void; + onSuccess?: (commitIds: number[]) => void; } export const useLinkCommit = ( @@ -20,7 +20,7 @@ export const useLinkCommit = ( const mutation = useMutation({ mutationFn: (commitIds: number[]) => linkCommit(applicationId, { commit_ids: commitIds }), - onSuccess: async () => { + onSuccess: async (_, commitIds) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: [ @@ -32,7 +32,7 @@ export const useLinkCommit = ( queryKey: [...APPLICATION_CONNECTED_COMMITS_QUERY_KEY, applicationId], }), ]); - options?.onSuccess?.(); + options?.onSuccess?.(commitIds); }, onError: (error: unknown) => { if (isAxiosError>(error)) { diff --git a/web/src/pages/decisions/hooks/useUnlinkCommit.ts b/web/src/pages/decisions/hooks/useUnlinkCommit.ts index 41ed77f..426dae3 100644 --- a/web/src/pages/decisions/hooks/useUnlinkCommit.ts +++ b/web/src/pages/decisions/hooks/useUnlinkCommit.ts @@ -7,7 +7,7 @@ import { APPLICATION_CONNECTED_COMMITS_QUERY_KEY } from "./useConnectedCommits"; import { APPLICATION_RECOMMENDED_COMMITS_QUERY_KEY } from "./useRecommendedCommits"; interface UseUnlinkCommitOptions { - onSuccess?: () => void; + onSuccess?: (commitId: number) => void; } export const useUnlinkCommit = ( @@ -20,7 +20,7 @@ export const useUnlinkCommit = ( const mutation = useMutation({ mutationFn: (commitId: number) => unlinkCommit(applicationId, { commit_id: commitId }), - onSuccess: async () => { + onSuccess: async (_, commitId) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: [ @@ -32,7 +32,7 @@ export const useUnlinkCommit = ( queryKey: [...APPLICATION_CONNECTED_COMMITS_QUERY_KEY, applicationId], }), ]); - options?.onSuccess?.(); + options?.onSuccess?.(commitId); }, onError: (error: unknown) => { if (isAxiosError>(error)) { diff --git a/web/src/pages/decisions/mocks.ts b/web/src/pages/decisions/mocks.ts index e289dbf..f795106 100644 --- a/web/src/pages/decisions/mocks.ts +++ b/web/src/pages/decisions/mocks.ts @@ -17,6 +17,7 @@ export const MOCK_DECISION_DETAIL_VIEW_MODEL: DecisionDetailViewModel = { member_id: 1, member_name: "김준용", profile_image: avatar("kim"), + content: "세션 유형별 TTL 분리 필요성을 제기함", dialogue_content: "Redis 키 만료 정책이 지금 TTL 고정인데, 세션 유형별로 다르게 가져가야 할 것 같아요. 지금 인증 세션이랑 캐시 세션이 같은 TTL인 게 문제인 거죠.", }, @@ -25,6 +26,7 @@ export const MOCK_DECISION_DETAIL_VIEW_MODEL: DecisionDetailViewModel = { member_id: 2, member_name: "유진", profile_image: avatar("yujin"), + content: "Redis 유지와 키 네이밍 규칙 변경을 제안함", dialogue_content: "Mongo로 옮기자는 의견도 있었는데, 운영 복잡도 올라가니까 Redis 유지하면서 키 네이밍 규칙만 바꾸는 게 낫지 않을까요?", }, @@ -33,6 +35,7 @@ export const MOCK_DECISION_DETAIL_VIEW_MODEL: DecisionDetailViewModel = { member_id: 3, member_name: "유상완", profile_image: avatar("yoo"), + content: "키 정책 분리로 메모리 비용에 대응할 수 있다고 판단함", dialogue_content: "저도 Redis 유지에 동의합니다. 메모리 비용 이슈는 키 정책 분리로 충분히 대응 가능할 것 같아요.", }, @@ -41,6 +44,7 @@ export const MOCK_DECISION_DETAIL_VIEW_MODEL: DecisionDetailViewModel = { member_id: 4, member_name: "조윤지", profile_image: avatar("cho"), + content: "세션 유형별 키 정책 분리를 최종 합의함", dialogue_content: "좋습니다. 그럼 Redis 유지하고 키 정책만 세션 유형별로 분리하는 걸로 가겠습니다.", }, diff --git a/web/src/styles/base/base.css b/web/src/styles/base/base.css index 56f5f20..bb50b65 100644 --- a/web/src/styles/base/base.css +++ b/web/src/styles/base/base.css @@ -68,3 +68,22 @@ body { box-shadow: none; -webkit-box-shadow: none; } + +.application-scroll::-webkit-scrollbar { + display: none; +} + +.application-scroll::-webkit-scrollbar-track { + background-color: transparent; + box-shadow: none; +} + +.application-scroll::-webkit-scrollbar-thumb { + background-color: transparent; + box-shadow: none; + -webkit-box-shadow: none; +} + +.application-scroll { + scrollbar-width: none; +} diff --git a/web/src/types/application.ts b/web/src/types/application.ts index c4be060..6408dec 100644 --- a/web/src/types/application.ts +++ b/web/src/types/application.ts @@ -10,6 +10,7 @@ export interface ApplicationContextMessage { member_id: number; member_name: string; profile_image: string; + content: string | null; dialogue_content: string; } @@ -34,6 +35,9 @@ export interface ApplicationRecommendedCommit { commit_id: string; commit_hash: string; message: string; + author_name: string; + added_lines: number; + deleted_lines: number; reason: string; } @@ -43,6 +47,9 @@ export interface ApplicationConnectedCommit { commit_id: number; commit_hash: string; message: string; + author_name: string; + added_lines: number; + deleted_lines: number; committed_date: string; } diff --git a/web/src/types/decision.ts b/web/src/types/decision.ts index ae85e34..1ac686c 100644 --- a/web/src/types/decision.ts +++ b/web/src/types/decision.ts @@ -10,6 +10,7 @@ export interface DecisionContextMessage { member_id: number; member_name: string; profile_image: string; + content: string | null; dialogue_content: string; }