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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 Down Expand Up @@ -166,6 +177,14 @@
' "content": "간략 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ],",
' "context_items": [',
" {",
' "timestamp": "...",',
' "member_id": 1,',
' "content": "해당 발화 요약 한 문장",',
' "utterance": "실제 발화 원문"',
" }",
" ]",
" }",
" ],",
Expand Down Expand 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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 Down Expand 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