Skip to content

Commit dc5ae10

Browse files
authored
Merge pull request #56 from Team-StackUp/feature/analysis-sse
feat(analysis): 분석 단계별 진행 상황 실시간 표시 (AI→RealTime 직접)
2 parents 446f63e + 64ce125 commit dc5ae10

19 files changed

Lines changed: 564 additions & 76 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Awaitable, Callable
4+
5+
# analyzer 가 단계마다 호출하는 진행 emitter. (phase, message) 만 받으며,
6+
# 실제 발행(user/target/trace 바인딩 + RealTime 직접 publish)은 messaging 레이어가 주입한다.
7+
# analyzer 가 messaging 을 import 하지 않도록(레이어 규칙) 타입만 여기 둔다.
8+
ProgressEmitter = Callable[[str, str], Awaitable[None]]

ai/src/ai_server/analyzer/repository_analyzer.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
EmbeddingStepError,
99
chunk_embed_and_upsert,
1010
)
11+
from ai_server.analyzer._progress import ProgressEmitter
1112
from ai_server.analyzer.sources.github_repo import (
1213
GitHubRepoSourceExtractor,
1314
RepositoryFetchError,
@@ -66,7 +67,12 @@ async def analyze(
6667
default_branch: str = "main",
6768
user_id: int | None,
6869
analyzed_document_id: int,
70+
progress: ProgressEmitter | None = None,
6971
) -> RepositoryAnalysisResult:
72+
async def emit(phase: str, message: str) -> None:
73+
if progress is not None:
74+
await progress(phase, message)
75+
7076
if user_id is None:
7177
raise RepositoryAnalyzeError(
7278
code="MISSING_USER_ID",
@@ -86,6 +92,7 @@ async def analyze(
8692
code=err.code, message=err.message, retriable=err.retriable
8793
) from err
8894

95+
await emit("EXTRACTING", "레포지토리 코드를 가져오는 중…")
8996
log.info(
9097
"repository.extract.start",
9198
repository_id=repository_id,
@@ -109,6 +116,7 @@ async def analyze(
109116
retriable=False,
110117
)
111118

119+
await emit("SUMMARIZING", "AI가 코드를 분석·요약하는 중…")
112120
log.info(
113121
"repository.llm.start",
114122
repository_id=repository_id,
@@ -128,6 +136,7 @@ async def analyze(
128136
md_chars=len(analysis.markdown),
129137
)
130138

139+
await emit("EMBEDDING", "분석 결과를 임베딩하는 중…")
131140
try:
132141
chunk_count = await chunk_embed_and_upsert(
133142
document_id=analyzed_document_id,

ai/src/ai_server/analyzer/resume_analyzer.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
EmbeddingStepError,
99
chunk_embed_and_upsert,
1010
)
11+
from ai_server.analyzer._progress import ProgressEmitter
1112
from ai_server.analyzer.sources.base import SourceExtractor
1213
from ai_server.chain.document_analysis_chain import DocumentAnalyzer
1314
from ai_server.core.client import CoreClient
@@ -61,7 +62,13 @@ async def analyze(
6162
resume_id: int,
6263
file_path: str,
6364
analyzed_document_id: int,
65+
progress: ProgressEmitter | None = None,
6466
) -> ResumeAnalysisResult:
67+
async def emit(phase: str, message: str) -> None:
68+
if progress is not None:
69+
await progress(phase, message)
70+
71+
await emit("EXTRACTING", "이력서 텍스트를 추출하는 중…")
6572
log.info(
6673
"resume.extract.start",
6774
resume_id=resume_id,
@@ -76,6 +83,7 @@ async def analyze(
7683
retriable=False,
7784
)
7885

86+
await emit("SUMMARIZING", "AI가 이력서를 분석·요약하는 중…")
7987
log.info(
8088
"resume.llm.start",
8189
resume_id=resume_id,
@@ -96,6 +104,7 @@ async def analyze(
96104
md_chars=len(analysis.markdown),
97105
)
98106

107+
await emit("EMBEDDING", "분석 결과를 임베딩하는 중…")
99108
try:
100109
chunk_count = await chunk_embed_and_upsert(
101110
document_id=analyzed_document_id,

ai/src/ai_server/config/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
3232
ai_callback_routing_questions: str = "callback.questions"
3333
ai_callback_routing_feedback: str = "callback.feedback"
3434
ai_callback_routing_voice: str = "callback.voice"
35+
# AI -> RealTime 직접 발행 (분석 단계 진행 상황). Core 를 거치지 않는 휘발성 알림.
36+
ai_realtime_exchange: str = "stackup.realtime"
37+
ai_realtime_routing_user: str = "realtime.user.notify"
3538
feedback_rag_top_k: int = 5
3639
# 질문 풀 초기 크기. Core 의 applyPool 이 questions[0] 만 INSERT 하므로 1 로 고정해 토큰 낭비 차단.
3740
# 후속 작업에서 풀 저장 도입 시 늘리기 (예: 5).

ai/src/ai_server/messaging/consumers/repository_consumer.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
RepositoryAnalyzer,
99
)
1010
from ai_server.messaging.idempotency import LruIdempotencyStore
11+
from ai_server.messaging.progress import AnalysisProgressNotifier
1112
from ai_server.messaging.publisher import CallbackPublisher
1213
from ai_server.model.envelope import Envelope
1314
from ai_server.model.messages.analyze import (
@@ -26,11 +27,13 @@ def __init__(
2627
publisher: CallbackPublisher,
2728
idempotency: LruIdempotencyStore,
2829
callback_routing_key: str,
30+
progress_notifier: AnalysisProgressNotifier | None = None,
2931
) -> None:
3032
self._analyzer = analyzer
3133
self._publisher = publisher
3234
self._idempotency = idempotency
3335
self._callback_routing_key = callback_routing_key
36+
self._progress = progress_notifier
3437

3538
async def handle(self, message: AbstractIncomingMessage) -> None:
3639
async with message.process(requeue=False):
@@ -92,13 +95,24 @@ async def _run_and_build_payload(
9295
user_id: int | None,
9396
trace_id: str,
9497
) -> AnalysisCallbackPayload:
98+
progress = (
99+
self._progress.emitter_for(
100+
user_id=user_id,
101+
target_type="REPOSITORY",
102+
target_id=req.repository_id,
103+
trace_id=trace_id,
104+
)
105+
if self._progress is not None
106+
else None
107+
)
95108
try:
96109
result = await self._analyzer.analyze(
97110
repository_id=req.repository_id,
98111
repo_full_name=req.repo_full_name,
99112
default_branch=req.default_branch,
100113
user_id=user_id,
101114
analyzed_document_id=req.analyzed_document_id,
115+
progress=progress,
102116
)
103117
except RepositoryAnalyzeError as err:
104118
log.warning(

ai/src/ai_server/messaging/consumers/resume_consumer.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from ai_server.analyzer.resume_analyzer import ResumeAnalyzeError, ResumeAnalyzer
77
from ai_server.messaging.idempotency import LruIdempotencyStore
8+
from ai_server.messaging.progress import AnalysisProgressNotifier
89
from ai_server.messaging.publisher import CallbackPublisher
910
from ai_server.model.envelope import Envelope
1011
from ai_server.model.messages.analyze import (
@@ -23,11 +24,13 @@ def __init__(
2324
publisher: CallbackPublisher,
2425
idempotency: LruIdempotencyStore,
2526
callback_routing_key: str,
27+
progress_notifier: AnalysisProgressNotifier | None = None,
2628
) -> None:
2729
self._analyzer = analyzer
2830
self._publisher = publisher
2931
self._idempotency = idempotency
3032
self._callback_routing_key = callback_routing_key
33+
self._progress = progress_notifier
3134

3235
async def handle(self, message: AbstractIncomingMessage) -> None:
3336
async with message.process(requeue=False):
@@ -59,7 +62,9 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
5962
trace_id=envelope.trace_id,
6063
)
6164

62-
payload = await self._run_and_build_payload(req, envelope.trace_id)
65+
payload = await self._run_and_build_payload(
66+
req, envelope.trace_id, user_id=envelope.context.user_id
67+
)
6368

6469
await self._publisher.publish(
6570
routing_key=self._callback_routing_key,
@@ -81,12 +86,25 @@ async def _run_and_build_payload(
8186
self,
8287
req: ResumeAnalyzeRequest,
8388
trace_id: str,
89+
*,
90+
user_id: int | None,
8491
) -> AnalysisCallbackPayload:
92+
progress = (
93+
self._progress.emitter_for(
94+
user_id=user_id,
95+
target_type="RESUME",
96+
target_id=req.resume_id,
97+
trace_id=trace_id,
98+
)
99+
if self._progress is not None
100+
else None
101+
)
85102
try:
86103
result = await self._analyzer.analyze(
87104
resume_id=req.resume_id,
88105
file_path=req.file_path,
89106
analyzed_document_id=req.analyzed_document_id,
107+
progress=progress,
90108
)
91109
except ResumeAnalyzeError as err:
92110
log.warning(
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import structlog
4+
5+
from ai_server.analyzer._progress import ProgressEmitter
6+
from ai_server.messaging.publisher import CallbackPublisher
7+
from ai_server.model.envelope import MessageContext
8+
from ai_server.model.messages.realtime import (
9+
AnalysisProgressData,
10+
RealtimeNotifyPayload,
11+
)
12+
13+
log = structlog.get_logger(__name__)
14+
15+
ANALYSIS_PROGRESS_EVENT = "ANALYSIS_PROGRESS"
16+
17+
18+
class AnalysisProgressNotifier:
19+
"""AI -> RealTime 직접 발행. 분석 단계 진행 상황을 user 채널 SSE 로 흘린다.
20+
21+
진행 정보는 휘발성이라 발행 실패가 분석을 막아선 안 된다 — 모든 예외를 삼키고 경고만 남긴다.
22+
"""
23+
24+
def __init__(self, *, publisher: CallbackPublisher, routing_key: str) -> None:
25+
self._publisher = publisher
26+
self._routing_key = routing_key
27+
28+
async def emit(
29+
self,
30+
*,
31+
user_id: int | None,
32+
target_type: str,
33+
target_id: int,
34+
phase: str,
35+
message: str,
36+
trace_id: str,
37+
) -> None:
38+
if user_id is None:
39+
return
40+
41+
payload = RealtimeNotifyPayload(
42+
event_type=ANALYSIS_PROGRESS_EVENT,
43+
data=AnalysisProgressData(
44+
target_type=target_type,
45+
target_id=target_id,
46+
phase=phase,
47+
message=message,
48+
),
49+
)
50+
try:
51+
await self._publisher.publish(
52+
routing_key=self._routing_key,
53+
message_type=self._routing_key,
54+
payload=payload,
55+
trace_id=trace_id,
56+
correlation_id=f"progress-{target_type}-{target_id}-{phase}",
57+
context=MessageContext(user_id=user_id),
58+
)
59+
except Exception:
60+
log.warning(
61+
"analysis.progress.publish_failed",
62+
target_type=target_type,
63+
target_id=target_id,
64+
phase=phase,
65+
trace_id=trace_id,
66+
)
67+
68+
def emitter_for(
69+
self,
70+
*,
71+
user_id: int | None,
72+
target_type: str,
73+
target_id: int,
74+
trace_id: str,
75+
) -> ProgressEmitter:
76+
async def _emit(phase: str, message: str) -> None:
77+
await self.emit(
78+
user_id=user_id,
79+
target_type=target_type,
80+
target_id=target_id,
81+
phase=phase,
82+
message=message,
83+
trace_id=trace_id,
84+
)
85+
86+
return _emit

ai/src/ai_server/messaging/runner.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from ai_server.messaging.consumers.resume_consumer import ResumeConsumer
4040
from ai_server.messaging.consumers.web_consumer import WebResumeConsumer
4141
from ai_server.messaging.idempotency import LruIdempotencyStore
42+
from ai_server.messaging.progress import AnalysisProgressNotifier
4243
from ai_server.messaging.publisher import CallbackPublisher
4344
from ai_server.storage.factory import build_storage
4445

@@ -59,6 +60,16 @@ def __init__(self, settings: Settings) -> None:
5960
exchange_name=settings.ai_callback_exchange,
6061
publisher_name=settings.ai_publisher_name,
6162
)
63+
# AI -> RealTime 직접 발행용 (분석 단계 진행 상황). Core 콜백 익스체인지와 별개.
64+
self._realtime_publisher = CallbackPublisher(
65+
connection=self._connection,
66+
exchange_name=settings.ai_realtime_exchange,
67+
publisher_name=settings.ai_publisher_name,
68+
)
69+
self._progress_notifier = AnalysisProgressNotifier(
70+
publisher=self._realtime_publisher,
71+
routing_key=settings.ai_realtime_routing_user,
72+
)
6273
self._idempotency = LruIdempotencyStore(
6374
max_size=settings.ai_idempotency_lru_size,
6475
)
@@ -130,12 +141,14 @@ def __init__(self, settings: Settings) -> None:
130141
publisher=self._publisher,
131142
idempotency=self._idempotency,
132143
callback_routing_key=settings.ai_callback_routing_analysis,
144+
progress_notifier=self._progress_notifier,
133145
)
134146
self._repository_consumer = RepositoryConsumer(
135147
analyzer=repo_analyzer,
136148
publisher=self._publisher,
137149
idempotency=self._idempotency,
138150
callback_routing_key=settings.ai_callback_routing_analysis,
151+
progress_notifier=self._progress_notifier,
139152
)
140153
self._web_consumer = WebResumeConsumer(
141154
analyzer=web_analyzer,
@@ -202,6 +215,7 @@ def __init__(self, settings: Settings) -> None:
202215
async def start(self) -> None:
203216
await self._connection.open()
204217
await self._publisher.open()
218+
await self._realtime_publisher.open()
205219

206220
channel = self._connection.channel
207221

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from pydantic import BaseModel
2+
3+
from ai_server.model._config import camel_config
4+
5+
6+
# AI -> RealTime 직접 발행되는 분석 진행 상황. RealTime 이 user 채널 SSE 로 fan-out 한다.
7+
# 휘발성(진행 표시용)이라 Core/DB 를 거치지 않는다. 종료 상태(ANALYZED/FAILED)는
8+
# 기존대로 Core 콜백 -> REPO_STATE/DOC_STATE 로 전달된다.
9+
class AnalysisProgressData(BaseModel):
10+
model_config = camel_config()
11+
12+
target_type: str # "REPOSITORY" | "RESUME"
13+
target_id: int
14+
phase: str # "EXTRACTING" | "SUMMARIZING" | "EMBEDDING"
15+
message: str
16+
17+
18+
# RealTime Envelope.payload 형태 ({eventType, data}). Core 의 RealtimeNotifyPayload 와 동일.
19+
class RealtimeNotifyPayload(BaseModel):
20+
model_config = camel_config()
21+
22+
event_type: str
23+
data: AnalysisProgressData

0 commit comments

Comments
 (0)