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
6 changes: 3 additions & 3 deletions LLM/OSS/modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
)

UNIT_SUFFIX_RE = re.compile(r"(학부|학과|과|전공|대학|대학원|본부|센터|팀|처|단|부|원)$")
CEREMONY_RE = re.compile(r"(졸업식|학위수여식)")
CEREMONY_RE = re.compile(r"(졸업식|종업식|학위수여식)")
GRAD_POLICY_RE = re.compile(r"(졸업학점|이수학점|졸업요건|전공최저|최저이수|학위수여(?!식)|졸업(?!식))")
CONTACT_INTENT_RE = re.compile(r"(연락처|전화|전화번호|문의|상담|담당자)")
GOVERNANCE_REMOVE_RE = re.compile(r"(없애|폐지|해체)\s*(시키|하는\s*법)?")
Expand All @@ -34,7 +34,7 @@
"학사일정", "학사 일정", "중간", "중간고사", "기말", "기말고사",
"수강", "정정", "성적", "등록", "보강", "개강", "종강",
"휴일", "공휴", "시험", "고사", "이번주", "다음주", "이번달", "다음달",
"졸업식", "학위수여식",
"졸업식", "종업식", "학위수여식",
)
RULE_BOOK_KWS = ("규정", "규정집", "학칙", "준칙", "회칙", "규약", "세칙", "강령", "운영규칙", "선발 규칙", "선발규칙")

Expand Down Expand Up @@ -73,7 +73,7 @@ def looks_like_schedule(text: str) -> bool:
return True
return "언제" in source and any(
keyword in source
for keyword in ("중간", "기말", "시험", "고사", "수강", "등록", "성적", "개강", "종강", "졸업식", "학위수여식")
for keyword in ("중간", "기말", "시험", "고사", "수강", "등록", "성적", "개강", "종강", "졸업식", "종업식", "학위수여식")
)


Expand Down
75 changes: 71 additions & 4 deletions LLM/OSS/service.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import datetime as dt
import asyncio
import hashlib
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from pathlib import Path
from typing import Optional

Expand Down Expand Up @@ -32,7 +34,7 @@
)
from LLM.OSS.postprocess import run_postprocess
from LLM.rule_book.graph import run_rule_book
from LLM.sub_model.query_index import build_answer
from LLM.sub_model.query_index import build_answer, confident_search_answer, metadata_direct_answer
from LLM.sub_model.schedule_index import schedule_search


Expand All @@ -47,6 +49,7 @@
_ssh_tunnel: Optional[SSHTunnelForwarder] = None
_db_pool: Optional[pg_pool.ThreadedConnectionPool] = None
_log_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="chatbot_log")
_oss_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="chatbot_oss")

_client: Optional[OpenAI] = None
_client_lock = threading.Lock()
Expand Down Expand Up @@ -198,13 +201,19 @@ def call_oss(messages: list[dict[str, str]], **kwargs) -> str:
messages=messages,
temperature=kwargs.get("temperature", 0.3),
max_tokens=kwargs.get("max_tokens", 64),
timeout=kwargs.get("timeout", 45),
)
return (response.choices[0].message.content or "").strip()
except Exception:
logger.warning("oss_call_failed", exc_info=True)
return ""


async def call_oss_async(messages: list[dict[str, str]], **kwargs) -> str:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(_oss_executor, partial(call_oss, messages, **kwargs))


def call_submodel(user_text: str) -> str:
base = ""
try:
Expand Down Expand Up @@ -249,6 +258,7 @@ async def chat_with_oss(req: ChatReq) -> dict:
start = time.monotonic()
user_text = extract_user_text(req)
messages_for_oss = ensure_messages(req, user_text)
compact_user_text = "".join(user_text.split())

if GOVERNANCE_REMOVE_RE.search(user_text) and GOVERNANCE_TARGET_RE.search(user_text):
return {
Expand All @@ -257,6 +267,8 @@ async def chat_with_oss(req: ChatReq) -> dict:
}

mode = req.engine or decide_mode(user_text)
if mode == "oss" and len(compact_user_text) <= 2:
return {"engine": "greet", "text": "네, 무엇을 도와드릴까요?"}
normalized = " ".join(user_text.strip().split())
relative_date_scope = ""
if looks_like_schedule(user_text) and any(keyword in user_text for keyword in RELATIVE_DATE_KEYWORDS):
Expand Down Expand Up @@ -296,6 +308,18 @@ def cache_and_return(response: dict) -> dict:
}

if mode == "fast":
if any(keyword in user_text for keyword in ("종강", "졸업식", "종업식", "학위수여식")):
schedule_only = schedule_search(user_text, top_k=8)
if schedule_only:
return cache_and_return({"engine": "fast", "text": render_chatty_schedule(schedule_only, user_text)})

direct = metadata_direct_answer(user_text)
if direct:
response = {"engine": "fast", "text": direct["answer"]}
if direct.get("url"):
response["url"] = direct["url"]
return cache_and_return(response)

