Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ai/app/domains/meeting_analysis/schemas.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -62,6 +69,13 @@ class Application(BaseModel):
default_factory=list,
description="적용사항 도출 타임라인(이슈제기→대안논의→적용합의)",
)
context_items: list[ApplicationContextItem] = Field(
default_factory=list,
description=(
"적용사항과 관련된 개별 발화별 요약 목록. "
"timeline과 달리 관련된 발화를 빠짐없이 담는다."
),
)


class MeetingAnalysisResult(BaseModel):
Expand Down
99 changes: 78 additions & 21 deletions ai/app/domains/meeting_analysis/services/extraction.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
)
Expand DownExpand Up@@ -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 구조]:",
Expand DownExpand Up@@ -166,6 +177,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -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": [',
Expand All@@ -250,6 +278,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -568,45 +604,66 @@ 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
}
corrected_count = 0
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,
)

Expand Down
35 changes: 35 additions & 0 deletions ai/tests/test_timeline_member_id.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
from app.domains.meeting_analysis.schemas import (
Application,
ApplicationContextItem,
ApplicationTimelineItem,
MeetingAnalysisResult,
)
Expand All@@ -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=[
Expand Down
36 changes: 36 additions & 0 deletions docs/pr-reviews/PR-14.md
Original file line numberDiff line numberDiff line change
@@ -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|

<!-- whylog-ai-pr-review-state {"findings":[],"head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","history":[{"generated_at":"2026-08-21T20:30:19+00:00","head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0},{"generated_at":"2026-08-15T08:51:38+00:00","head_sha":"ccf27d61531b05b5857697b0adfb939c9cc3f328","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0}],"model":"gemini-3.6-flash","pr":{"author":"ggamnunq","base":"develop","head":"feat/application-tabs","number":14,"title":"feat(web, server): 적용사항 대시보드 개선","url":"https://github.com/WhyLog-App/WhyLog/pull/14"},"provider":"Google","resolved":[],"review_input_digest":"846908196bad8f5e12a1aa02d7279ed8661d5d58353a7ba6d26d9610ce149bdf","schema":1,"status":"PASS","summary":"적용사항 대시보드 탭 재구성 및 원문 맥락(context_items) 백엔드, AI, 프론트엔드 연동이 저장소 규칙에 맞게 차단 요소 없이 잘 구현되었습니다."} -->
<!-- whylog-ai-pr-review-signature c0c5a25614b930cd7a84fd0f9e515547a856353c251cd650d8dbc7983f5141d7 -->
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -70,7 +68,6 @@ public static class DecisionTimelineItemDTO {

@Schema(description = "타임라인 내용", example = "장애 이슈 제기")
private String content;

}

@Getter
Expand All@@ -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
Expand All@@ -109,7 +111,6 @@ public static class DecisionReasonItemDTO {

@Schema(description = "근거 내용", example = "운영복잡 우려로 보류")
private String title;

}

@Getter
Expand DownExpand Up@@ -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;

Expand DownExpand Up@@ -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;
}
Expand All@@ -216,5 +235,4 @@ public static class CommitConnectionResponseDTO {
@Schema(description = "연결된 커밋 ID 목록", example = "[1, 2, 3]")
private List<Long> commitIds;
}

}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ai/app/domains/meeting_analysis/schemas.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -62,6 +69,13 @@ class Application(BaseModel):
default_factory=list,
description="적용사항 도출 타임라인(이슈제기→대안논의→적용합의)",
)
context_items: list[ApplicationContextItem] = Field(
default_factory=list,
description=(
"적용사항과 관련된 개별 발화별 요약 목록. "
"timeline과 달리 관련된 발화를 빠짐없이 담는다."
),
)


class MeetingAnalysisResult(BaseModel):
Expand Down
99 changes: 78 additions & 21 deletions ai/app/domains/meeting_analysis/services/extraction.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
)
Expand DownExpand Up@@ -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 구조]:",
Expand DownExpand Up@@ -166,6 +177,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -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": [',
Expand All@@ -250,6 +278,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -568,45 +604,66 @@ 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
}
corrected_count = 0
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,
)

