This commit is contained in:
2026-01-28 15:06:02 +09:00
commit 7b3b299cbb
12 changed files with 642 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.venv
.idea
chroma_db
models
*.iml

31
README.md Normal file
View File

@@ -0,0 +1,31 @@
# 인사정보 AI
# pip offline download
pip download -r requirements.txt --no-binary :all: -d /path/to/download/dir
# Qwen/Qwen3-0.6B model download
HF CLI 설치 (처음 한 번만)
pip install huggingface_hub
로그인 (오프라인 사용 전 한 번만 필요)
huggingface-cli login # 토큰 입력 (https://huggingface.co/settings/tokens)
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
모델 로컬 저장
huggingface-cli download Qwen/Qwen3-0.6B --local-dir ./models/Qwen3-0.6B --local-dir-use-symlinks False
# sentence-transformers model download (chroma vector db)
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('all-MiniLM-L6-v2')
model.save('./models/all-MiniLM-L6-v2')
# 실행방법
uvicorn manual:app --host 0.0.0.0 --port 8040 --reload

Binary file not shown.

Binary file not shown.

6
download_embed.py Normal file
View File

@@ -0,0 +1,6 @@
from sentence_transformers import SentenceTransformer
# 모델 다운로드 및 로컬 저장
# 이 스크립트는 인터넷 연결이 필요하며, 한 번 실행 후에는 오프라인에서 모델을 사용할 수 있게 해줍니다.
model = SentenceTransformer('jhgan/ko-sroberta-multitask')
model.save('./models/ko-sroberta-multitask')

127
org.html Normal file
View File

@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.container {
width:100%;
height:100vh;
}
.chat-wrap {
padding: 0 20%;
}
.chat {
height: 95vh;
border: 1px solid #ccc;
overflow-y: auto;
padding: 10px;
white-space: pre-wrap; /* 줄바꿈 유지 */
}
.prompt {
width:100%;
height:3vh;
border: 1px solid #ccc;
}
.user-msg {
color: blue;
margin-top: 10px;
font-weight: bold;
}
.bot-msg {
color: black;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="chat-wrap">
<div class="chat"></div>
<input type="text" class="prompt" value="재난관리부 부서장은 누구야?" placeholder="질문을 입력하고 Enter를 누르세요">
</div>
</div>
<script>
const promptInput = document.querySelector('.prompt');
const chatDiv = document.querySelector('.chat');
promptInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
const query = promptInput.value;
if (!query.trim()) return;
promptInput.value = '';
// 사용자 질문 표시
const userDiv = document.createElement('div');
userDiv.className = 'user-msg';
userDiv.textContent = `Q: ${query}`;
chatDiv.appendChild(userDiv);
// 봇 답변 영역 생성
const botDiv = document.createElement('div');
botDiv.className = 'bot-msg';
botDiv.textContent = 'A: ';
chatDiv.appendChild(botDiv);
// 스크롤 하단으로 이동
chatDiv.scrollTop = chatDiv.scrollHeight;
const xhr = new XMLHttpRequest();
xhr.open('GET', `http://127.0.0.1:8000/?sessionId=20033011&query=${encodeURIComponent(query)}`);
let seenBytes = 0;
let buffer = '';
xhr.onprogress = function () {
const newData = xhr.responseText.substring(seenBytes);
console.log(newData)
seenBytes = xhr.responseText.length;
buffer += newData;
// 줄바꿈 기준으로 분리
const lines = buffer.split('\n');
// 마지막 부분은 불완전할 수 있으므로 버퍼에 남김
buffer = lines.pop();
lines.forEach(line => {
if (!line.trim()) return;
try {
const json = JSON.parse(line);
if (json.kind === 'text') {
botDiv.textContent += json.text;
chatDiv.scrollTop = chatDiv.scrollHeight;
}
} catch (e) {
console.error("JSON parsing error:", e);
}
});
};
xhr.onloadend = function() {
// 남은 버퍼 처리 (혹시 마지막에 줄바꿈이 없었을 경우)
if (buffer.trim()) {
try {
const json = JSON.parse(buffer);
if (json.kind === 'text') {
botDiv.textContent += json.text;
}
} catch (e) {
console.error("Final JSON parsing error:", e);
}
}
};
xhr.send();
}
});
</script>
</body>
</html>

