diff --git a/app/api/routes.py b/app/api/routes.py index fdd630d..ae57e2a 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -44,6 +44,8 @@ save_policy_bookmarks, ) +from app.services.job_service import recommend_jobs +from app.models.jobSchemas import JobRecommendation router = APIRouter() @@ -53,6 +55,44 @@ async def ping(): return {"message": "pong"} +@router.get( + "/api/job/recommend", + response_model=list[JobRecommendation], + summary="사용자 맞춤 구직 추천", + description="user_id에 기반하여 서울시 구인공고 중 사용자 조건에 맞는 구직 정보를 추천하고, 각 항목에 대해 GPT 기반 설명을 포함하여 반환합니다.", + response_description="추천된 구직 리스트", + responses={ + 200: { + "description": "구직 추천 응답 예시", + "content": { + "application/json": { + "example": [ + { + "jo_reqst_no": "H954202505090864", + "jo_regist_no": "K120122505090018", + "company_name": "늘섬김 재가복지센터", + "job_title": "제기동 90세 4등급 10:30-13:30 주5일 할머니 모실 요양보호사 모십니다.", + "description": "우대사항: 고령자 이동 지원, 목욕 등 신체 활동 지원, 가사 및 일상 생활 지원, 정서 지원에 능한 요양보호사\n\n직무 특징: 90세의 4등급 할머니를 주 5일, 오전 10시 30분부터 오후 1시 30분까지 돌봐야 합니다. 실내에서는 이동식 보행기를 이용하며, 외출 시에는 휠체어를 이용합니다. 인지력이 좋아 본인 의사를 잘 전달하며, 딸이 주말에 필요한 물품을 챙겨줍니다. 시급은 10,030원이며 기초 수당이 별도로 제공됩니다.", + "deadline": "마감일 (2025-07-08)", + "location": "서울 동대문구.", + "pay": "시급 / 10030원 ", + "registration_date": "2025-05-09", + "time": "(근무시간) (오전) 10시 30분 ~ (오후) 1시 30분", + } + ] + } + }, + } + }, +) +def recommend_job_for_user( + user_id: int, + db: Session = Depends(get_db), + token_data=Depends(verify_jwt), +): + return recommend_jobs(user_id, db) + + @router.post( "/api/reemployment/analyze", response_model=ReemploymentResponse, @@ -79,6 +119,7 @@ async def reemployment_analysis_endpoint( request: ReemploymentRequest = Body( example={"question": "50대, 광업, 남성 재취업 가능성이 궁금해"} ), + token_data=Depends(verify_jwt), ): question = request.question result = get_final_reemployment_analysis(question) @@ -131,12 +172,49 @@ def education_search( "category": "디지털기초역량/사무행정실무/전문기술자격증/서비스 직무교육 중 버튼 선택 1" } ), + token_data=Depends(verify_jwt), ): xml_data = fetch_education_data() filtered_results = parse_education_xml(xml_data, request.category) return {"results": filtered_results} +@router.post( + "/api/education/bookmark", + summary="교육 정보 북마크", + description="사용자가 선택한 교육 정보를 데이터베이스에 북마크로 저장합니다.", + response_description="북마크 저장 결과 메시지", + responses={ + 200: { + "description": "북마크 성공 예시", + "content": { + "application/json": {"example": {"message": "교육 정보 북마크 성공."}} + }, + } + }, +) +def bookmark_education( + data: EducationBookmarkRequest = Body( + example={ + "user_id": 1, + "bookmarks": [ + { + "title": "교육1", + "url": "https://example.com/edu1", + }, + { + "title": "교육2", + "url": "https://example.com/edu2", + }, + ], + } + ), + db: Session = Depends(get_db), + token_data=Depends(verify_jwt), +): + return save_bookmarked_education(data, db) + + @router.post( "/api/policy/recommend", response_model=PolicyRecommendResponse, @@ -174,6 +252,7 @@ async def policy_recommend( "category": "디지털기초역량/사무행정실무/전문기술자격증/서비스 직무교육 중 버튼 선택 1" } ), + token_data=Depends(verify_jwt), ): policies = recommend_policy_by_category(req.category) @@ -182,42 +261,6 @@ async def policy_recommend( ) -@router.post( - "/api/education/bookmark", - summary="교육 정보 북마크", - description="사용자가 선택한 교육 정보를 데이터베이스에 북마크로 저장합니다.", - response_description="북마크 저장 결과 메시지", - responses={ - 200: { - "description": "북마크 성공 예시", - "content": { - "application/json": {"example": {"message": "교육 정보 북마크 성공."}} - }, - } - }, -) -def bookmark_education( - data: EducationBookmarkRequest = Body( - example={ - "user_id": 1, - "bookmarks": [ - { - "title": "교육1", - "url": "https://example.com/edu1", - }, - { - "title": "교육2", - "url": "https://example.com/edu2", - }, - ], - } - ), - db: Session = Depends(get_db), - token_data=Depends(verify_jwt), -): - return save_bookmarked_education(data, db) - - @router.post( "/api/policy/bookmark", summary="복지 정보 북마크", @@ -264,7 +307,7 @@ def bookmark_policy( summary="자기소개서 세션 시작", description="입사할 회사명과 직무를 입력받아 자기소개서 작성을 위한 세션을 초기화하고 첫 번째 질문을 반환.", ) -def init(data: ResumeInitRequest): +def init(data: ResumeInitRequest, token_data=Depends(verify_jwt)): """ 입력: - company: 지원 회사명 @@ -285,7 +328,7 @@ def init(data: ResumeInitRequest): summary="사용자 입력에 대한 AI 응답 생성", description="현재 질문에 대한 사용자의 답변을 받아 AI가 해당 항목의 자기소개서 문장을 생성. 이후 다음 질문 항목도 함께 반환.", ) -def answer(data: ResumeAnswerRequest): +def answer(data: ResumeAnswerRequest, token_data=Depends(verify_jwt)): """ 입력: - session_id: 기존 생성된 세션 ID @@ -308,7 +351,7 @@ def answer(data: ResumeAnswerRequest): description="해당 세션 ID에 대해 지금까지 작성된 모든 자기소개서 항목과 내용을 반환.", response_model=ResumeResult, ) -def result(session_id: str): +def result(session_id: str, token_data=Depends(verify_jwt)): """ 입력: - session_id: 자기소개서 작성 세션 ID @@ -406,7 +449,6 @@ def save_resume( }, ) def get_user_resumes( - userId: int, - db: Session = Depends(get_db), - token_data=Depends(verify_jwt)): + userId: int, db: Session = Depends(get_db), token_data=Depends(verify_jwt) +): return get_resumes_by_user_id(db, userId) diff --git a/app/core/config.py b/app/core/config.py index cdf0a99..f9fdb23 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -24,7 +24,7 @@ class Settings(BaseSettings): JWT_SECRET_KEY_BASE64: str JWT_ALGORITHM: str = "HS256" - # ✅ Base64 디코딩된 JWT secret 키 + # Base64 디코딩된 JWT secret 키 @property def jwt_secret_bytes(self) -> bytes: return base64.b64decode(self.JWT_SECRET_KEY_BASE64) diff --git a/app/core/db.py b/app/core/db.py index 78a1c7b..92d4cb9 100644 --- a/app/core/db.py +++ b/app/core/db.py @@ -11,8 +11,8 @@ # BaseEntity에 해당하는 공통 필드 믹스인 class BaseEntity: - createdAt = Column(DateTime, default=func.now(), nullable=False) - updatedAt = Column( + created_at = Column(DateTime, default=func.now(), nullable=False) + updated_at = Column( DateTime, default=func.now(), onupdate=func.now(), nullable=False ) diff --git a/app/db_models/__init__.py b/app/db_models/__init__.py new file mode 100644 index 0000000..75d414e --- /dev/null +++ b/app/db_models/__init__.py @@ -0,0 +1,7 @@ +from .user import User +from .refresh_token import RefreshToken +from .resume import Resume +from .education_info import EducationInfo +from .policy_info import PolicyInfo +from .bookmark import Bookmark +from .authentication_code import AuthenticationCode diff --git a/app/db_models/authentication_code.py b/app/db_models/authentication_code.py index aa43d2a..dd57365 100644 --- a/app/db_models/authentication_code.py +++ b/app/db_models/authentication_code.py @@ -11,10 +11,9 @@ class CodeStatus(str, enum.Enum): # AuthenticationCode 모델 class AuthenticationCode(Base): - __tablename__ = "AuthenticationCode" + __tablename__ = "authentication_code" id = Column(Integer, primary_key=True, index=True) - email = Column(String(50), nullable=False) code = Column(String, unique=True, nullable=False) isVerified = Column(Boolean, nullable=False) diff --git a/app/db_models/base_entity.py b/app/db_models/base_entity.py index af6d03c..86933f8 100644 --- a/app/db_models/base_entity.py +++ b/app/db_models/base_entity.py @@ -4,7 +4,7 @@ class TimestampMixin: - createdAt = Column(DateTime, default=func.now(), nullable=False) - updatedAt = Column( + created_at = Column(DateTime, default=func.now(), nullable=False) + updated_at = Column( DateTime, default=func.now(), onupdate=func.now(), nullable=False ) diff --git a/app/db_models/bookmark.py b/app/db_models/bookmark.py index e419501..33a9fe9 100644 --- a/app/db_models/bookmark.py +++ b/app/db_models/bookmark.py @@ -1,21 +1,22 @@ -from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy import Column, Integer, String, ForeignKey, BigInteger from sqlalchemy.orm import relationship from app.core.db import Base +from app.db_models.base_entity import TimestampMixin -class Bookmark(Base): - __tablename__ = "Bookmark" +class Bookmark(Base, TimestampMixin): + __tablename__ = "bookmark" - id = Column(Integer, primary_key=True, index=True) - - user_id = Column(Integer, ForeignKey("User.id", ondelete="CASCADE"), nullable=False) + id = Column(BigInteger, primary_key=True, index=True, autoincrement=True) + user_id = Column(BigInteger, ForeignKey("user.id"), nullable=False) user = relationship("User", back_populates="bookmarks") - jobId = Column(Integer, nullable=True) - companyName = Column(String, nullable=True) - jobTitle = Column(String, nullable=True) - pay = Column(String, nullable=True) - time = Column(String, nullable=True) - location = Column(String, nullable=True) - deadline = Column(String, nullable=True) - registrationDate = Column(String, nullable=True) + job_id = Column(BigInteger, nullable=True) + company_name = Column(String(255), nullable=True) + job_title = Column(String(255), nullable=True) + pay = Column(String(255), nullable=True) + time = Column(String(255), nullable=True) + location = Column(String(255), nullable=True) + deadline = Column(String(255), nullable=True) + registration_date = Column(String(255), nullable=True) + detail_url = Column(String(255), nullable=True) diff --git a/app/db_models/education_info.py b/app/db_models/education_info.py index 3e55af1..129ffb1 100644 --- a/app/db_models/education_info.py +++ b/app/db_models/education_info.py @@ -1,15 +1,17 @@ -from sqlalchemy import Column, Integer, String, Text, ForeignKey +from sqlalchemy import Column, BigInteger, String, Text, ForeignKey from sqlalchemy.orm import relationship from app.core.db import Base +from app.db_models.base_entity import TimestampMixin -class EducationInfo(Base): - __tablename__ = "EducationInfo" +class EducationInfo(Base, TimestampMixin): + __tablename__ = "education_info" - id = Column(Integer, primary_key=True, index=True) - - user_id = Column(Integer, ForeignKey("User.id", ondelete="CASCADE"), nullable=False) - user = relationship("User", back_populates="educationInfos") + id = Column(BigInteger, primary_key=True, index=True, autoincrement=True) + user_id = Column( + BigInteger, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + user = relationship("User", back_populates="education_infos") title = Column(String(50), nullable=False) url = Column(Text, nullable=True) diff --git a/app/db_models/policy_info.py b/app/db_models/policy_info.py index bfa2520..60c668e 100644 --- a/app/db_models/policy_info.py +++ b/app/db_models/policy_info.py @@ -1,17 +1,19 @@ -from sqlalchemy import Column, Integer, String, Text, ForeignKey +from sqlalchemy import Column, BigInteger, String, Text, ForeignKey from sqlalchemy.orm import relationship from app.core.db import Base +from app.db_models.base_entity import TimestampMixin -class PolicyInfo(Base): - __tablename__ = "PolicyInfo" +class PolicyInfo(Base, TimestampMixin): + __tablename__ = "policy_info" - id = Column(Integer, primary_key=True, index=True) + id = Column(BigInteger, primary_key=True, index=True, autoincrement=True) + user_id = Column( + BigInteger, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + user = relationship("User", back_populates="policy_infos") - user_id = Column(Integer, ForeignKey("User.id", ondelete="CASCADE"), nullable=False) - user = relationship("User", back_populates="policyInfos") - - category = Column(String(50), nullable=False) title = Column(String(50), nullable=False) + category = Column(String(255), nullable=False) description = Column(Text, nullable=True) url = Column(Text, nullable=True) diff --git a/app/db_models/refresh_token.py b/app/db_models/refresh_token.py index 37f9535..2937588 100644 --- a/app/db_models/refresh_token.py +++ b/app/db_models/refresh_token.py @@ -1,16 +1,18 @@ -from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy import Column, BigInteger, String, DateTime from sqlalchemy.orm import relationship from app.core.db import Base -from datetime import datetime +from app.db_models.base_entity import TimestampMixin -class RefreshToken(Base): - __tablename__ = "RefreshToken" +class RefreshToken(Base, TimestampMixin): + __tablename__ = "refresh_token" - id = Column(Integer, primary_key=True, index=True) + id = Column(BigInteger, primary_key=True, index=True, autoincrement=True) + refresh_token = Column(String(512), nullable=False) + expiry_date = Column(DateTime, nullable=False) - refreshToken = Column(String(512), nullable=False) - - expiryDate = Column(DateTime, nullable=False) - - user = relationship("User", back_populates="refreshToken", uselist=False) + user = relationship( + "User", + back_populates="refresh_token", + uselist=False, + ) diff --git a/app/db_models/resume.py b/app/db_models/resume.py index 1f9fd47..858653b 100644 --- a/app/db_models/resume.py +++ b/app/db_models/resume.py @@ -1,12 +1,10 @@ -from sqlalchemy import Column, Integer, String, Text, Enum as SqlEnum, ForeignKey +from sqlalchemy import Column, BigInteger, String, Text, Enum as SqlEnum, ForeignKey from sqlalchemy.orm import relationship from app.core.db import Base +from app.db_models.base_entity import TimestampMixin import enum -# ------------------ ResumeCategory Enum ------------------ - - class ResumeCategory(str, enum.Enum): GROWTH = "GROWTH" MOTIVATION = "MOTIVATION" @@ -25,18 +23,15 @@ def display_name(self): }[self.value] -# ------------------ Resume 모델 ------------------ - +class Resume(Base, TimestampMixin): + __tablename__ = "resume" -class Resume(Base): - __tablename__ = "Resume" - - id = Column(Integer, primary_key=True, index=True) - - user_id = Column(Integer, ForeignKey("User.id", ondelete="CASCADE"), nullable=False) + id = Column(BigInteger, primary_key=True, index=True, autoincrement=True) + user_id = Column( + BigInteger, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) user = relationship("User", back_populates="resumes") title = Column(String(50), nullable=False) content = Column(Text, nullable=False) - - resumeCategory = Column(SqlEnum(ResumeCategory), nullable=False) + resume_category = Column(SqlEnum(ResumeCategory), nullable=False) diff --git a/app/db_models/user.py b/app/db_models/user.py index cc136d6..0aa4b5a 100644 --- a/app/db_models/user.py +++ b/app/db_models/user.py @@ -1,12 +1,12 @@ from sqlalchemy import Column, Integer, String, Enum as SqlEnum, ForeignKey from sqlalchemy.orm import relationship from app.core.db import Base +from app.db_models.refresh_token import RefreshToken +from app.db_models.base_entity import TimestampMixin import enum -from app.core.db import Base - -# ------------------ ENUM 정의 ------------------ +# ENUM 정의 class Gender(str, enum.Enum): MALE = "MALE" FEMALE = "FEMALE" @@ -40,7 +40,7 @@ def description(self): }[self.value] -class UserStatus(str, enum.Enum): +class Status(str, enum.Enum): ACTIVE = "ACTIVE" INACTIVE = "INACTIVE" @@ -50,34 +50,35 @@ class Role(str, enum.Enum): ADMIN = "ADMIN" -# ------------------ User 모델 정의 ------------------ - - -class User(Base): - __tablename__ = "User" +# User 모델 +class User(Base, TimestampMixin): + __tablename__ = "user" id = Column(Integer, primary_key=True, index=True) name = Column(String(20), nullable=False) - primaryEmail = Column(String(50), unique=True) + primary_email = Column(String(50), unique=True) password = Column(String(100), nullable=False) age = Column(Integer, nullable=False) gender = Column(SqlEnum(Gender), nullable=False) region = Column(SqlEnum(Region), nullable=False) job = Column(String(50), nullable=False) career = Column(Integer, nullable=False) - finalEdu = Column(SqlEnum(FinalEdu), nullable=False) - status = Column(SqlEnum(UserStatus), nullable=False) + final_edu = Column(SqlEnum(FinalEdu), nullable=False) + status = Column(SqlEnum(Status), nullable=False) role = Column(SqlEnum(Role), nullable=True) - refresh_token_id = Column(Integer, ForeignKey("RefreshToken.id"), nullable=True) - refreshToken = relationship("RefreshToken", back_populates="user", uselist=False) + + refresh_token_id = Column(Integer, ForeignKey("refresh_token.id"), nullable=True) + + refresh_token = relationship("RefreshToken", back_populates="user", uselist=False) bookmarks = relationship( "Bookmark", back_populates="user", cascade="all, delete-orphan" ) - policyInfos = relationship( + policy_infos = relationship( "PolicyInfo", back_populates="user", cascade="all, delete-orphan" ) - educationInfos = relationship( + + education_infos = relationship( "EducationInfo", back_populates="user", cascade="all, delete-orphan" ) resumes = relationship( diff --git a/app/main.py b/app/main.py index cab1fdc..835dcf7 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,11 @@ app.add_middleware( CORSMiddleware, - allow_origins=["https://parentsgowork.vercel.app", "http://localhost:5173"], + allow_origins=[ + "https://parentsgowork.vercel.app", + "http://localhost:5173", + "http://127.0.0.1:8000", + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/app/models/common.py b/app/models/common.py index 07e1ee8..2df27c4 100644 --- a/app/models/common.py +++ b/app/models/common.py @@ -3,8 +3,8 @@ class TimestampMixin(BaseModel): - createdAt: datetime - updatedAt: datetime + created_at: datetime + updated_at: datetime class Config: - orm_mode = True + from_attributes = True diff --git a/app/models/jobSchemas.py b/app/models/jobSchemas.py index a78c8cb..9d9969c 100644 --- a/app/models/jobSchemas.py +++ b/app/models/jobSchemas.py @@ -1,13 +1,14 @@ from pydantic import BaseModel -from typing import List -from app.models.common import TimestampMixin -class JobSummary(BaseModel): - title: str +class JobRecommendation(BaseModel): + jo_reqst_no: str + jo_regist_no: str + company_name: str + job_title: str description: str - - -class JobSummaryResponse(BaseModel): - count: int - results: List[JobSummary] + deadline: str + location: str + pay: str + registration_date: str + time: str diff --git a/app/services/job_service.py b/app/services/job_service.py index 1ef9abe..039bf56 100644 --- a/app/services/job_service.py +++ b/app/services/job_service.py @@ -1,126 +1,139 @@ -from app.core.config import settings import requests import xml.etree.ElementTree as ET -from app.models.jobSchemas import JobSummary -import openai - -# 카테고리명 직종 코드 매핑 -CATEGORY_CODE_MAP = { - "사무직": "024", - "서비스직": "050", - "기술직": "121", - "판매직": "062", +from sqlalchemy.orm import Session +from app.db_models.user import User +from app.core.config import settings +from app.models.jobSchemas import JobRecommendation +from openai import OpenAI + +REGION_KR_MAP = { + "SEOUL": "서울", + "GYEONGGI": "경기", + "INCHEON": "인천", + "GANGWON": "강원", + "DAEJEON": "대전", + "SEJONG": "세종", + "CHUNGBUK": "충북", } -API_KEY = settings.JOB_INFO_KEY -API_URL = settings.JOB_INFO_URL -OPENAI_API_KEY = settings.OPENAI_API_KEY - -openai.api_key = OPENAI_API_KEY - - -def fetch_job_data(category: str, region=None, career=None, education=None, empTp=None): - code = CATEGORY_CODE_MAP.get(category) - if not code: - return {"error": f"[{category}]는 지원되지 않는 직종입니다."} - - params = { - "authKey": API_KEY, - "callTp": "L", - "returnType": "XML", - "startPage": 1, - "display": 5, # 처리 속도 고려해 5건 제한 - "occupation": code, - "pfPreferential": "B", - } - - if region: - params["region"] = region - if career: - params["career"] = career - params["minCareerM"] = 0 - params["maxCareerM"] = 999 - if education: - params["education"] = education - if empTp: - params["empTp"] = empTp - - response = requests.get(API_URL, params=params) - print("호출된 URL:", response.url) - print("응답 내용 일부:", response.text[:500]) - if response.status_code != 200: - return {"error": "공공 API 호출 실패"} - - jobs = parse_job_xml(response.content) - - if not jobs: - return {"message": "조회된 구직 정보가 없습니다."} - - # 각 항목마다 GPT 설명 추가 - summaries = [summarize_job(job) for job in jobs] - - return {"count": len(summaries), "results": summaries} - - -def parse_job_xml(xml_data): - root = ET.fromstring(xml_data) - rows = root.findall(".//wanted") - - results = [] - for row in rows: - job = { - "title": row.findtext("title", "제목 없음"), - "company": row.findtext("company", ""), - "sal_type": row.findtext("salTpNm", ""), - "salary": row.findtext("sal", ""), - "region": row.findtext("region", ""), - "work_type": row.findtext("holidayTpNm", ""), - "education": row.findtext("minEdubg", ""), - "career": row.findtext("career", ""), - "reg_date": row.findtext("regDt", ""), - "close_date": row.findtext("closeDt", ""), - } - results.append(job) - - return results - - -def summarize_job(job: dict) -> JobSummary: - info = ( - f"채용 제목: {job['title']}\n" - f"회사명: {job['company']}\n" - f"임금형태: {job['sal_type']}\n" - f"급여: {job['salary']}\n" - f"근무지역: {job['region']}\n" - f"근무형태: {job['work_type']}\n" - f"최소학력: {job['education']}\n" - f"경력 요건: {job['career']}\n" - f"등록일: {job['reg_date']}\n" - f"마감일: {job['close_date']}\n" - ) +EDU_CODE_MAP = { + "HIGH_SCHOOL": "J00106", + "ASSOCIATE": "J00108", + "BACHELOR": "J00110", + "MASTER": "J00114", + "DOCTOR": "J00114", +} + +def get_career_code(career: int): + if career == 0: + return "J01301" + elif career > 0: + return "J01302" + return "J01300" + + +def select_top_jobs_by_gpt(user, jobs: list[dict]) -> list[dict]: + """GPT를 통해 사용자에게 가장 적합한 상위 3개 공고를 선택""" prompt = ( - "다음은 채용 공고의 상세 정보입니다.\n" - "내용을 기반으로, 구직자가 이해할 수 있도록 객관적으로 설명해 주세요.\n" - "추측, 조언, 의견 없이 사실만 정리해주세요.\n\n" - f"{info}" + f"사용자 정보:\n" + f"- 직무: {user.job}\n" + f"- 지역: {REGION_KR_MAP.get(user.region.name, '')}\n" + f"- 학력: {user.final_edu.name}\n" + f"- 경력: {user.career}년\n\n" + f"아래는 채용 공고 목록입니다. 사용자에게 가장 적합한 3개 공고를 골라 JSON 배열 형태로 반환해주세요. " + f"각 항목은 'JO_REGIST_NO'만 포함하고, 주관적인 판단 없이 정보 기반으로 판단해주세요.\n\n" ) - # 디버깅용 - print(info) + for job in jobs: + prompt += f"- 공고번호: {job['JO_REGIST_NO']}, 직무: {job['JO_SJ']}, 지역: {job['WORK_PARAR_BASS_ADRES_CN']}, 경력조건: {job['CAREER_CND_NM']}, 학력조건: {job['ACDMCR_NM']}\n" - response = openai.ChatCompletion.create( - model="gpt-3.5-turbo", + prompt += '\n결과 예시:\n["K12345", "K23456", "K34567"]' + + client = OpenAI(api_key=settings.OPENAI_API_KEY) + response = client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": prompt}], temperature=0, - max_tokens=300, - messages=[ - { - "role": "system", - "content": "당신은 채용 공고 정보를 설명해주는 AI입니다.", - }, - {"role": "user", "content": prompt}, - ], ) - summary = response.choices[0].message["content"] - return JobSummary(title=job["title"], description=summary) + try: + selected_ids = eval(response.choices[0].message.content.strip()) + return [job for job in jobs if job["JO_REGIST_NO"] in selected_ids] + except Exception: + return jobs[:3] + + +def generate_description_gpt(job_title, company_name, job_content, user_job): + prompt = ( + f"사용자의 관심 직무는 '{user_job}'입니다. " + f"다음은 {company_name}의 '{job_title}' 직무에 대한 상세 설명입니다:\n" + f"{job_content}\n\n" + f"이 정보를 바탕으로 우대사항과 직무의 특징을 요약해 주세요. 감정이나 판단 없이 설명만 해주세요." + ) + + client = OpenAI(api_key=settings.OPENAI_API_KEY) + response = client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": prompt}], + temperature=0.7, + ) + return response.choices[0].message.content.strip() + + +def recommend_jobs(user_id: int, db: Session) -> list[JobRecommendation]: + user = db.query(User).filter(User.id == user_id).first() + if not user: + return [] + + user_region_kr = REGION_KR_MAP.get(user.region.name, "") + job_keyword = user.job.strip() + + url = f"http://openapi.seoul.go.kr:8088/{settings.seoul_openapi_key}/xml/GetJobInfo/1/200/" + response = requests.get(url) + if response.status_code != 200: + return [] + + root = ET.fromstring(response.content) + filtered_jobs = [] + + for row in root.findall(".//row"): + title = row.findtext("JO_SJ", "").strip() + if job_keyword not in title: + continue + + region_text = row.findtext("WORK_PARAR_BASS_ADRES_CN", "") + if user_region_kr not in region_text: + continue + + job_dict = {child.tag: child.text for child in row} + filtered_jobs.append(job_dict) + + # GPT가 최적의 공고 3개 선택 + top_jobs = select_top_jobs_by_gpt(user, filtered_jobs) + + recommendations = [] + for job in top_jobs: + description = generate_description_gpt( + job_title=job.get("JO_SJ", ""), + company_name=job.get("CMPNY_NM", ""), + job_content=job.get("DTY_CN", ""), + user_job=user.job.strip(), + ) + + recommendations.append( + JobRecommendation( + jo_reqst_no=job.get("JO_REQST_NO", ""), + jo_regist_no=job.get("JO_REGIST_NO", ""), + company_name=job.get("CMPNY_NM", ""), + job_title=job.get("JO_SJ", ""), + description=description, + deadline=job.get("RCEPT_CLOS_NM", ""), + location=job.get("WORK_PARAR_BASS_ADRES_CN", ""), + pay=job.get("HOPE_WAGE", ""), + registration_date=job.get("JO_REG_DT", ""), + time=job.get("WORK_TIME_NM", ""), + ) + ) + + return recommendations diff --git a/requirements.txt b/requirements.txt index bcd41b9..5122181 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,55 +1,120 @@ +aiofiles==24.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.10.11 +aiosignal==1.3.2 +alembic==1.15.2 annotated-types==0.7.0 anyio==4.9.0 +asyncio==3.4.3 +attrs==25.3.0 +backoff==2.2.1 +beautifulsoup4==4.13.3 certifi==2025.1.31 cffi==1.17.1 +chardet==5.2.0 charset-normalizer==3.4.1 +click==8.1.8 colorama==0.4.6 -cryptography==44.0.3 +cryptography==44.0.2 +dataclasses-json==0.6.7 distro==1.9.0 ecdsa==0.19.1 -et_xmlfile==2.0.0 +emoji==2.14.1 +eval_type_backport==0.2.2 faiss-cpu==1.10.0 -git-filter-repo==2.45.0 +fastapi==0.115.12 +filetype==1.2.0 +frozenlist==1.5.0 greenlet==3.1.1 h11==0.14.0 +html5lib==1.1 httpcore==1.0.7 httpx==0.28.1 +httpx-sse==0.4.0 idna==3.10 +iniconfig==2.1.0 jiter==0.9.0 +joblib==1.4.2 jsonpatch==1.33 jsonpointer==3.0.0 langchain==0.3.23 -langchain-core==0.3.51 +langchain-community==0.3.21 +langchain-core==0.3.56 +langchain-openai==0.3.14 +langchain-pinecone==0.2.5 +langchain-tests==0.3.19 langchain-text-splitters==0.3.8 +langdetect==1.0.9 langsmith==0.3.28 +lxml==5.3.2 +Mako==1.3.10 +MarkupSafe==3.0.2 +marshmallow==3.26.1 +multidict==6.4.2 +mypy-extensions==1.0.0 +nest-asyncio==1.6.0 +nltk==3.9.1 numpy==2.2.4 +olefile==0.47 openai==1.72.0 -openpyxl==3.1.5 orjson==3.10.16 +outcome==1.3.0.post0 packaging==24.2 pandas==2.2.3 pinecone==6.0.2 pinecone-plugin-interface==0.0.7 +pluggy==1.5.0 +propcache==0.3.1 +psutil==7.0.0 pyasn1==0.4.8 pycparser==2.22 pydantic==2.11.3 -pydantic-settings==2.9.1 +pydantic-settings==2.8.1 pydantic_core==2.33.1 +PyMySQL==1.1.1 +pypdf==5.4.0 +PySocks==1.7.1 +pytest==8.3.5 +pytest-asyncio==0.26.0 +pytest-socket==0.7.0 python-dateutil==2.9.0.post0 python-dotenv==1.1.0 +python-iso639==2025.2.18 python-jose==3.4.0 +python-magic==0.4.27 +python-oxmsg==0.0.2 pytz==2025.2 PyYAML==6.0.2 +RapidFuzz==3.13.0 +regex==2024.11.6 requests==2.32.3 requests-toolbelt==1.0.0 rsa==4.9.1 +selenium==4.32.0 six==1.17.0 sniffio==1.3.1 +sortedcontainers==2.4.0 +soupsieve==2.6 SQLAlchemy==2.0.40 +starlette==0.46.1 +syrupy==4.9.1 tenacity==9.1.2 +tiktoken==0.9.0 tqdm==4.67.1 +trio==0.30.0 +trio-websocket==0.12.2 +typing-inspect==0.9.0 typing-inspection==0.4.0 typing_extensions==4.13.1 tzdata==2025.2 +unstructured==0.17.2 +unstructured-client==0.32.3 urllib3==2.3.0 +uvicorn==0.34.0 +webdriver-manager==4.0.2 +webencodings==0.5.1 +websocket-client==1.8.0 +wrapt==1.17.2 +wsproto==1.2.0 +yarl==1.19.0 zstandard==0.23.0