Expand Down
35 changes: 35 additions & 0 deletions ai/tests/test_timeline_member_id.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
from app.domains.meeting_analysis.schemas import (
Application,
ApplicationContextItem,
ApplicationTimelineItem,
MeetingAnalysisResult,
)
Expand All@@ -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=[
Expand Down
36 changes: 36 additions & 0 deletions docs/pr-reviews/PR-14.md
Original file line numberDiff line numberDiff line change
@@ -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|

<!-- whylog-ai-pr-review-state {"findings":[],"head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","history":[{"generated_at":"2026-08-21T20:30:19+00:00","head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0},{"generated_at":"2026-08-15T08:51:38+00:00","head_sha":"ccf27d61531b05b5857697b0adfb939c9cc3f328","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0}],"model":"gemini-3.6-flash","pr":{"author":"ggamnunq","base":"develop","head":"feat/application-tabs","number":14,"title":"feat(web, server): 적용사항 대시보드 개선","url":"https://github.com/WhyLog-App/WhyLog/pull/14"},"provider":"Google","resolved":[],"review_input_digest":"846908196bad8f5e12a1aa02d7279ed8661d5d58353a7ba6d26d9610ce149bdf","schema":1,"status":"PASS","summary":"적용사항 대시보드 탭 재구성 및 원문 맥락(context_items) 백엔드, AI, 프론트엔드 연동이 저장소 규칙에 맞게 차단 요소 없이 잘 구현되었습니다."} -->
<!-- whylog-ai-pr-review-signature c0c5a25614b930cd7a84fd0f9e515547a856353c251cd650d8dbc7983f5141d7 -->
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -70,7 +68,6 @@ public static class DecisionTimelineItemDTO {

@Schema(description = "타임라인 내용", example = "장애 이슈 제기")
private String content;

}

@Getter
Expand All@@ -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
Expand All@@ -109,7 +111,6 @@ public static class DecisionReasonItemDTO {

@Schema(description = "근거 내용", example = "운영복잡 우려로 보류")
private String title;

}

@Getter
Expand DownExpand Up@@ -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;

Expand DownExpand Up@@ -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;
}
Expand All@@ -216,5 +235,4 @@ public static class CommitConnectionResponseDTO {
@Schema(description = "연결된 커밋 ID 목록", example = "[1, 2, 3]")
private List<Long> commitIds;
}

}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ai/app/domains/meeting_analysis/schemas.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -62,6 +69,13 @@ class Application(BaseModel):
default_factory=list,
description="적용사항 도출 타임라인(이슈제기→대안논의→적용합의)",
)
context_items: list[ApplicationContextItem] = Field(
default_factory=list,
description=(
"적용사항과 관련된 개별 발화별 요약 목록. "
"timeline과 달리 관련된 발화를 빠짐없이 담는다."
),
)


class MeetingAnalysisResult(BaseModel):
Expand Down
99 changes: 78 additions & 21 deletions ai/app/domains/meeting_analysis/services/extraction.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
)
Expand DownExpand Up@@ -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 구조]:",
Expand DownExpand Up@@ -166,6 +177,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -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": [',
Expand All@@ -250,6 +278,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -568,45 +604,66 @@ 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
}
corrected_count = 0
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,
)

