From 0499c89cdf5feeb4ef895c379011b211380bf13e Mon Sep 17 00:00:00 2001 From: bangae1 Date: Wed, 28 Jan 2026 17:58:38 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- org_offline.py | 174 +++++++++++------- util/__init__.py | 0 util/org_filter.py | 41 +++++ .../org_transformer_offline.py | 38 +++- util/summarize_query.py | 124 +++++++++++++ 5 files changed, 305 insertions(+), 72 deletions(-) create mode 100644 util/__init__.py create mode 100644 util/org_filter.py rename org_transformer_offline.py => util/org_transformer_offline.py (50%) create mode 100644 util/summarize_query.py diff --git a/org_offline.py b/org_offline.py index 0e31339..d642c55 100644 --- a/org_offline.py +++ b/org_offline.py @@ -10,7 +10,8 @@ from starlette.responses import StreamingResponse from fastapi import FastAPI from transformers import AutoTokenizer, AutoModelForCausalLM, TextIteratorStreamer -from org_transformer_offline import init +from util.org_transformer_offline import init +from util.org_filter import extract_keywords_simple # === 경로 설정 (모두 로컬) === QWEN_MODEL_PATH = "./models/Qwen3-0.6B" @@ -99,29 +100,30 @@ def get_qwen_model(): return _model, _tokenizer -def query_and_summarize_stream(sessionId: str, query: str, top_k: int = 3, min_similarity: float = 0.2) -> Generator: +def query_select(sessionId: str, query: str) : + keywords = extract_keywords_simple(query) + print(keywords) + filter_list = [{"sessionId": sessionId}] + if keywords['dept'] != '' : + filter_list.append({"deptCd": keywords['dept']}) + if keywords['rank'] != '' : + filter_list.append({"gradeCd": keywords['rank']}) + print(filter_list) + results = collection.query( + query_texts=[keywords['keyword']], + where={"$and": filter_list}, + ) + return results, keywords['keyword'] + + +def query_select_summarize_stream(results, query, ai, min_similarity: float = 0.2) -> Generator: """ 사용자 질문에 대해 벡터 DB를 검색하고, LLM을 통해 답변을 스트리밍으로 생성합니다. - Args: - sessionId (str): 세션 ID (사용자 구분) - query (str): 사용자 질문 - top_k (int): 검색할 문서 개수 - min_similarity (float): 최소 유사도 임계값 Returns: Generator: 스트리밍 응답 제너레이터 """ - from datetime import datetime - - # 관련 문서 검색 (top_k보다 여유 있게 가져옴) - results = collection.query( - query_texts=[query], - n_results=top_k + 2, # 여유분 확보 - where={"sessionId": sessionId} - ) - - print(f"검색 결과: {results}") if not results['documents'] or not results['documents'][0]: def generate_empty(): @@ -135,15 +137,10 @@ def query_and_summarize_stream(sessionId: str, query: str, top_k: int = 3, min_s similarity = 1 - dist if similarity >= min_similarity: filtered_docs.append((doc, similarity)) - if len(filtered_docs) >= top_k: + if ai and len(filtered_docs) >= 5: break print(f"필터링된 문서: {filtered_docs}") - if not filtered_docs: - def generate_low_sim(): - yield json.dumps({"kind": "text", "text": "유사도 기준을 만족하는 문서가 없습니다."}) + "\n" - return generate_low_sim - # 컨텍스트 생성 context_parts = [] for i, (doc, sim) in enumerate(filtered_docs): @@ -152,20 +149,15 @@ def query_and_summarize_stream(sessionId: str, query: str, top_k: int = 3, min_s # 모델 로드 model, tokenizer = get_qwen_model() - - sub_query = '' - if query.find('부') > -1: - sub_query = '***부로 끝나는 단어는 부서 맵핑' - + # 프롬프트 구성 messages = [ { "role": "system", "content": ( ''' - 당신은 인사 담당 어시스던트 입니다. 인사 이동, 승진, 적정 부서 이동 등 전반적으로 모든 인사 정보에 대해 답변해야합니다. - hint) {} - '''.format(sub_query) + 당신은 인사 담당 어시스던트 입니다. 인사 이동, 승진, 적정 부서 이동 등 전반적으로 모든 인사 정보에 대해 답변해야합니다. + ''' ) }, { @@ -174,8 +166,7 @@ def query_and_summarize_stream(sessionId: str, query: str, top_k: int = 3, min_s } ] - print(f'Messages: {messages}') - + # 토큰화 text = tokenizer.apply_chat_template( messages, @@ -207,9 +198,7 @@ def query_and_summarize_stream(sessionId: str, query: str, top_k: int = 3, min_s def generate(): for new_text in streamer: if new_text: - print(f'new Text: {new_text}') yield json.dumps({"kind": "text", "text": new_text}) + "\n" - print(f'End time: {datetime.now()}') return generate @@ -225,6 +214,78 @@ app.add_middleware( allow_headers=["*"], # 모든 헤더 허용 ) + + +def query_summarize_simple(query: str) : + model, tokenizer = get_qwen_model() + # 프롬프트 구성 + messages = [ + { + "role": "system", + "content": ( + ''' + 당신은 데이터 진단 전문가 입니다. 아래 질의 내용을 보고 해당 내용이 단순 질문 인지 아니면 통계, 총개수, 카운트, 직원수 질문 인지 확인해야합니다. + 단순질문 일시 0 통계질문 또는 통계, 총개수, 카운트, 직원수 질문 일시 1 로 대답해주세요. 부가 내용은 필요 없습니다. + ''' + ) + }, + { + "role": "user", + "content": f"질문: {query}" + } + ] + + print(f'Messages: {messages}') + + # 토큰화 + text = tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True, + enable_thinking=False # Qwen 모델 버전에 따라 지원 여부 확인 필요 + ) + model_inputs = tokenizer([text], return_tensors="pt").to(model.device) + + # conduct text completion + generated_ids = model.generate( + **model_inputs, + max_new_tokens=300, + do_sample=True, # ✅ 샘플링 활성화 + temperature=0.3, + top_p=0.9, + pad_token_id=tokenizer.eos_token_id + ) + output_ids = generated_ids[0][len(model_inputs.input_ids[0]):].tolist() + + # parsing thinking content + try: + # rindex finding 151668 () + index = len(output_ids) - output_ids[::-1].index(151668) + except ValueError: + index = 0 + + thinking_content = tokenizer.decode(output_ids[:index], skip_special_tokens=True).strip("\n") + end_think_id = tokenizer.convert_tokens_to_ids("") + if end_think_id in output_ids: + idx = len(output_ids) - output_ids[::-1].index(end_think_id) + else: + idx = 0 + content = tokenizer.decode(output_ids[idx:], skip_special_tokens=True).strip() + + return content + + +app = FastAPI() + +# CORS 설정 추가 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 모든 출처 허용 + allow_credentials=True, + allow_methods=["*"], # 모든 HTTP 메서드 허용 + allow_headers=["*"], # 모든 헤더 허용 +) + # === 1. 기초 데이터 주입 API === class Item(BaseModel): context: list @@ -245,38 +306,9 @@ async def set_data(query: Item): remaining_count = collection.get(where={"sessionId": query.sessionId}) print(f"남은 데이터 수: {len(remaining_count['ids'])}") - doc_list = [] - for q in query.context: - # 각 필드를 안전하게 추출 (None 방어) - name = q.get('name') or "" - dept = q.get('deptNm') or "" - grade = q.get('gradeNm') or "" - position = q.get('ptsnNm') or "" - office_phone = q.get('ofcePhn') or "" - mobile_phone = q.get('mblPhn') or "" - chief_name = q.get('chiefNm') or "" - state_code = q.get('state') or "" - # 상태 코드 한글화 - state_map = {'C': '재직', 'T': '퇴사', 'H': '휴직'} - status = state_map.get(state_code, "정보없음") - # [핵심] 검색 엔진이 좋아할만한 서술형 문장 생성 - # 부서명과 이름을 앞부분에 배치하여 가중치 유도 - if name == '': - doc = ( - f"부서: {dept}. " - f"해당 {dept}의 부서장은 {chief_name}입니다." - ) - else: - doc = ( - f"부서: {dept}. 이름: {name}. {dept} 소속의 {name} {grade}입니다. " - f"직위는 {position}이며 현재 {status} 중입니다. " - f"사내 전화번호(사선)는 {office_phone}입니다." - ) - doc_list.append(doc) - - init(query.sessionId, doc_list) + init(query.sessionId, query.context) return {"status": "success", "message": f"{len(query.context)}건의 데이터가 로드되었습니다."} @@ -286,7 +318,17 @@ def question(sessionId: str, query: str): """ 질의응답 API 엔드포인트 """ - generate = query_and_summarize_stream(sessionId=sessionId, query=query) + type = query_summarize_simple(query=query) + if(type == '0') : + results, keyword = query_select(sessionId, query) + print('단순질문 AI') + generate = query_select_summarize_stream(results, query=keyword, ai=True) + else : + results, keyword = query_select(sessionId, query) + print('단순질문 데이터베이스조회') + generate = query_select_summarize_stream(results, query=keyword, ai=False) + + return StreamingResponse(generate(), media_type="application/x-ndjson") diff --git a/util/__init__.py b/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/util/org_filter.py b/util/org_filter.py new file mode 100644 index 0000000..cc78903 --- /dev/null +++ b/util/org_filter.py @@ -0,0 +1,41 @@ +def extract_keywords_simple(query): + # 1. 사전 정의 + dept_map = { + "남부발전(주)": "0000", "부산빛드림본부": "7620", "남부발전(직할)": "0010", "재무경영처": "0965", "재무예산실": "1070", "사업금융부": "1154", "세무회계부": "1149", "출자관리부": "1658", "안전경영처": "0972", "안전총괄부": "0977", "안전보건부": "0978", "재난관리부": "0975", "산재예방부": "0930", "감사실": "0990", "종합감사부": "0982", "청렴감사부": "0981", "특임감사부": "0983", "미디어홍보실": "1167", "미디어홍보실": "1067", "기획관리본부": "0960", "정보보안실": "0966", "정보보안실": "7655", "ESG기획관리처": "0961", "기획성과실": "1627", "성과관리담당": "1146", "노사협력실": "1636", "인재경영부": "1643", "준법경영부": "1147", "육아휴직자": "9999", "조달협력처": "1160", "상생협력실": "1162", "연료조달부": "1073", "계약자재부": "1633", "해외사업총괄부": "1661", "해외사업개발1부": "1662", "기업성장응답센터": "1153", "기업성장응답센터": "1163", "기술마켓지원센터": "1155", "기술안전본부": "0980", "발전처": "0997", "발전총괄실": "1076", "전력거래부": "1078", "환경품질부": "8881", "전원개발처": "0998", "기계기술부": "1647", "계전기술부": "1648", "토건기술부": "1084", "기후변화대응부": "8882", "수소사업기획부": "1671", "에너지효율혁신부": "1082", "하동빛드림본부": "7550", "보안정보부": "7722", "안전품질실": "6471", "산재예방팀": "6911", "품질관리팀": "6472", "경영지원실": "6730", "총무부": "8095", "노사협력부": "8085", "시설관리부": "8630", "정보통신부": "8629", "제1발전소": "8620", "터빈부": "8097", "보일러부": "8762", "전기부": "8098", "발전1부": "8761", "화학운영부": "8105", "제2발전소": "8720", "발전운영2실": "8931", "터빈부": "8932", "보일러부": "8933", "전기부": "8953", "계측제어부": "8955", "발전2부": "8956", "기술지원실": "6740", "연료설비부": "8503", "연소기술센터": "8502", "환경관리부": "7733", "환경설비부": "8504", "저탄장옥내화부": "8505", "안전센터": "8624", "하동복합건설준비반": "8054", "신인천빛드림본부": "8260", "감사팀": "8283", "보안정보팀": "8275", "기획관리부": "8285", "총무부": "8222", "환경안전부": "8288", "발전소": "8298", "발전운영실": "8279", "기계부": "8296", "전기부": "8293", "화학팀": "8290", "신재생운영팀": "8239", "용인복합준비팀": "8242", "고양창릉준비팀": "8241", "감사팀": "7634", "환경안전부": "7633", "총무기획부": "7629", "발전운영실": "7605", "기계부": "7624", "계측제어부": "7626", "화학팀": "7609", "감사팀": "8700", "보안정보팀": "8724", "안전재난부": "8709", "자재시설부": "8740", "발전소": "8739", "기계부": "8718", "전기부": "8721", "계측제어부": "8725", "한림빛드림발전소": "8690", "안전재난팀": "8694", "영월빛드림본부": "3200", "감사팀": "3202", "보안정보팀": "3204", "안전재난팀": "3201", "총무기획팀": "3205", "발전운영실": "3218", "계전부": "3215", "감사팀": "8202", "보안정보팀": "8209", "총무기획부": "8204", "발전운영실": "8217", "계전부": "8216", "건설소": "8223", "건설안전부": "8224", "공사관리부": "8221", "기전부": "8225", "토건부": "8226", "삼척빛드림본부": "8400", "감사부": "8405", "보안정보팀": "8427", "안전재난부": "8404", "산재예방팀": "6912", "품질관리팀": "8413", "경영지원실": "8402", "경영기획부": "8409", "총무자재부": "8428", "시설관리부": "8439", "정보통신부": "8419", "발전소": "8403", "발전운영실": "8437", "터빈부": "8418", "보일러부": "8445", "전기부": "8433", "계측제어부": "8434", "환경화학부": "8436", "기술지원실": "8411", "연료설비부": "8414", "연소기술부": "8423", "자원사업부": "8438", "신세종빛드림본부": "7000", "감사팀": "7775", "보안정보팀": "7641", "안전재난팀": "7770", "발전운영실": "7780", "기계부": "7778", "건설정리부": "7771", "환경화학팀": "7875", "디지털인프라실": "7653", "디지털운영부": "1148", "AI혁신부": "0959", "통합관리팀": "0958", "비상계획부": "7656", "신재생사업본부": "0963", "제주신재생운영담당": "1698", "풍력사업부": "1660", "태양광사업부": "1655", "기술전문센터": "6670", "기술전문센터": "6669", "내포O&M부": "6611", "발전회사협력본부": "0091", "육아및군휴직자": "0999", "외부업무지원": "9288", "기타부서": "0001", "법무담당": "1164", "청정연료부": "1161", "해외사업처": "1638", "해외사업개발2부": "1669", "기술마켓지원센터": "1156", "발전운영담당": "1077", "전원개발총괄실": "1646", "신성장사업처": "8880", "수소기술진흥부": "1673", "감사부": "7721", "안전재난부": "6374", "경영기획부": "7732", "자재연료부": "8096", "발전운영1실": "8104", "계측제어부": "8763", "계측제어부": "8294", "수도권건설추진반": "8238", "보안정보팀": "7636", "발전소": "7623", "전기부": "7625", "남제주빛드림본부": "8920", "총무기획부": "8730", "발전운영실": "8741", "환경화학부": "8799", "발전운영부": "8727", "풍력운영팀": "3206", "안동빛드림본부": "8200", "안전재난팀": "8201", "기계부": "8215", "인프라건설부": "8412", "총무기획부": "7773", "계전부": "7781", "디지털기획부": "7652", "인프라지원부": "1074", "신재생사업총괄실": "1654", "연료전지사업부": "1672", "발전회사협력본부": "0090" + } + ranks = { + "사장": "11", "감사": "12", "부사장": "13", "1직급": "20", "1직급예정": "20.0", "2직급": "23", "2직급예정": "23.0", "3직급": "24", "3직급예정": "24.0", "4직급(가)": "25", "4직급(나)": "26", "4직급(다)": "27", "수석전문위원": "37", "책임전문위원": "38", "선임전문위원": "39", "비상임이사": "39", "5직급": "48", "6직급(51)": "51", "촉탁": "52", "6직급(53)": "53", "예비군지휘관": "54", "계약직원갑류": "59", "청경반장": "62", "청경조장": "63", "청경조원": "64", "외부지원": "90" + } + + code_dept = '' + found_dept = '' + code_rank = '' + found_rank = '' + + keyword = '' + # 2. 부서 추출 + for k, v in dept_map.items(): + code_dept = k + if k in query: + found_dept = v + keyword = query.replace(delete_paragraphs(query, code_dept), "").strip() + break + + # 3. 직급 추출 + for k, v in ranks.items(): + code_rank = k + if k in keyword: + keyword = keyword.replace(delete_paragraphs(keyword, code_rank), "").strip() + found_rank = v + break + + # 4. 키워드 추출 (부서/직급 제외한 나머지) + + return {"dept": found_dept, "rank": found_rank, "keyword": keyword} +def delete_paragraphs(query, target) : + if query.find(target) != 0 : + query = query[query.find(target):] + + query = query[query.find(target):query.find(' ')] + return query + diff --git a/org_transformer_offline.py b/util/org_transformer_offline.py similarity index 50% rename from org_transformer_offline.py rename to util/org_transformer_offline.py index 74f4255..1f59e69 100644 --- a/org_transformer_offline.py +++ b/util/org_transformer_offline.py @@ -41,17 +41,43 @@ def init(sessionId: str, data: List[Union[str, dict]]): # 데이터 처리: 문자열이면 그대로, 객체면 JSON 문자열로 변환 documents = [] - for item in data: - if isinstance(item, str): - documents.append(item) + + doc_list = [] + for q in data: + # 각 필드를 안전하게 추출 (None 방어) + name = q.get('name') or "" + dept = q.get('deptNm') or "" + grade = q.get('gradeNm') or "" + position = q.get('ptsnNm') or "" + office_phone = q.get('ofcePhn') or "" + mobile_phone = q.get('mblPhn') or "" + chief_name = q.get('chiefNm') or "" + state_code = q.get('state') or "" + + # 상태 코드 한글화 + state_map = {'C': '재직', 'T': '퇴사', 'H': '휴직'} + status = state_map.get(state_code, "정보없음") + + # [핵심] 검색 엔진이 좋아할만한 서술형 문장 생성 + # 부서명과 이름을 앞부분에 배치하여 가중치 유도 + if name == '': + doc = ( + f"부서: {dept}. " + f"해당 {dept}의 부서장은 {chief_name}입니다." + ) else: - documents.append(json.dumps(item, ensure_ascii=False)) + doc = ( + f"부서: {dept}. 이름: {name}. {dept} 소속의 {name} {grade}입니다. " + f"직위는 {position}이며 현재 {status} 중입니다. " + f"사내 전화번호(사선)는 {office_phone}입니다." + ) + doc_list.append(doc) # 벡터 DB에 추가 collection.add( - documents=documents, + documents=doc_list, ids=doc_ids, - metadatas=[{"sessionId": sessionId} for _ in doc_ids] + metadatas=[{"sessionId": sessionId, 'deptCd': d.get('deptCd') or "", 'gradeCd': d.get('gradeCd') or ""} for d in data] ) print(f'{sessionId} init end') diff --git a/util/summarize_query.py b/util/summarize_query.py new file mode 100644 index 0000000..1464b1b --- /dev/null +++ b/util/summarize_query.py @@ -0,0 +1,124 @@ +from typing import Generator + +from transformers import TextIteratorStreamer + +from org_offline import get_qwen_model + + +def query_and_summarize_stream(sessionId: str, query: str, top_k: int = 3, min_similarity: float = 0.2) -> Generator: + """ + 사용자 질문에 대해 벡터 DB를 검색하고, LLM을 통해 답변을 스트리밍으로 생성합니다. + + Args: + sessionId (str): 세션 ID (사용자 구분) + query (str): 사용자 질문 + top_k (int): 검색할 문서 개수 + min_similarity (float): 최소 유사도 임계값 + + Returns: + Generator: 스트리밍 응답 제너레이터 + """ + from datetime import datetime + import json + + + # 관련 문서 검색 (top_k보다 여유 있게 가져옴) + from org_offline import collection + results = collection.query( + query_texts=[query], + n_results=top_k + 2, # 여유분 확보 + where={"sessionId": sessionId} + ) + + print(f"검색 결과: {results}") + + if not results['documents'] or not results['documents'][0]: + def generate_empty(): + yield json.dumps({"kind": "text", "text": "관련 문서를 찾을 수 없습니다."}) + "\n" + return generate_empty + + # 유사도 계산 및 필터링 + filtered_docs = [] + if results['distances'] and results['distances'][0]: + for doc, dist in zip(results['documents'][0], results['distances'][0]): + similarity = 1 - dist + if similarity >= min_similarity: + filtered_docs.append((doc, similarity)) + if len(filtered_docs) >= top_k: + break + print(f"필터링된 문서: {filtered_docs}") + + if not filtered_docs: + def generate_low_sim(): + yield json.dumps({"kind": "text", "text": "유사도 기준을 만족하는 문서가 없습니다."}) + "\n" + return generate_low_sim + + # 컨텍스트 생성 + context_parts = [] + for i, (doc, sim) in enumerate(filtered_docs): + context_parts.append(f"[청크 {i+1} | 유사도: {sim:.3f}]\n{doc}") + context = "\n\n".join(context_parts) + + # 모델 로드 + model, tokenizer = get_qwen_model() + + sub_query = '' + if query.find('부') > -1: + sub_query = '***부로 끝나는 단어는 부서 맵핑' + + # 프롬프트 구성 + messages = [ + { + "role": "system", + "content": ( + ''' + 당신은 인사 담당 어시스던트 입니다. 인사 이동, 승진, 적정 부서 이동 등 전반적으로 모든 인사 정보에 대해 답변해야합니다. + hint) {} + '''.format(sub_query) + ) + }, + { + "role": "user", + "content": f"다음 데이터를 참고하세요:\n\n{context}\n\n질문: {query}" + } + ] + + print(f'Messages: {messages}') + + # 토큰화 + text = tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True, + enable_thinking=False # Qwen 모델 버전에 따라 지원 여부 확인 필요 + ) + model_inputs = tokenizer([text], return_tensors="pt").to(model.device) + + # 스트리머 설정 + streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True) + + # 생성 인자 설정 + generation_kwargs = dict( + **model_inputs, + streamer=streamer, + max_new_tokens=150, + do_sample=True, + temperature=0.3, + top_p=0.9, + pad_token_id=tokenizer.eos_token_id + ) + + # 별도 스레드에서 생성 실행 + from threading import Thread + thread = Thread(target=model.generate, kwargs=generation_kwargs) + thread.start() + + # 제너레이터 함수 정의 + def generate(): + for new_text in streamer: + if new_text: + print(f'new Text: {new_text}') + yield json.dumps({"kind": "text", "text": new_text}) + "\n" + print(f'End time: {datetime.now()}') + + return generate \ No newline at end of file