297
org_offline.py Normal file
View File

@@ -0,0 +1,297 @@
from threading import Thread
import json
from typing import List, Generator
import torch
import chromadb
from pydantic import BaseModel
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import StreamingResponse
from fastapi import FastAPI
from transformers import AutoTokenizer, AutoModelForCausalLM, TextIteratorStreamer
from org_transformer_offline import init
# === 경로 설정 (모두 로컬) ===
QWEN_MODEL_PATH = "./models/Qwen3-0.6B"
# 전역 변수 설정
_model = None
_tokenizer = None
# 2. 벡터 DB 설정
persist_directory = "./chroma_db"
chroma_client = chromadb.PersistentClient(path=persist_directory)
collection = chroma_client.get_or_create_collection(
name="orgchart",
)
def search_employees(data: List[dict], query: str) -> List[dict]:
"""
직원 데이터에서 검색어가 포함된 항목을 필터링합니다.
(현재 API에서 직접 사용되지 않으나 유틸리티 목적으로 유지)
Args:
data (List[dict]): 직원 데이터 리스트
query (str): 검색어
Returns:
List[dict]: 필터링된 직원 리스트
"""
if not query:
return data
query = query.lower().strip()
# 모든 필드값 중 검색어가 포함된 항목 필터링
filtered = [
emp for emp in data
if any(query in str(value).lower() for value in emp.values() if value)
]
return filtered
def get_qwen_model():
"""
Qwen 모델과 토크나이저를 로드하거나 캐시된 인스턴스를 반환합니다.
torch.compile을 사용하여 추론 속도를 최적화합니다.
Returns:
tuple: (model, tokenizer)
"""
global _model, _tokenizer
if _model is not None:
return _model, _tokenizer
# 토크나이저 로드
_tokenizer = AutoTokenizer.from_pretrained(
QWEN_MODEL_PATH,
trust_remote_code=True,
local_files_only=True
)
# 모델 로드
_model = AutoModelForCausalLM.from_pretrained(
QWEN_MODEL_PATH,
dtype=torch.bfloat16, # CPU: bfloat16, GPU: float16 권장
device_map="auto",
trust_remote_code=True,
local_files_only=True
)
# ✅ torch.compile() 적용 (PyTorch 2.0+)
if hasattr(torch, 'compile'):
try:
print("🚀 torch.compile() 적용 중...")
# mode="reduce-overhead": 추론 시 추천
# dynamic=True: 입력 길이가 유동적인 RAG 환경에 적합
_model = torch.compile(
_model,
mode="reduce-overhead",
dynamic=True
)
print("✅ torch.compile() 성공!")
except Exception as e:
print(f"⚠️ torch.compile() 실패, 원본 모델 사용: {e}")
pass # 실패하면 원본 사용
return _model, _tokenizer
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
# 관련 문서 검색 (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():
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
)
# 별도 스레드에서 생성 실행
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
app = FastAPI()
# CORS 설정 추가
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 모든 출처 허용
allow_credentials=True,
allow_methods=["*"], # 모든 HTTP 메서드 허용
allow_headers=["*"], # 모든 헤더 허용
)
# === 1. 기초 데이터 주입 API ===
class Item(BaseModel):
context: list
sessionId: str
@app.post("/set-data")
async def set_data(query: Item):
"""
클라이언트로부터 받은 인사 데이터를 자연어 문장으로 변환하여 벡터 DB에 저장합니다.
기존 세션 데이터는 삭제 후 재생성됩니다.
"""
# 기존 데이터 삭제
collection.delete(
where={"sessionId": query.sessionId}
)
# 삭제 확인 (디버깅용)
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)
return {"status": "success", "message": f"{len(query.context)}건의 데이터가 로드되었습니다."}
@app.get("/")
def question(sessionId: str, query: str):
"""
질의응답 API 엔드포인트
"""
generate = query_and_summarize_stream(sessionId=sessionId, query=query)
return StreamingResponse(generate(), media_type="application/x-ndjson")
# 개발용 실행 (직접 실행 시)
if __name__ == "__main__":
import uvicorn
print("서버 시작: uvicorn manual:app --reload")
# uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,57 @@
import json
from typing import List, Union
import chromadb
from chromadb.utils import embedding_functions
# === 경로 설정 (모두 로컬) ===
EMBEDDING_MODEL_PATH = "./models/ko-sroberta-multitask"
# 2. 벡터 DB 설정
persist_directory = "./chroma_db"
chroma_client = chromadb.PersistentClient(path=persist_directory)
# 임베딩 함수 설정
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name=EMBEDDING_MODEL_PATH, # 로컬 폴더 경로 가능
device="cpu",
normalize_embeddings=True
)
# 컬렉션 생성 또는 가져오기
collection = chroma_client.get_or_create_collection(
name="orgchart",
embedding_function=embedding_fn,
metadata={"hnsw:space": "cosine"}
)
def init(sessionId: str, data: List[Union[str, dict]]):
"""
데이터를 벡터 DB에 초기화(저장)합니다.
Args:
sessionId (str): 세션 ID
data (List[Union[str, dict]]): 저장할 데이터 리스트 (문자열 또는 딕셔너리)
"""
print(f'{sessionId} init start')
# 문서 ID 생성
doc_ids = [f"{sessionId}_{i}" for i in range(len(data))]
# 데이터 처리: 문자열이면 그대로, 객체면 JSON 문자열로 변환
documents = []
for item in data:
if isinstance(item, str):
documents.append(item)
else:
documents.append(json.dumps(item, ensure_ascii=False))
# 벡터 DB에 추가
collection.add(
documents=documents,
ids=doc_ids,
metadatas=[{"sessionId": sessionId} for _ in doc_ids]
)
print(f'{sessionId} init end')