Expand Down
35 changes: 35 additions & 0 deletions ai/tests/test_timeline_member_id.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
from app.domains.meeting_analysis.schemas import (
Application,
ApplicationContextItem,
ApplicationTimelineItem,
MeetingAnalysisResult,
)
Expand All@@ -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=[
Expand Down
36 changes: 36 additions & 0 deletions docs/pr-reviews/PR-14.md
Original file line numberDiff line numberDiff line change
@@ -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|

<!-- whylog-ai-pr-review-state {"findings":[],"head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","history":[{"generated_at":"2026-08-21T20:30:19+00:00","head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0},{"generated_at":"2026-08-15T08:51:38+00:00","head_sha":"ccf27d61531b05b5857697b0adfb939c9cc3f328","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0}],"model":"gemini-3.6-flash","pr":{"author":"ggamnunq","base":"develop","head":"feat/application-tabs","number":14,"title":"feat(web, server): 적용사항 대시보드 개선","url":"https://github.com/WhyLog-App/WhyLog/pull/14"},"provider":"Google","resolved":[],"review_input_digest":"846908196bad8f5e12a1aa02d7279ed8661d5d58353a7ba6d26d9610ce149bdf","schema":1,"status":"PASS","summary":"적용사항 대시보드 탭 재구성 및 원문 맥락(context_items) 백엔드, AI, 프론트엔드 연동이 저장소 규칙에 맞게 차단 요소 없이 잘 구현되었습니다."} -->
<!-- whylog-ai-pr-review-signature c0c5a25614b930cd7a84fd0f9e515547a856353c251cd650d8dbc7983f5141d7 -->
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -70,7 +68,6 @@ public static class DecisionTimelineItemDTO {

@Schema(description = "타임라인 내용", example = "장애 이슈 제기")
private String content;

}

@Getter
Expand All@@ -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
Expand All@@ -109,7 +111,6 @@ public static class DecisionReasonItemDTO {

@Schema(description = "근거 내용", example = "운영복잡 우려로 보류")
private String title;

}

@Getter
Expand DownExpand Up@@ -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;

Expand DownExpand Up@@ -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;
}
Expand All@@ -216,5 +235,4 @@ public static class CommitConnectionResponseDTO {
@Schema(description = "연결된 커밋 ID 목록", example = "[1, 2, 3]")
private List<Long> commitIds;
}

}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ai/app/domains/meeting_analysis/schemas.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -62,6 +69,13 @@ class Application(BaseModel):
default_factory=list,
description="적용사항 도출 타임라인(이슈제기→대안논의→적용합의)",
)
context_items: list[ApplicationContextItem] = Field(
default_factory=list,
description=(
"적용사항과 관련된 개별 발화별 요약 목록. "
"timeline과 달리 관련된 발화를 빠짐없이 담는다."
),
)


class MeetingAnalysisResult(BaseModel):
Expand Down
99 changes: 78 additions & 21 deletions ai/app/domains/meeting_analysis/services/extraction.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
)
Expand DownExpand Up@@ -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 구조]:",
Expand DownExpand Up@@ -166,6 +177,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -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": [',
Expand All@@ -250,6 +278,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -568,45 +604,66 @@ 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
}
corrected_count = 0
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,
)

Expand Down
35 changes: 35 additions & 0 deletions ai/tests/test_timeline_member_id.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
from app.domains.meeting_analysis.schemas import (
Application,
ApplicationContextItem,
ApplicationTimelineItem,
MeetingAnalysisResult,
)
Expand All@@ -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=[
Expand Down
36 changes: 36 additions & 0 deletions docs/pr-reviews/PR-14.md
Original file line numberDiff line numberDiff line change
@@ -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|

<!-- whylog-ai-pr-review-state {"findings":[],"head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","history":[{"generated_at":"2026-08-21T20:30:19+00:00","head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0},{"generated_at":"2026-08-15T08:51:38+00:00","head_sha":"ccf27d61531b05b5857697b0adfb939c9cc3f328","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0}],"model":"gemini-3.6-flash","pr":{"author":"ggamnunq","base":"develop","head":"feat/application-tabs","number":14,"title":"feat(web, server): 적용사항 대시보드 개선","url":"https://github.com/WhyLog-App/WhyLog/pull/14"},"provider":"Google","resolved":[],"review_input_digest":"846908196bad8f5e12a1aa02d7279ed8661d5d58353a7ba6d26d9610ce149bdf","schema":1,"status":"PASS","summary":"적용사항 대시보드 탭 재구성 및 원문 맥락(context_items) 백엔드, AI, 프론트엔드 연동이 저장소 규칙에 맞게 차단 요소 없이 잘 구현되었습니다."} -->
<!-- whylog-ai-pr-review-signature c0c5a25614b930cd7a84fd0f9e515547a856353c251cd650d8dbc7983f5141d7 -->
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -70,7 +68,6 @@ public static class DecisionTimelineItemDTO {

@Schema(description = "타임라인 내용", example = "장애 이슈 제기")
private String content;

}

@Getter
Expand All@@ -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
Expand All@@ -109,7 +111,6 @@ public static class DecisionReasonItemDTO {

@Schema(description = "근거 내용", example = "운영복잡 우려로 보류")
private String title;

}

@Getter
Expand DownExpand Up@@ -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;

Expand DownExpand Up@@ -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;
}
Expand All@@ -216,5 +235,4 @@ public static class CommitConnectionResponseDTO {
@Schema(description = "연결된 커밋 ID 목록", example = "[1, 2, 3]")
private List<Long> commitIds;
}

}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ai/app/domains/meeting_analysis/schemas.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -62,6 +69,13 @@ class Application(BaseModel):
default_factory=list,
description="적용사항 도출 타임라인(이슈제기→대안논의→적용합의)",
)
context_items: list[ApplicationContextItem] = Field(
default_factory=list,
description=(
"적용사항과 관련된 개별 발화별 요약 목록. "
"timeline과 달리 관련된 발화를 빠짐없이 담는다."
),
)