schedule_only = schedule_search(user_text, top_k=8)
if schedule_only:
return cache_and_return({"engine": "fast", "text": render_chatty_schedule(schedule_only, user_text)})
Expand All @@ -312,6 +336,13 @@ def cache_and_return(response: dict) -> dict:
return cache_and_return(response)

if mode == "policy":
direct = metadata_direct_answer(user_text)
if direct:
response = {"engine": "policy", "text": direct["answer"]}
if direct.get("url"):
response["url"] = direct["url"]
return cache_and_return(response)

sub_answer = call_submodel(user_text)
text, url = run_postprocess("policy", user_text, sub_answer)
response = {"engine": "policy", "text": text}
Expand All @@ -328,6 +359,13 @@ def cache_and_return(response: dict) -> dict:
return cache_and_return(response)

if mode == "grad":
direct = metadata_direct_answer(user_text)
if direct:
response = {"engine": "grad", "text": direct["answer"]}
if direct.get("url"):
response["url"] = direct["url"]
return cache_and_return(response)

sub_answer = call_submodel(user_text)
text, url = run_postprocess("grad", user_text, sub_answer)
response = {"engine": "grad", "text": text}
Expand All @@ -344,7 +382,21 @@ def cache_and_return(response: dict) -> dict:
return cache_and_return(response)

if mode == "oss":
output = call_oss(messages_for_oss)
direct = metadata_direct_answer(user_text)
if direct:
response = {"engine": "fast", "text": direct["answer"]}
if direct.get("url"):
response["url"] = direct["url"]
return cache_and_return(response)

confident = confident_search_answer(user_text, top_k=2)
if confident:
response = {"engine": "fast", "text": confident["answer"]}
if confident.get("url"):
response["url"] = confident["url"]
return cache_and_return(response)

output = await call_oss_async(messages_for_oss)
if not any(keyword in user_text for keyword in ("연락처", "전화", "번호", "문의")) and not any(keyword in user_text for keyword in COUNCIL_KWS):
output = scrub_non_contact(output)

Expand Down Expand Up @@ -379,17 +431,32 @@ def cache_and_return(response: dict) -> dict:
_log_executor.submit(_log_chatbot, user_text, "oss", output, None, False, latency)
return {"engine": "oss", "text": output}

direct = metadata_direct_answer(user_text)
if direct:
response = {"engine": "fast", "text": direct["answer"]}
if direct.get("url"):
response["url"] = direct["url"]
return cache_and_return(response)

confident = confident_search_answer(user_text, top_k=2)
if confident:
response = {"engine": "fast", "text": confident["answer"]}
if confident.get("url"):
response["url"] = confident["url"]
return cache_and_return(response)

sub_answer = call_submodel(user_text)
fused = call_oss(
fused = await call_oss_async(
[
{
"role": "system",
"content": "Reasoning: low\n다음 <context>의 사실만 사용해 한국어로 한 문장으로만 답하라. 불릿/개행 금지. 임의의 전화번호/URL을 생성하지 말라.",
},
{"role": "user", "content": user_text + "\n\n<context>\n" + (sub_answer or "") + "\n</context>"},
],
max_tokens=64,
max_tokens=96,
temperature=0.2,
timeout=45,
)
if not fused:
text, _ = run_postprocess("topic", user_text, sub_answer)
Expand Down
2 changes: 1 addition & 1 deletion LLM/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def _valid_or_fallback_regex_pattern(candidate: str | None, fallback: str) -> st
)

SCHED_LINE_PATTERN = (
r"^\s*-\s*(?P<title>[^:]+):\s*(?P<s>\d{4}-\d{2}-\d{2})"
r"^\s*-\s*(?P<title>.+?):\s*(?P<s>\d{4}-\d{2}-\d{2})"
r"(?:\s*~\s*(?P<e>\d{4}-\d{2}-\d{2}))?$"
)
LINE_PATTERN = r"^\s*-\s*(?P<label>[^::]+)[::]\s*(?P<body>.+)$"
Expand Down
116 changes: 105 additions & 11 deletions LLM/rule_book/graph.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import os
import re
import time
import logging
from typing import TypedDict, List, Dict, Optional
Expand All @@ -19,6 +20,12 @@
OSS_MODEL = os.getenv("OSS_MODEL")

TOP_K = int(os.getenv("RULE_BOOK_TOP_K", "5"))
LLM_TOP_K = int(os.getenv("RULE_BOOK_LLM_TOP_K", "2"))
LLM_MAX_TOKENS = int(os.getenv("RULE_BOOK_LLM_MAX_TOKENS", "192"))
LLM_TIMEOUT_SECONDS = float(os.getenv("RULE_BOOK_LLM_TIMEOUT_SECONDS", "45"))
DIRECT_RULE_TERMS_RE = re.compile(r"(임기|기간|자격|권한|의무|선출|선임|구성|정족수|징계|사퇴|해임|소집)")
RULE_BOOK_NOISE_RE = re.compile(r"(규정집|규정|학칙|준칙|회칙|규약|세칙|강령|운영규칙|찾아줘|알려줘|에서)")
ARTICLE_HEADING_RE = re.compile(r"^제\s*\d+\s*조(?:의\d+)?(?:\([^)]*\))?$")

