Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ services:
# Qdrant
- QDRANT_HOST=qdrant
- QDRANT_PORT=6333
# MCP
- MCP_JWT_SECRET=${MCP_JWT_SECRET}
# Environment (local로 설정)
- ENVIRONMENT=local
depends_on:
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies = [
"langchain>=0.3.13",
"langchain-community>=0.3.13",
"langchain-openai>=0.2.14",
"langchain-anthropic>=0.3.0",
"langchain-qdrant>=0.2.0",

# Security (JWT for OAuth authentication)
Expand All @@ -43,6 +44,7 @@ dependencies = [
"httpx>=0.28.0",
"tenacity>=8.2.0",
"jinja2>=3.1.4",
"coolname>=2.0.0",
]

[dependency-groups]
Expand Down
22 changes: 8 additions & 14 deletions src/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from src.auth import constants
from src.auth.utils import get_oauth_redirect_uri
from src.auth.utils import generate_random_nickname, get_oauth_redirect_uri
from src.auth.exceptions import (
InvalidTokenError,
OAuthError,
Expand Down Expand Up @@ -123,23 +123,17 @@ async def _find_or_create_user(
Returns (user, is_new_user)
"""
# 1) 동일 provider + provider_id로 기존 사용자 조회
existing_user = await user_service.get_user_by_oauth(
session=session, provider=provider, provider_id=provider_id
)

if existing_user:
if not existing_user.is_active:
raise OAuthError("탈퇴한 계정입니다. 고객센터에 문의해주세요.")
return existing_user, False

# 2) 동일 email로 이미 가입된 사용자가 있으면 기존 계정으로 로그인
# 동일 email로 이미 가입된 사용자가 있으면 기존 계정으로 로그인
email_user = await user_service.get_user_by_email(
session=session, email=email
)

if email_user:
if not email_user.is_active:
raise OAuthError("탈퇴한 계정입니다. 고객센터에 문의해주세요.")
email_user.is_active = True
session.add(email_user)
await session.commit()
await session.refresh(email_user)
return email_user, False
Comment on lines 131 to 137
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify account deactivation/deletion semantics and whether is_active implies soft-deleted.
set -euo pipefail

echo "== User model fields related to active/deleted state =="
rg -n -C3 '\bis_active\b|\bdeleted_at\b' src/users src/auth

echo
echo "== User/account deletion or deactivation service paths =="
rg -n -C3 'delete|deactivate|withdraw|soft.?delete|is_active\s*=\s*False|deleted_at\s*=' src/users src/auth

echo
echo "== OAuth login paths that reactivate users =="
rg -n -C3 '_find_or_create_user|is_active\s*=\s*True' src/auth

Repository: Nexters/Logit-BE

Length of output: 10406


삭제된 계정을 OAuth 로그인 중에 자동으로 복구하면 사용자 탈퇴 정책을 위반합니다.

Lines 131-137에서 이메일로 매칭된 비활성(탈퇴) 계정을 사용자 동의 없이 자동으로 복구합니다. delete_user()is_active=False로 설정하여 soft-delete를 구현하지만, OAuth 로그인 시 _find_or_create_user()가 이를 자동으로 is_active=True로 되돌립니다. 이는 탈퇴한 계정의 모든 데이터에 대한 접근을 사용자 재인증 없이 복구하므로 삭제 정책을 무효화합니다. oauth_provider_id 보존은 무료 체험 재발급 방지(사기 차단)이지, 자동 계정 복구 권한이 아닙니다. 명시적인 사용자 재가입 또는 재활성화 흐름을 거치도록 수정하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/auth/service.py` around lines 131 - 137, The code in
_find_or_create_user() currently reactivates soft-deleted accounts by setting
email_user.is_active = True during OAuth login; change this so that if an
email-matched user exists but is_active is False you do not flip is_active or
return the account: instead treat it as a blocked/deleted account and return an
explicit error or signal requiring an explicit reactivation/re-signup flow;
preserve the oauth_provider_id semantics but do not auto-reactivate, and update
callers of _find_or_create_user() to handle the new “deleted account” response
(e.g., raise a specific exception or return (None, 'deleted_account')) so
reactivation must go through delete_user()/reactivation endpoint or explicit
user consent flow.


try:
Expand Down Expand Up @@ -422,7 +416,7 @@ async def apple_oauth_flow(
provider=OAuthProvider.apple,
provider_id=apple_sub,
email=email,
name=full_name or None,
name=full_name or generate_random_nickname(),
picture=None,
)

Expand All @@ -445,7 +439,7 @@ async def apple_mobile_auth_flow(
provider=OAuthProvider.apple,
provider_id=apple_sub,
email=email,
name=full_name,
name=full_name or generate_random_nickname(),
picture=None,
)

Expand Down
8 changes: 8 additions & 0 deletions src/auth/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
"""Authentication utility functions."""

from coolname import generate as coolname_generate

from src.config import settings


def generate_random_nickname() -> str:
"""형용사 + 동물 조합의 랜덤 닉네임 생성 (예: Lucky Fox, Brave Eagle)."""
words = coolname_generate(2) # ['lucky', 'fox']
return " ".join(word.capitalize() for word in words)


def get_oauth_redirect_uri(provider: str) -> str:
"""BACKEND_HOST 기반 OAuth provider 콜백 URL 생성."""
return f"{settings.BACKEND_HOST.rstrip('/')}{settings.API_V1_STR}/auth/{provider}/callback"
Expand Down
15 changes: 15 additions & 0 deletions src/chats/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from fastapi import Depends

if TYPE_CHECKING:
from langchain_anthropic import ChatAnthropic
from langchain_openai import ChatOpenAI
from .rate_limit import ChatRateLimiter

Expand All @@ -20,6 +21,7 @@ class LLMProvider:
def __init__(self):
self._streaming_llm: ChatOpenAI | None = None
self._classification_llm: ChatOpenAI | None = None
self._writing_llm: ChatAnthropic | None = None

@property
def streaming_llm(self) -> ChatOpenAI:
Expand Down Expand Up @@ -49,6 +51,19 @@ def classification_llm(self) -> ChatOpenAI:
)
return self._classification_llm

@property
def writing_llm(self) -> ChatAnthropic:
"""글쓰기용 LLM - Claude (초안 생성, 수정, 길이 보정)"""
from langchain_anthropic import ChatAnthropic

if self._writing_llm is None:
self._writing_llm = ChatAnthropic(
model=settings.ANTHROPIC_MODEL,
api_key=settings.ANTHROPIC_API_KEY,
temperature=settings.OPENAI_TEMPERATURE,
)
return self._writing_llm


@lru_cache
def get_llm_provider() -> LLMProvider:
Expand Down
180 changes: 7 additions & 173 deletions src/chats/llm_service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import asyncio
import json
import logging
import re
from enum import Enum
from typing import AsyncGenerator
from uuid import UUID
Expand All @@ -16,11 +15,7 @@
from src.experience.service import get_experience
from .prompts import (
CLASSIFICATION_PROMPT,
STORYLINE_PROMPT,
GENERATION_PROMPT,
HALLUCINATION_CHECK_PROMPT,
REVISION_PROMPT,
LENGTH_ADJUSTMENT_PROMPT,
CONVERSATION_SYSTEM_PROMPT,
)
from .chat_history import PostgresChatMessageHistory
Expand Down Expand Up @@ -54,16 +49,6 @@ class RequestClassification(BaseModel):
)


class StorylineExperience(BaseModel):
title: str = Field(description="경험 제목")
role: str = Field(description="주 경험 / 보조 경험")
key_facts: list[str] = Field(description="이 경험에서 사용할 핵심 사실 (1~3개)")


class Storyline(BaseModel):
core_message: str = Field(description="자기소개서의 핵심 메시지 (한 문장)")
experiences: list[StorylineExperience] = Field(description="경험별 역할 및 사용 계획")


# ============================================================
# Experience Formatting Helpers
Expand Down Expand Up @@ -169,41 +154,6 @@ def get_experiences_by_ids(
return experiences


def _check_listing_pattern(draft: str) -> bool:
"""나열식 패턴 감지 (LLM 없이 정규식으로)"""
patterns = [
r'첫째[,\s]',
r'둘째[,\s]',
r'셋째[,\s]',
r'첫 번째',
r'두 번째',
r'세 번째',
r'\n[①②③④⑤]',
r'\n\s*\d+\.',
]
return any(re.search(p, draft) for p in patterns)


async def _check_hallucination(
draft: str,
experiences: list[Experience],
provider: LLMProvider,
) -> bool:
"""할루시네이션 감지 (yes/no LLM, structured output 없이 ~2-3s)"""
if not experiences:
return False
try:
experiences_text = _format_experiences_plain(experiences)
response = await provider.classification_llm.ainvoke(
HALLUCINATION_CHECK_PROMPT.format(
experiences=experiences_text,
draft=draft,
)
)
return response.content.strip().lower().startswith("yes")
except Exception:
logger.warning("Hallucination check failed, skipping", exc_info=True)
return False


# ============================================================
Expand All @@ -225,106 +175,31 @@ async def classify_request(
)


async def design_storyline(
question: str,
max_length: int | None,
company: str,
experiences: list[Experience],
provider: LLMProvider,
) -> Storyline:
"""2단계: 적합성 점수 기반 역할 분배 + 스토리라인 설계"""
experiences_text = _format_experiences_with_roles(experiences)
structured_llm = provider.classification_llm.with_structured_output(Storyline)
return await structured_llm.ainvoke(
STORYLINE_PROMPT.format(
question=question,
max_length=max_length or "제한 없음",
company=company,
experiences=experiences_text,
)
)


async def generate_draft_text(
storyline: Storyline,
experiences: list[Experience],
question: str,
max_length: int | None,
company: str,
recruit_notice: str | None,
provider: LLMProvider,
) -> str:
"""3단계: 스토리라인 기반 초안 생성 (비스트리밍)"""
experience_plan = "\n".join(
f"- [{exp.role}] {exp.title}: {', '.join(exp.key_facts)}"
for exp in storyline.experiences
)
"""2단계: 경험 기반 초안 생성 (인라인 스토리라인 설계 포함)"""
experiences_text = _format_experiences_with_roles(experiences)

prompt = GENERATION_PROMPT.format(
company=company,
recruit_notice=recruit_notice or "채용공고 정보 없음",
question=question,
max_length=max_length or "제한 없음",
min_length=int(max_length * 0.85) if max_length else 0,
core_message=storyline.core_message,
experience_plan=experience_plan,
experiences=experiences_text,
)

response = await provider.streaming_llm.ainvoke(prompt)
response = await provider.writing_llm.ainvoke(prompt)
return response.content



async def revise_draft_text(
draft: str,
feedback: str,
experiences: list[Experience],
max_length: int | None,
provider: LLMProvider,
) -> str:
"""4-1단계: 검수 피드백 기반 수정 (조건부)"""
experiences_text = _format_experiences_plain(experiences)
response = await provider.streaming_llm.ainvoke(
REVISION_PROMPT.format(
draft=draft,
feedback=feedback,
experiences=experiences_text,
min_length=int(max_length * 0.9) if max_length else 0,
max_length=max_length or "제한 없음",
)
)
return response.content


async def adjust_length_if_needed(
draft: str,
max_length: int,
provider: LLMProvider,
) -> str:
"""4-2단계: 글자수 보정 (조건부, 최대 1회 / 허용 범위 ±15%)"""
min_length = int(max_length * 0.85)

for _ in range(1):
current = len(draft)
if min_length <= current <= max_length:
break

if current > max_length:
direction = f"{current - max_length}자 초과"
else:
direction = f"{min_length - current}자 부족"

response = await provider.streaming_llm.ainvoke(
LENGTH_ADJUSTMENT_PROMPT.format(
current_length=current,
draft=draft,
min_length=min_length,
max_length=max_length,
direction=direction,
)
)
draft = response.content

return draft


# ============================================================
Expand Down Expand Up @@ -432,57 +307,16 @@ async def _load_experiences() -> list[Experience]:
else:
# === 자기소개서 초안 생성: 멀티 스텝 파이프라인 ===

# 2단계: 스토리라인 설계
storyline = await design_storyline(
question=question_content,
max_length=max_length,
company=company,
experiences=experiences,
provider=provider,
)

# 3단계: 초안 생성
# 2단계: 초안 생성 (인라인 스토리라인 설계 포함)
draft = await generate_draft_text(
storyline=storyline,
experiences=experiences,
question=question_content,
max_length=max_length,
company=company,
recruit_notice=recruit_notice,
provider=provider,
)

# 4단계: 검수 (정규식 + yes/no LLM 병렬)
async def _listing_check() -> bool:
return _check_listing_pattern(draft)

has_listing, has_hallucination = await asyncio.gather(
_listing_check(),
_check_hallucination(draft, experiences, provider),
)

feedbacks: list[str] = []
if has_listing:
feedbacks.append(
"경험들이 나열식으로 서술되어 있습니다. '첫째~둘째~' 또는 단락별 나열 구조 대신 하나의 자연스러운 이야기 흐름으로 재작성하세요."
)
if has_hallucination:
feedbacks.append(
"경험 정보에 없는 수치, 성과, 역할, 활동이 포함되어 있습니다. 원본 경험 정보에 있는 사실만 사용하세요."
)

if feedbacks:
draft = await revise_draft_text(
draft=draft,
feedback="\n".join(feedbacks),
experiences=experiences,
max_length=max_length,
provider=provider,
)

# 4-2단계: 글자수 보정
if max_length:
draft = await adjust_length_if_needed(draft, max_length, provider)

# 청크 단위 스트리밍 (5자씩, 100ms 간격)
chunk_size = 5
for i in range(0, len(draft), chunk_size):
Expand Down
Loading