Skip to content
5 changes: 5 additions & 0 deletions app/core/job_log_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ def init_job_log(job_id: str) -> None:
_logs_var.set([])


def get_current_job_id() -> str | None:
"""현재 job id를 반환한다."""
return _job_id_var.get()


def append_job_log(
stage: str,
message: str,
Expand Down
177 changes: 176 additions & 1 deletion app/core/llm_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
from langchain_openai import ChatOpenAI

from app.core.config import Settings, get_settings
from app.core.job_log_context import append_job_log
from app.core.job_log_context import append_job_log, get_current_job_id
from app.core.logger import get_logger
from app.core.timeout_policy import get_timeout_policy
from app.services.webhook_notification import notify_pipeline_event, schedule_webhook

logger = get_logger(__name__)

Expand Down Expand Up @@ -166,6 +167,64 @@ def _log_failure(
)


def _queue_llm_event(
*,
event_type: str,
severity: str,
stage: Stage,
status: str,
title: str,
message: str,
elapsed_ms: int,
model: str,
fallback_used: bool,
error: str | None = None,
) -> None:
schedule_webhook(
notify_pipeline_event(
event_type=event_type,
severity=severity,
stage=stage.value,
status=status,
title=title,
message=message,
job_id=get_current_job_id(),
elapsed_ms=elapsed_ms,
model=model,
fallback_used=fallback_used,
error=error,
)
)


async def _send_llm_event(
*,
event_type: str,
severity: str,
stage: Stage,
status: str,
title: str,
message: str,
elapsed_ms: int,
model: str,
fallback_used: bool,
error: str | None = None,
) -> None:
await notify_pipeline_event(
event_type=event_type,
severity=severity,
stage=stage.value,
status=status,
title=title,
message=message,
job_id=get_current_job_id(),
elapsed_ms=elapsed_ms,
model=model,
fallback_used=fallback_used,
error=error,
)


def invoke(
stage: Stage,
payload: Any,
Expand Down Expand Up @@ -199,6 +258,17 @@ def invoke(
fallback_used=False,
latency_ms=latency,
)
_queue_llm_event(
event_type="llm_call_success",
severity="success",
stage=stage,
status="SUCCESS",
title="🤖 LLM Call Succeeded",
message="LLM 호출이 정상적으로 완료되었습니다.",
elapsed_ms=latency,
model=selected_model,
fallback_used=False,
)
append_job_log(
"llm",
f"stage={stage.value} model={selected_model} tier={tier.value if tier else 'N/A'} fallback=false",
Expand All @@ -225,6 +295,18 @@ def invoke(
f"fallback=false err={type(exc).__name__}",
{"latency_ms": latency},
)
_queue_llm_event(
event_type="llm_call_failed",
severity="error",
stage=stage,
status="FAILED",
title="🤖 LLM Call Failed",
message="LLM 호출이 실패했습니다.",
elapsed_ms=latency,
model=selected_model,
fallback_used=False,
error=type(exc).__name__,
)
raise

_log_failure(
Expand All @@ -243,6 +325,18 @@ def invoke(
f"fallback=false err={type(exc).__name__} retrying=true",
{"latency_ms": latency},
)
_queue_llm_event(
event_type="llm_call_retry",
severity="warning",
stage=stage,
status="RETRYING",
title="🔁 LLM Call Retrying",
message="1차 LLM 호출이 실패해 fallback 모델로 재시도합니다.",
elapsed_ms=latency,
model=selected_model,
fallback_used=False,
error=type(exc).__name__,
)

fallback_started = perf_counter()
fallback_client = _get_chat_openai_client(
Expand All @@ -262,6 +356,17 @@ def invoke(
fallback_used=True,
latency_ms=fb_latency,
)
_queue_llm_event(
event_type="llm_fallback_success",
severity="warning",
stage=stage,
status="SUCCESS",
title="🔁 LLM Fallback Succeeded",
message="fallback 모델 호출이 성공했습니다.",
elapsed_ms=fb_latency,
model=fallback_model,
fallback_used=True,
)
append_job_log(
"llm",
f"stage={stage.value} model={fallback_model} tier={tier.value if tier else 'N/A'} fallback=true",
Expand All @@ -287,6 +392,18 @@ def invoke(
f"fallback=true err={type(fallback_exc).__name__}",
{"latency_ms": fb_latency},
)
_queue_llm_event(
event_type="llm_fallback_failed",
severity="error",
stage=stage,
status="FAILED",
title="🔁 LLM Fallback Failed",
message="fallback 모델 호출도 실패했습니다.",
elapsed_ms=fb_latency,
model=fallback_model,
fallback_used=True,
error=type(fallback_exc).__name__,
)
raise


Expand Down Expand Up @@ -323,6 +440,17 @@ async def ainvoke(
fallback_used=False,
latency_ms=latency,
)
await _send_llm_event(
event_type="llm_call_success",
severity="success",
stage=stage,
status="SUCCESS",
title="🤖 LLM Call Succeeded",
message="LLM 호출이 정상적으로 완료되었습니다.",
elapsed_ms=latency,
model=selected_model,
fallback_used=False,
)
append_job_log(
"llm",
f"stage={stage.value} model={selected_model} tier={tier.value if tier else 'N/A'} fallback=false",
Expand All @@ -349,6 +477,18 @@ async def ainvoke(
f"fallback=false err={type(exc).__name__}",
{"latency_ms": latency},
)
await _send_llm_event(
event_type="llm_call_failed",
severity="error",
stage=stage,
status="FAILED",
title="🤖 LLM Call Failed",
message="LLM 호출이 실패했습니다.",
elapsed_ms=latency,
model=selected_model,
fallback_used=False,
error=type(exc).__name__,
)
raise

_log_failure(
Expand All @@ -367,6 +507,18 @@ async def ainvoke(
f"fallback=false err={type(exc).__name__} retrying=true",
{"latency_ms": latency},
)
await _send_llm_event(
event_type="llm_call_retry",
severity="warning",
stage=stage,
status="RETRYING",
title="🔁 LLM Call Retrying",
message="1차 LLM 호출이 실패해 fallback 모델로 재시도합니다.",
elapsed_ms=latency,
model=selected_model,
fallback_used=False,
error=type(exc).__name__,
)

fallback_started = perf_counter()
fallback_client = _get_chat_openai_client(
Expand All @@ -386,6 +538,17 @@ async def ainvoke(
fallback_used=True,
latency_ms=fb_latency,
)
await _send_llm_event(
event_type="llm_fallback_success",
severity="warning",
stage=stage,
status="SUCCESS",
title="🔁 LLM Fallback Succeeded",
message="fallback 모델 호출이 성공했습니다.",
elapsed_ms=fb_latency,
model=fallback_model,
fallback_used=True,
)
append_job_log(
"llm",
f"stage={stage.value} model={fallback_model} tier={tier.value if tier else 'N/A'} fallback=true",
Expand All @@ -411,4 +574,16 @@ async def ainvoke(
f"fallback=true err={type(fallback_exc).__name__}",
{"latency_ms": fb_latency},
)
await _send_llm_event(
event_type="llm_fallback_failed",
severity="error",
stage=stage,
status="FAILED",
title="🔁 LLM Fallback Failed",
message="fallback 모델 호출도 실패했습니다.",
elapsed_ms=fb_latency,
model=fallback_model,
fallback_used=True,
error=type(fallback_exc).__name__,
)
raise
71 changes: 71 additions & 0 deletions app/graph/chat/nodes/analyze_intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from app.graph.roadmap.utils import strip_code_fence
from app.schemas.chat import ChatIntent
from app.schemas.enums import ChatOperation, ChatStatus
from app.services.webhook_notification import notify_pipeline_event, schedule_webhook

logger = get_logger(__name__)

Expand Down Expand Up @@ -544,6 +545,20 @@ def _parse_modification_intent(
raise ValueError("수정 의도 응답 파싱에 실패했습니다.") from exc


def _emit_intent_event(event_type: str, severity: str, status: str, message: str, extra_fields: list[dict]) -> None:
schedule_webhook(
notify_pipeline_event(
event_type=event_type,
severity=severity,
stage="chat.intent",
status=status,
title="🧭 Intent Analysis Stage",
message=message,
extra_fields=extra_fields,
)
)


def analyze_intent(state: ChatState) -> ChatState:
"""사용자 요청을 GENERAL_CHAT 또는 MODIFICATION으로 분류합니다."""
current_itinerary = state.get("current_itinerary")
Expand All @@ -566,9 +581,26 @@ def analyze_intent(state: ChatState) -> ChatState:
f"type={route.intent_type} action={route.requested_action} scope={route.target_scope}",
)
if route.intent_type == "GENERAL_CHAT":
_emit_intent_event(
"chat_intent_routed",
"info",
"GENERAL_CHAT",
"일반 대화로 분류되었습니다.",
[
{"name": "Action", "value": route.requested_action or "N/A", "inline": True},
{"name": "Scope", "value": route.target_scope or "N/A", "inline": True},
],
)
return {**state, "intent_type": "GENERAL_CHAT"}

if route.requested_action == "DELETE" and route.target_scope == "DAY_LEVEL":
_emit_intent_event(
"chat_intent_rejected",
"warning",
"REJECTED",
"일차 삭제 요청이 거부되었습니다.",
[{"name": "Reason", "value": "일차 삭제는 지원하지 않습니다.", "inline": False}],
)
return {
**state,
"intent_type": "MODIFICATION",
Expand All @@ -577,6 +609,13 @@ def analyze_intent(state: ChatState) -> ChatState:
}

if route.requested_action == "DELETE" and route.target_scope == "UNKNOWN":
_emit_intent_event(
"chat_intent_clarification",
"warning",
"ASK_CLARIFICATION",
"삭제 대상이 모호해 확인이 필요합니다.",
[{"name": "Reason", "value": "삭제할 일차와 장소 순서가 모두 필요합니다.", "inline": False}],
)
return {
**state,
"intent_type": "MODIFICATION",
Expand All @@ -585,6 +624,13 @@ def analyze_intent(state: ChatState) -> ChatState:
}

if route.target_scope == "DAY_LEVEL" or _is_day_or_date_change_request(user_query):
_emit_intent_event(
"chat_intent_rejected",
"warning",
"REJECTED",
"일차 변경 요청이 거부되었습니다.",
[{"name": "Reason", "value": "일차 자체는 변경할 수 없습니다.", "inline": False}],
)
return {
**state,
"intent_type": "MODIFICATION",
Expand All @@ -608,11 +654,36 @@ def analyze_intent(state: ChatState) -> ChatState:
f" clarify={intent_draft.needs_clarification}",
level="detail",
)
_emit_intent_event(
"chat_intent_parsed",
"info",
"MODIFICATION",
"수정 의도가 파싱되었습니다.",
[
{"name": "Op", "value": str(intent_draft.op), "inline": True},
{"name": "Day", "value": str(intent_draft.target_day), "inline": True},
{"name": "Index", "value": str(intent_draft.target_index), "inline": True},
],
)
except Exception as exc:
logger.error("의도 분석 LLM 호출 실패: %s", exc)
_emit_intent_event(
"chat_intent_failed",
"error",
"FAILED",
"수정 의도 분석에 실패했습니다.",
[{"name": "Error", "value": type(exc).__name__, "inline": False}],
)
return {**state, "error": "수정 의도 분석에 실패했습니다."}

if intent_draft.needs_clarification:
_emit_intent_event(
"chat_intent_clarification",
"warning",
"ASK_CLARIFICATION",
"추가 확인이 필요한 수정 요청입니다.",
[{"name": "Keyword", "value": intent_draft.search_keyword or "N/A", "inline": False}],
)
return {
**state,
"intent_type": "MODIFICATION",
Expand Down
Loading
Loading