class MeetingAnalysisResult(BaseModel):
Expand Down
99 changes: 78 additions & 21 deletions ai/app/domains/meeting_analysis/services/extraction.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
)
Expand DownExpand Up@@ -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 구조]:",
Expand DownExpand Up@@ -166,6 +177,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -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": [',
Expand All@@ -250,6 +278,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -568,45 +604,66 @@ 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
}
corrected_count = 0
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,
)

Expand Down
35 changes: 35 additions & 0 deletions ai/tests/test_timeline_member_id.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
from app.domains.meeting_analysis.schemas import (
Application,
ApplicationContextItem,
ApplicationTimelineItem,
MeetingAnalysisResult,
)
Expand All@@ -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=[
Expand Down
36 changes: 36 additions & 0 deletions docs/pr-reviews/PR-14.md
Original file line numberDiff line numberDiff line change
@@ -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|

<!-- whylog-ai-pr-review-state {"findings":[],"head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","history":[{"generated_at":"2026-08-21T20:30:19+00:00","head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0},{"generated_at":"2026-08-15T08:51:38+00:00","head_sha":"ccf27d61531b05b5857697b0adfb939c9cc3f328","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0}],"model":"gemini-3.6-flash","pr":{"author":"ggamnunq","base":"develop","head":"feat/application-tabs","number":14,"title":"feat(web, server): 적용사항 대시보드 개선","url":"https://github.com/WhyLog-App/WhyLog/pull/14"},"provider":"Google","resolved":[],"review_input_digest":"846908196bad8f5e12a1aa02d7279ed8661d5d58353a7ba6d26d9610ce149bdf","schema":1,"status":"PASS","summary":"적용사항 대시보드 탭 재구성 및 원문 맥락(context_items) 백엔드, AI, 프론트엔드 연동이 저장소 규칙에 맞게 차단 요소 없이 잘 구현되었습니다."} -->
<!-- whylog-ai-pr-review-signature c0c5a25614b930cd7a84fd0f9e515547a856353c251cd650d8dbc7983f5141d7 -->
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -70,7 +68,6 @@ public static class DecisionTimelineItemDTO {

@Schema(description = "타임라인 내용", example = "장애 이슈 제기")
private String content;

}

@Getter
Expand All@@ -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
Expand All@@ -109,7 +111,6 @@ public static class DecisionReasonItemDTO {

@Schema(description = "근거 내용", example = "운영복잡 우려로 보류")
private String title;

}

@Getter
Expand DownExpand Up@@ -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;

Expand DownExpand Up@@ -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;
}
Expand All@@ -216,5 +235,4 @@ public static class CommitConnectionResponseDTO {
@Schema(description = "연결된 커밋 ID 목록", example = "[1, 2, 3]")
private List<Long> commitIds;
}

}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ai/app/domains/meeting_analysis/schemas.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -62,6 +69,13 @@ class Application(BaseModel):
default_factory=list,
description="적용사항 도출 타임라인(이슈제기→대안논의→적용합의)",
)
context_items: list[ApplicationContextItem] = Field(
default_factory=list,
description=(
"적용사항과 관련된 개별 발화별 요약 목록. "
"timeline과 달리 관련된 발화를 빠짐없이 담는다."
),
)


class MeetingAnalysisResult(BaseModel):
Expand Down
99 changes: 78 additions & 21 deletions ai/app/domains/meeting_analysis/services/extraction.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
)
Expand DownExpand Up@@ -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 구조]:",
Expand DownExpand Up@@ -166,6 +177,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -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": [',
Expand All@@ -250,6 +278,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -568,45 +604,66 @@ 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
}
corrected_count = 0
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,
)