BIN
requirements.txt Normal file

Binary file not shown.

5
test.py Normal file
View File

@@ -0,0 +1,5 @@
# 테스트용 스크립트
text = '산재예방부 부서장은 누구야?'
# '부'라는 글자가 포함되어 있는지 확인
print(text.find(''))

55
vector_transformer.py Normal file
View File

@@ -0,0 +1,55 @@
import chromadb
from chromadb.utils import embedding_functions
# 2. 벡터 DB 설정
persist_directory = "./chroma_db"
chroma_client = chromadb.PersistentClient(path=persist_directory)
# ✅ Chroma 전용 임베딩 함수 사용 (오류 방지)
sentence_transformer_ef = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="all-MiniLM-L6-v2"
)
# 컬렉션 생성
collection = chroma_client.get_or_create_collection(
name="manuals",
embedding_function=sentence_transformer_ef # ← 여기가 핵심!
)
def init(job: str):
"""
직무별 매뉴얼 데이터를 벡터 DB에 초기화합니다.
Args:
job (str): 직무 코드 (예: 'FI', 'HR')
"""
print(f'{job} init start')
# 1. 문서 준비 (실제로는 PDF/Word 등에서 추출)
manuals = [
"지출 결의서는 사용 목적, 금액, 일자, 증빙 서류를 반드시 첨부하여 전자 결재 시스템에 등록해야 합니다.",
"월말 마감은 매월 25일부터 시작되며, 모든 부서는 28일까지 비용 집행 내역을 최종 확정해야 합니다.",
"외화 송금은 반드시 외환관리부의 사전 승인을 받은 후 금융팀을 통해 진행되어야 하며, 계약서 사본을 첨부해야 합니다.",
"세금계산서는 발행일로부터 10일 이내에 ERP에 등록되지 않으면 비용 처리가 불가합니다.",
"장기자산(차량, 사무기기 등)은 매년 1월에 정기 감가상각 점검을 받아야 하며, 자산관리부서가 이를 주관합니다.",
"현금 보관은 원칙적으로 금지되며, 불가피한 경우는 금고 보관 후 당일 중 재무팀에 입금 처리해야 합니다.",
"연말 정산 대상 직원은 매년 12월 10일까지 개인 소득공제 자료를 인사 시스템에 제출해야 합니다.",
"예산 초과 지출은 사전에 재무부와 협의 후 예산 조정 승인을 받아야 하며, 미승인 시 결재가 거부됩니다.",
"재무 제표 초안은 분기 마감 후 5영업일 이내에 감사법인에 제출되어야 하며, 최종 승인은 CFO가 담당합니다.",
]
# 문서 ID 생성 및 추가
doc_ids = [f"DOC_{job}_{i}" for i in range(len(manuals))]
collection.add(
documents=manuals,
ids=doc_ids,
metadatas=[{"source": "fi_manual_v1.pdf", "version": 1.0, "dept": job} for _ in doc_ids]
)
print(f'{job} init end')
if __name__ == "__main__":
# FI : 재무 HR : 인사
init(job="FI")