class RuleState(TypedDict):
query: str
Expand All @@ -40,6 +47,86 @@ async def retrieve(state: RuleState) -> RuleState:
return {**state, "chunks": [], "error": "규정집 검색 중 오류가 발생했습니다."}


def _query_terms(query: str) -> list[str]:
cleaned = RULE_BOOK_NOISE_RE.sub(" ", query or "")
terms = [t for t in re.findall(r"[가-힣A-Za-z0-9]{2,}", cleaned) if len(t) >= 2]
return list(dict.fromkeys(terms))


def _clean_text(text: str, max_chars: int = 360) -> str:
text = re.sub(r"\s+", " ", text or "").strip()
if len(text) <= max_chars:
return text
return text[:max_chars].rstrip() + "..."


def _split_rule_sentences(text: str) -> list[str]:
lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
candidates = []
for line in lines:
candidates.extend(part.strip() for part in re.split(r"(?<=[다요함음)])\s+", line) if part.strip())
if not candidates:
candidates = [part.strip() for part in re.split(r"(?<=[.!?])\s+", text or "") if part.strip()]
return candidates


def _source_label(chunk: Dict) -> str:
source = (chunk.get("source") or "").strip()
article = (chunk.get("article") or "").strip()
return f"{source} {article}".strip()


def _direct_answer_from_chunks(query: str, chunks: List[Dict]) -> Optional[str]:
if not chunks or not DIRECT_RULE_TERMS_RE.search(query or ""):
return None

terms = _query_terms(query)
if not terms:
return None

def chunk_score(chunk: Dict) -> int:
haystack = f"{chunk.get('source', '')} {chunk.get('article', '')} {chunk.get('text', '')}"
return sum(1 for term in terms if term in haystack)

ranked = sorted(chunks, key=chunk_score, reverse=True)
for chunk in ranked[:3]:
text = chunk.get("text", "")
label = _source_label(chunk)
sentences = _split_rule_sentences(text)

scored_sentences = []
for idx, sentence in enumerate(sentences):
if ARTICLE_HEADING_RE.match(sentence) and idx + 1 < len(sentences):
sentence = f"{sentence} {sentences[idx + 1]}"
score = sum(1 for term in terms if term in sentence)
if DIRECT_RULE_TERMS_RE.search(sentence):
score += 2
if score > 0:
scored_sentences.append((score, sentence))

if scored_sentences:
scored_sentences.sort(key=lambda item: item[0], reverse=True)
snippet = _clean_text(scored_sentences[0][1])
return f"{label}에 따르면, {snippet} (출처: {label})"

if chunk_score(chunk) >= max(1, min(2, len(terms))):
snippet = _clean_text(text)
return f"관련 조문은 {label}입니다. 확인된 내용: {snippet} (출처: {label})"

return None


def _fallback_answer_from_chunks(chunks: List[Dict]) -> str:
if not chunks:
return "해당 규정을 찾을 수 없습니다."
lines = []
for chunk in chunks[:2]:
label = _source_label(chunk)
snippet = _clean_text(chunk.get("text", ""), max_chars=220)
lines.append(f"- {label}: {snippet}")
return "확인된 규정집 자료 기준으로 관련 조문은 다음과 같습니다.\n" + "\n".join(lines)


async def generate(state: RuleState) -> RuleState:
if state.get("error") and not state.get("chunks"):
return {**state, "answer": "규정집 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
Expand All @@ -48,9 +135,13 @@ async def generate(state: RuleState) -> RuleState:
if not chunks:
return {**state, "answer": "해당 규정을 찾을 수 없습니다."}

direct_answer = _direct_answer_from_chunks(state["query"], chunks)
if direct_answer:
return {**state, "answer": direct_answer}

context_parts = []
for c in chunks:
text = c['text'][:300]
for c in chunks[:LLM_TOP_K]:
text = c['text'][:450]
context_parts.append(f"[출처: {c['source']} {c['article']}]\n{text}")
context = "\n\n".join(context_parts)

Expand All @@ -65,14 +156,17 @@ async def generate(state: RuleState) -> RuleState:
user_prompt = f"질문: {state['query']}\n\n<규정집 내용>\n{context}\n</규정집 내용>"

try:
resp = await _async_client.chat.completions.create(
model=OSS_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.2,
max_tokens=1024,
resp = await asyncio.wait_for(
_async_client.chat.completions.create(
model=OSS_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.2,
max_tokens=LLM_MAX_TOKENS,
),
timeout=LLM_TIMEOUT_SECONDS,
)
choice = resp.choices[0]
finish_reason = choice.finish_reason
Expand All @@ -84,7 +178,7 @@ async def generate(state: RuleState) -> RuleState:
return {**state, "answer": answer}
except Exception as e:
logger.exception("LLM 답변 생성 중 오류 발생: %s", e)
return {**state, "answer": "답변 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
return {**state, "answer": _fallback_answer_from_chunks(chunks)}


def _build_graph():
Expand Down
Loading