Expand Down
35 changes: 35 additions & 0 deletions ai/tests/test_timeline_member_id.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
from app.domains.meeting_analysis.schemas import (
Application,
ApplicationContextItem,
ApplicationTimelineItem,
MeetingAnalysisResult,
)
Expand All@@ -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=[
Expand Down
36 changes: 36 additions & 0 deletions docs/pr-reviews/PR-14.md
Original file line numberDiff line numberDiff line change
@@ -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|

<!-- whylog-ai-pr-review-state {"findings":[],"head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","history":[{"generated_at":"2026-08-21T20:30:19+00:00","head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0},{"generated_at":"2026-08-15T08:51:38+00:00","head_sha":"ccf27d61531b05b5857697b0adfb939c9cc3f328","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0}],"model":"gemini-3.6-flash","pr":{"author":"ggamnunq","base":"develop","head":"feat/application-tabs","number":14,"title":"feat(web, server): 적용사항 대시보드 개선","url":"https://github.com/WhyLog-App/WhyLog/pull/14"},"provider":"Google","resolved":[],"review_input_digest":"846908196bad8f5e12a1aa02d7279ed8661d5d58353a7ba6d26d9610ce149bdf","schema":1,"status":"PASS","summary":"적용사항 대시보드 탭 재구성 및 원문 맥락(context_items) 백엔드, AI, 프론트엔드 연동이 저장소 규칙에 맞게 차단 요소 없이 잘 구현되었습니다."} -->
<!-- whylog-ai-pr-review-signature c0c5a25614b930cd7a84fd0f9e515547a856353c251cd650d8dbc7983f5141d7 -->
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -70,7 +68,6 @@ public static class DecisionTimelineItemDTO {

@Schema(description = "타임라인 내용", example = "장애 이슈 제기")
private String content;

}

@Getter
Expand All@@ -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
Expand All@@ -109,7 +111,6 @@ public static class DecisionReasonItemDTO {

@Schema(description = "근거 내용", example = "운영복잡 우려로 보류")
private String title;

}

@Getter
Expand DownExpand Up@@ -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;

Expand DownExpand Up@@ -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;
}
Expand All@@ -216,5 +235,4 @@ public static class CommitConnectionResponseDTO {
@Schema(description = "연결된 커밋 ID 목록", example = "[1, 2, 3]")
private List<Long> commitIds;
}

}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ai/app/domains/meeting_analysis/schemas.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -62,6 +69,13 @@ class Application(BaseModel):
default_factory=list,
description="적용사항 도출 타임라인(이슈제기→대안논의→적용합의)",
)
context_items: list[ApplicationContextItem] = Field(
default_factory=list,
description=(
"적용사항과 관련된 개별 발화별 요약 목록. "
"timeline과 달리 관련된 발화를 빠짐없이 담는다."
),
)


class MeetingAnalysisResult(BaseModel):
Expand Down
99 changes: 78 additions & 21 deletions ai/app/domains/meeting_analysis/services/extraction.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
)
Expand DownExpand Up@@ -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 구조]:",
Expand DownExpand Up@@ -166,6 +177,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -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": [',
Expand All@@ -250,6 +278,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -568,45 +604,66 @@ 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
}
corrected_count = 0
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,
)

Expand Down
35 changes: 35 additions & 0 deletions ai/tests/test_timeline_member_id.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
from app.domains.meeting_analysis.schemas import (
Application,
ApplicationContextItem,
ApplicationTimelineItem,
MeetingAnalysisResult,
)
Expand All@@ -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=[
Expand Down
36 changes: 36 additions & 0 deletions docs/pr-reviews/PR-14.md
Original file line numberDiff line numberDiff line change
@@ -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|

<!-- whylog-ai-pr-review-state {"findings":[],"head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","history":[{"generated_at":"2026-08-21T20:30:19+00:00","head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0},{"generated_at":"2026-08-15T08:51:38+00:00","head_sha":"ccf27d61531b05b5857697b0adfb939c9cc3f328","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0}],"model":"gemini-3.6-flash","pr":{"author":"ggamnunq","base":"develop","head":"feat/application-tabs","number":14,"title":"feat(web, server): 적용사항 대시보드 개선","url":"https://github.com/WhyLog-App/WhyLog/pull/14"},"provider":"Google","resolved":[],"review_input_digest":"846908196bad8f5e12a1aa02d7279ed8661d5d58353a7ba6d26d9610ce149bdf","schema":1,"status":"PASS","summary":"적용사항 대시보드 탭 재구성 및 원문 맥락(context_items) 백엔드, AI, 프론트엔드 연동이 저장소 규칙에 맞게 차단 요소 없이 잘 구현되었습니다."} -->
<!-- whylog-ai-pr-review-signature c0c5a25614b930cd7a84fd0f9e515547a856353c251cd650d8dbc7983f5141d7 -->
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -70,7 +68,6 @@ public static class DecisionTimelineItemDTO {

@Schema(description = "타임라인 내용", example = "장애 이슈 제기")
private String content;

}

@Getter
Expand All@@ -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
Expand All@@ -109,7 +111,6 @@ public static class DecisionReasonItemDTO {

@Schema(description = "근거 내용", example = "운영복잡 우려로 보류")
private String title;

}

@Getter
Expand DownExpand Up@@ -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;

Expand DownExpand Up@@ -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;
}
Expand All@@ -216,5 +235,4 @@ public static class CommitConnectionResponseDTO {
@Schema(description = "연결된 커밋 ID 목록", example = "[1, 2, 3]")
private List<Long> commitIds;
}

}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ai/app/domains/meeting_analysis/schemas.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -62,6 +69,13 @@ class Application(BaseModel):
default_factory=list,
description="적용사항 도출 타임라인(이슈제기→대안논의→적용합의)",
)
context_items: list[ApplicationContextItem] = Field(
default_factory=list,
description=(
"적용사항과 관련된 개별 발화별 요약 목록. "
"timeline과 달리 관련된 발화를 빠짐없이 담는다."
),
)