View File

@@ -0,0 +1,59 @@
import chromadb
from chromadb.utils import embedding_functions
# === 경로 설정 (모두 로컬) ===
EMBEDDING_MODEL_PATH = "./models/all-MiniLM-L6-v2"
# 2. 벡터 DB 설정
persist_directory = "./chroma_db"
chroma_client = chromadb.PersistentClient(path=persist_directory)
# 임베딩 함수 설정
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name=EMBEDDING_MODEL_PATH, # 로컬 폴더 경로 가능
device="cpu"
)
# 컬렉션 생성 또는 가져오기
collection = chroma_client.get_or_create_collection(
name="manuals",
embedding_function=embedding_fn
)
def init(job: str):
"""
오프라인 환경에서 직무별 매뉴얼 데이터를 벡터 DB에 초기화합니다.
Args:
job (str): 직무 코드 (예: 'FI', 'HR')
"""
print(f'{job} init start')
# 1. 문서 준비 (실제로는 PDF/Word 등에서 추출)
manuals = [
"지출 결의서는 사용 목적, 금액, 일자, 증빙 서류를 반드시 첨부하여 전자 결재 시스템에 등록해야 합니다.",
"월말 마감은 매월 25일부터 시작되며, 모든 부서는 28일까지 비용 집행 내역을 최종 확정해야 합니다.",
"외화 송금은 반드시 외환관리부의 사전 승인을 받은 후 금융팀을 통해 진행되어야 하며, 계약서 사본을 첨부해야 합니다.",
"세금계산서는 발행일로부터 10일 이내에 ERP에 등록되지 않으면 비용 처리가 불가합니다.",
"장기자산(차량, 사무기기 등)은 매년 1월에 정기 감가상각 점검을 받아야 하며, 자산관리부서가 이를 주관합니다.",
"현금 보관은 원칙적으로 금지되며, 불가피한 경우는 금고 보관 후 당일 중 재무팀에 입금 처리해야 합니다.",
"연말 정산 대상 직원은 매년 12월 10일까지 개인 소득공제 자료를 인사 시스템에 제출해야 합니다.",
"예산 초과 지출은 사전에 재무부와 협의 후 예산 조정 승인을 받아야 하며, 미승인 시 결재가 거부됩니다.",
"재무 제표 초안은 분기 마감 후 5영업일 이내에 감사법인에 제출되어야 하며, 최종 승인은 CFO가 담당합니다.",
]
# 문서 ID 생성 및 추가
doc_ids = [f"DOC_{job}_{i}" for i in range(len(manuals))]
collection.add(
documents=manuals,
ids=doc_ids,
metadatas=[{"source": "fi_manual_v1.pdf", "version": 1.0, "dept": job} for _ in doc_ids]
)
print(f'{job} init end')
if __name__ == "__main__":
# FI : 재무 HR : 인사
init(job="FI")