From 321a40e617268ce1827381c75fa29c6a6827868b Mon Sep 17 00:00:00 2001 From: guineaaaa <165776804+guineaaaa@users.noreply.github.com> Date: Thu, 15 May 2025 22:58:59 +0900 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20GPT=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=ED=9A=9F=EC=88=98=20=EA=B0=90=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/job_service.py | 156 +++++++++++++++----------- app/services/policy_service.py | 26 ++--- app/utils/field_ailias_map.py | 199 +++++++++++++++++++++++++++++++++ 3 files changed, 301 insertions(+), 80 deletions(-) create mode 100644 app/utils/field_ailias_map.py diff --git a/app/services/job_service.py b/app/services/job_service.py index 039bf56..93c6933 100644 --- a/app/services/job_service.py +++ b/app/services/job_service.py @@ -1,3 +1,4 @@ +import json import requests import xml.etree.ElementTree as ET from sqlalchemy.orm import Session @@ -5,6 +6,9 @@ from app.core.config import settings from app.models.jobSchemas import JobRecommendation from openai import OpenAI +from difflib import SequenceMatcher +from app.utils.field_ailias_map import SIMILAR_JOB_MAP + REGION_KR_MAP = { "SEOUL": "서울", @@ -16,13 +20,9 @@ "CHUNGBUK": "충북", } -EDU_CODE_MAP = { - "HIGH_SCHOOL": "J00106", - "ASSOCIATE": "J00108", - "BACHELOR": "J00110", - "MASTER": "J00114", - "DOCTOR": "J00114", -} + +def is_similar(a: str, b: str) -> bool: + return SequenceMatcher(None, a, b).ratio() > 0.3 def get_career_code(career: int): @@ -33,52 +33,84 @@ def get_career_code(career: int): return "J01300" -def select_top_jobs_by_gpt(user, jobs: list[dict]) -> list[dict]: - """GPT를 통해 사용자에게 가장 적합한 상위 3개 공고를 선택""" +def check_similarity_and_filter(root, job_keyword, user_region_kr, job_aliases): + filtered_jobs = [] + + print("===== 포함 기반 매칭 필터링 시작 =====") + for row in root.findall(".//row"): + title = row.findtext("JO_SJ", "").strip() + jobcode = row.findtext("JOBCODE_NM", "").strip() + region = row.findtext("WORK_PARAR_BASS_ADRES_CN", "").strip() + + # alias가 title 또는 jobcode 중 하나에 포함되면 True + keyword_included = any( + alias in title or alias in jobcode for alias in job_aliases + ) + + region_match = user_region_kr in region or is_similar(user_region_kr, region) + + if keyword_included and region_match: + job_dict = {child.tag: child.text for child in row} + filtered_jobs.append(job_dict) + print(f"매칭된 공고: {title} | {jobcode} | {region}") + # else: + # print(f"제외된 공고: {title} | {jobcode} | {region}") + + print(f"최종 필터링 결과: {len(filtered_jobs)}건") + return filtered_jobs + + +def select_top_jobs_by_gpt_and_descriptions(user, jobs: list[dict]) -> list[dict]: prompt = ( - f"사용자 정보:\n" + f"당신은 취업 도우미입니다. 아래는 사용자의 기본 정보와 공고 리스트입니다.\n\n" + f"[사용자 정보]\n" f"- 직무: {user.job}\n" - f"- 지역: {REGION_KR_MAP.get(user.region.name, '')}\n" - f"- 학력: {user.final_edu.name}\n" + f"- 지역: {REGION_KR_MAP.get(user.region, '')}\n" + f"- 학력: {user.final_edu.value}\n" f"- 경력: {user.career}년\n\n" - f"아래는 채용 공고 목록입니다. 사용자에게 가장 적합한 3개 공고를 골라 JSON 배열 형태로 반환해주세요. " - f"각 항목은 'JO_REGIST_NO'만 포함하고, 주관적인 판단 없이 정보 기반으로 판단해주세요.\n\n" + f"[지침]\n" + f"1. 사용자에게 가장 적합한 공고 3개만 선택하세요.\n" + f"2. 각 공고마다 직무 특징을 1~2문장으로 요약한 'description'을 작성해주세요.\n" + f"3. 아래 형식처럼 JSON 배열로 반환하세요:\n" + f'[{{"jo_regist_no": "공고번호", "description": "요약 설명"}}]\n\n' + f"[공고 목록]\n" ) 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" - - 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, - ) + prompt += ( + f"- 공고번호: {job['JO_REGIST_NO']}, 회사: {job['CMPNY_NM']}, 직무명: {job['JOBCODE_NM']}, 제목: {job['JO_SJ']}\n" + f" 내용: {job.get('DTY_CN', '').strip()[:300]}...\n" + ) 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] - + client = OpenAI(api_key=settings.OPENAI_API_KEY) + response = client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": prompt}], + temperature=0.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"이 정보를 바탕으로 우대사항과 직무의 특징을 요약해 주세요. 감정이나 판단 없이 설명만 해주세요." - ) + raw = response.choices[0].message.content.strip() + parsed = json.loads(raw) - 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() + selected_jobs = [] + for item in parsed: + job = next( + (j for j in jobs if j["JO_REGIST_NO"] == item["jo_regist_no"]), None + ) + if job: + job["description"] = ( + item.get("description") or job.get("DTY_CN", "")[:200] + ) + selected_jobs.append(job) + + return selected_jobs + + except Exception as e: + print(f"GPT 오류 발생 (description 포함): {e}") + for j in jobs[:3]: + j["description"] = j.get("DTY_CN", "")[:200] + return jobs[:3] def recommend_jobs(user_id: int, db: Session) -> list[JobRecommendation]: @@ -86,48 +118,38 @@ def recommend_jobs(user_id: int, db: Session) -> list[JobRecommendation]: if not user: return [] - user_region_kr = REGION_KR_MAP.get(user.region.name, "") + user_region_kr = REGION_KR_MAP.get(user.region, "") job_keyword = user.job.strip() + job_aliases = SIMILAR_JOB_MAP.get(job_keyword, [job_keyword]) + + print(f"User job: {user.job}, region: {user.region}") + print(f"Region mapped: {user_region_kr}") - url = f"http://openapi.seoul.go.kr:8088/{settings.seoul_openapi_key}/xml/GetJobInfo/1/200/" + url = f"http://openapi.seoul.go.kr:8088/{settings.seoul_openapi_key}/xml/GetJobInfo/1/1000/" 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 + filtered_jobs = check_similarity_and_filter( + root, job_keyword, user_region_kr, job_aliases + ) - job_dict = {child.tag: child.text for child in row} - filtered_jobs.append(job_dict) + if not filtered_jobs: + return [] - # GPT가 최적의 공고 3개 선택 - top_jobs = select_top_jobs_by_gpt(user, filtered_jobs) + filtered_jobs = filtered_jobs[:5] # GPT에 넘길 후보 5개 제한 + top_jobs = select_top_jobs_by_gpt_and_descriptions(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, + description=job.get("description", "설명을 불러올 수 없습니다."), deadline=job.get("RCEPT_CLOS_NM", ""), location=job.get("WORK_PARAR_BASS_ADRES_CN", ""), pay=job.get("HOPE_WAGE", ""), diff --git a/app/services/policy_service.py b/app/services/policy_service.py index a79343f..796554f 100644 --- a/app/services/policy_service.py +++ b/app/services/policy_service.py @@ -40,36 +40,36 @@ # 상세 설명 추출 def get_policy_description(title: str) -> str: fallback_hardcoded = { - "중장년 경력지원제": """📌 대상: + "중장년 경력지원제": """1. 대상: - (참여자) 중장년내일센터 및 훈련기관을 통해 자격취득 또는 훈련 이수, 국민취업지원제도에 참여하여 IAP 수립 후 경력전환을 희망하는 50세 이상 중장년 - (참여기업) 고용보험 피보험자수 10인 이상 기업 (기술·경영 혁신형 중소기업은 5인 이상도 가능) -📌 지원 요건: +2. 지원 요건: - 위 대상에 해당하며 경력전환을 위한 훈련 이수 또는 자격취득 -📌 지원 내용: +3. 지원 내용: - 주된 업무에서 퇴직한 사무직 등 중장년에게 경력전환형 일경험을 쌓을 수 있도록 지원하여 취업가능성 제고 - 참여자: 참여수당 월 150만원 - 참여기업: 참여자 1인당 월 40만원 운영지원금 - 1~3개월 유망 자격/훈련 분야 실무 수행, 직무교육, 멘토링 제공 """, - "재취업지원서비스 시행지원": """📌 대상: + "재취업지원서비스 시행지원": """1. 대상: - 재취업지원서비스 지원 사업주 (300인 이상 기업) -📌 지원 요건: +2. 지원 요건: - 재취업지원서비스 제도 설계 또는 운영을 준비 중인 기업 -📌 지원 내용: +3. 지원 내용: - 제도 설계 컨설팅, 인사담당자 교육, 업종별 표준 프로그램 개발·보급 """, - "중장년 내일센터": """📌 대상: + "중장년 내일센터": """1. 대상: - 40세 이상 중장년층에게 생애설계, 재취업 및 창업 지원, 특화서비스등의 종합 고용지원서비스를 제공하여 고용안정 및 재취업 촉진 도모 -📌 지원 요건: +2. 지원 요건: - 중장년층 생애설계, 재취업·창업·전직 필요자 -📌 지원 내용: +3. 지원 내용: - 생애경력설계, 전직스쿨, 재도약 프로그램 - 산업별 특화서비스, 중장년 청춘문화공간 운영 - 사업주 대상 고용확대 컨설팅, 직무교육, 채용지원 전담반 운영 @@ -87,9 +87,9 @@ def get_policy_description(title: str) -> str: 대상, 지원 요건, 지원 내용을 항목별로 나누되, **최대한 GPT의 해석이나 요약 없이** 문서 원문 표현을 사용해줘. 항목 예시는 다음과 같아: - 📌 대상: (원문 문장 그대로) - 📌 지원 요건: (원문 문장 그대로) - 📌 지원 내용: (원문 문장 그대로) + 1. 대상: (원문 문장 그대로) + 2. 지원 요건: (원문 문장 그대로) + 3. 지원 내용: (원문 문장 그대로) 정보를 직접 문서에서 발췌해서, 최대한 사실 그대로 출력해줘. 너의 생각이나 요약 없이, 원문 중심으로 정리해줘. @@ -169,7 +169,7 @@ def recommend_policy_by_category(category: str) -> list[dict]: return results -def save_policy_bookmarks(user_id: int,data: PolicySaveRequest, db: Session): +def save_policy_bookmarks(user_id: int, data: PolicySaveRequest, db: Session): for policy in data.policies: info = PolicyInfo( user_id=user_id, diff --git a/app/utils/field_ailias_map.py b/app/utils/field_ailias_map.py new file mode 100644 index 0000000..621cf4f --- /dev/null +++ b/app/utils/field_ailias_map.py @@ -0,0 +1,199 @@ +SIMILAR_JOB_MAP = { + "유치원": [ + "유치원 교사", + "유치원", + "보육", + "보육교사", + "유아교사", + "유아교육", + "부담임", + "보조교사", + "어린이집", + "영유아", + "유아반", + ], + "간호": [ + "간호사", + "간호조무사", + "조무사", + "RN", + "병동", + "병원", + "의료", + "의무", + ], + "개발자": [ + "개발", + "개발자", + "프로그래머", + "소프트웨어", + "웹개발", + "앱개발", + "모바일개발", + "프론트엔드", + "백엔드", + "풀스택", + "코딩", + "IT", + "IT개발", + ], + "사무직": [ + "사무", + "행정", + "총무", + "경리", + "인사", + "문서", + "비서", + "사무보조", + "지원팀", + "데이터입력", + "오피스", + "관리직", + ], + "디자이너": [ + "디자이너", + "그래픽디자인", + "시각디자인", + "패션디자인", + "편집디자인", + "UI", + "UX", + "제품디자인", + "산업디자인", + "웹디자인", + ], + "회계": [ + "회계", + "경리", + "재무", + "세무", + "자금", + "결산", + "회계보조", + "전표", + "회계담당", + ], + "마케팅": [ + "마케팅", + "홍보", + "광고", + "콘텐츠", + "브랜드", + "SNS", + "인스타그램", + "바이럴", + "디지털마케팅", + "퍼포먼스마케팅", + "PR", + "프로모션", + ], + "물류": [ + "물류", + "배송", + "운송", + "운전", + "지게차", + "택배", + "배차", + "포장배송", + "창고", + "로지스틱스", + ], + "생산직": [ + "생산", + "제조", + "공장", + "조립", + "포장", + "라인", + "기계조작", + "설비", + "오퍼레이터", + "검사원", + "가공", + "현장직", + ], + "요리사": [ + "요리", + "조리", + "주방", + "찬모", + "조리보조", + "주방보조", + "급식", + "단체급식", + "식당", + "셰프", + "조리사", + "한식", + "중식", + "양식", + ], + "미화원": [ + "미화", + "청소", + "환경미화", + "청소원", + "시설미화", + "크리닝", + "빌딩청소", + "청소부", + "청소직", + ], + "보안직": [ + "경비", + "보안", + "보안요원", + "순찰", + "안전요원", + "건물보안", + "관리실", + "CCTV", + ], + "교육강사": [ + "강사", + "강의", + "강의자", + "교육", + "강사모집", + "강의직", + "전임강사", + "시간강사", + "방문강사", + "학원강사", + ], + "상담사": [ + "상담", + "상담원", + "고객상담", + "콜센터", + "CS", + "VOC", + "문의응대", + "전화응대", + ], + "건설": [ + "건설", + "시공", + "건축", + "토목", + "현장", + "건설현장", + "안전관리", + "건설기술자", + "현장소장", + "건축기사", + ], + "운전직": [ + "운전", + "운전기사", + "배송기사", + "지게차기사", + "화물운송", + "택시기사", + "버스기사", + "트럭기사", + "차량운행", + ], +}