class MeetingAnalysisResult(BaseModel):
Expand Down
99 changes: 78 additions & 21 deletions ai/app/domains/meeting_analysis/services/extraction.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
)
Expand DownExpand Up@@ -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 구조]:",
Expand DownExpand Up@@ -166,6 +177,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -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": [',
Expand All@@ -250,6 +278,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand DownExpand Up@@ -568,45 +604,66 @@ 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
}
corrected_count = 0
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,
)

Expand Down
35 changes: 35 additions & 0 deletions ai/tests/test_timeline_member_id.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
from app.domains.meeting_analysis.schemas import (
Application,
ApplicationContextItem,
ApplicationTimelineItem,
MeetingAnalysisResult,
)
Expand All@@ -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=[
Expand Down
36 changes: 36 additions & 0 deletions docs/pr-reviews/PR-14.md
Original file line numberDiff line numberDiff line change
@@ -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|

<!-- whylog-ai-pr-review-state {"findings":[],"head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","history":[{"generated_at":"2026-08-21T20:30:19+00:00","head_sha":"e9c2b3298c4954f27f3057b2e45dbe1f1bd96005","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0},{"generated_at":"2026-08-15T08:51:38+00:00","head_sha":"ccf27d61531b05b5857697b0adfb939c9cc3f328","model":"gemini-3.6-flash","new":0,"ongoing":0,"provider":"Google","resolved":0,"status":"PASS","total":0}],"model":"gemini-3.6-flash","pr":{"author":"ggamnunq","base":"develop","head":"feat/application-tabs","number":14,"title":"feat(web, server): 적용사항 대시보드 개선","url":"https://github.com/WhyLog-App/WhyLog/pull/14"},"provider":"Google","resolved":[],"review_input_digest":"846908196bad8f5e12a1aa02d7279ed8661d5d58353a7ba6d26d9610ce149bdf","schema":1,"status":"PASS","summary":"적용사항 대시보드 탭 재구성 및 원문 맥락(context_items) 백엔드, AI, 프론트엔드 연동이 저장소 규칙에 맞게 차단 요소 없이 잘 구현되었습니다."} -->
<!-- whylog-ai-pr-review-signature c0c5a25614b930cd7a84fd0f9e515547a856353c251cd650d8dbc7983f5141d7 -->
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -70,7 +68,6 @@ public static class DecisionTimelineItemDTO {

@Schema(description = "타임라인 내용", example = "장애 이슈 제기")
private String content;

}

@Getter
Expand All@@ -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
Expand All@@ -109,7 +111,6 @@ public static class DecisionReasonItemDTO {

@Schema(description = "근거 내용", example = "운영복잡 우려로 보류")
private String title;

}

@Getter
Expand DownExpand Up@@ -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;

Expand DownExpand Up@@ -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;
}
Expand All@@ -216,5 +235,4 @@ public static class CommitConnectionResponseDTO {
@Schema(description = "연결된 커밋 ID 목록", example = "[1, 2, 3]")
private List<Long> commitIds;
}

}
Loading