LangChain 챗봇8장 · 최종 프로젝트8 / 12← 이전목차다음 →MetaContents

8장. 최종 프로젝트

프로젝트 1 — 실무 문서 챗봇: 사내 규정 도우미

솔마루상사 인사팀에는 "재택근무는 며칠까지 돼요?", "출장 숙박비는 얼마까지 나와요?" 같은 질문이 날마다 들어옵니다. 답은 모두 복무·출장 규정에 있습니다. 직원이 규정 PDF 를 뒤지지 않고 물어보면 근거 조항과 함께 답하고, 규정에 없는 것은 인사팀으로 넘기는 사내 규정 도우미를 만들어 보세요. 솔마루상사와 그 규정은 연습용으로 지어낸 가상의 회사와 문서이고, 실제 회사와 관계가 없습니다. 이 프로젝트는 연습용 임베딩(BigramEmbeddings)과 가짜 모델(FakeListChatModel)로 짜서 API 키 없이 끝까지 실제로 돌아갑니다. 키가 있으면 부품 파일의 make_llm 이 진짜 모델을 고릅니다.

항목내용
문서solmaru_rules.pdf. 3쪽, 조항 12개(제1장 근무, 제2장 휴가, 제3장 출장과 경비). 쪽마다 맨 위에 머리글 "솔마루상사 복무·출장 규정 (연습용 가상 문서)"
평가 질문rules_questions.csv. 10행(열: 번호, 질문, 정답조항, 핵심어). 직원의 평소 말투로 지어낸 질문
범위 밖 질문코드에 적는 3개: 주차장, 성과급, 사내 식당
만들 파일out_ch08_rules_eval.csv(평가 결과), out_ch08_rules_bot.py(부품), out_ch08_rules_app.py(화면), out_ch08_rules_demo.csv(시연 점검표)

요구사항은 여섯 가지입니다.

  1. 규정 PDF 를 머리글을 지우고 조항 단위의 조각으로 나눕니다. 조각마다 metadata 에 조항 번호와 쪽을 넣습니다.
  2. BigramEmbeddings 와 FAISS 로 색인을 만들고, 평가 질문 10개로 hit@1 과 hit@3 을 잽니다.
  3. 놓친 질문을 읽고 피드백 하나를 반영해 고친 뒤 다시 잽니다. 평가 질문에 없던 새 말투의 질문으로도 따로 잽니다.
  4. 범위 밖 질문 3개와 맞게 찾은 질문의 거리로 문턱을 정하고, 문턱보다 멀면 "규정에서 찾지 못했습니다. 인사팀에 물어 주세요."라고 답하게 합니다.
  5. 챗봇 로직을 부품 파일에, 화면을 화면 파일에 나눠 씁니다. 화면은 답 아래에 근거 조항을 펼쳐 보이게 합니다.
  6. AppTest 로 시연 점검표(첫 화면, 시연 질문 셋, 기록, 키 노출)를 만들어 저장합니다.

단계별 힌트입니다.

  1. 조항 단위로 나누기는 6장 실습 2의 re.split(r"(?=제\d+조 )", text) 를 그대로 씁니다.
  2. 검색 결과의 점수는 거리라서 작을수록 가깝습니다. similarity_search_with_score(q, k=3) 의 첫째가 1위입니다.
  3. 놓친 질문은 질문과 조항 사이에 겹치는 글자 묶음이 적은 경우가 많습니다. "집에서 일"과 "재택근무"처럼 평소 말과 규정의 낱말을 짝지은 별칭 사전으로 질문을 고쳐 쓰면 연습용 임베딩으로도 찾을 수 있습니다.
  4. 문턱은 "맞게 찾은 질문 가운데 가장 먼 거리"와 "범위 밖 질문 가운데 가장 가까운 거리"의 한가운데로 둡니다.
  5. 부품 파일은 Path("out_ch08_rules_bot.py").write_text(...) 로 쓰고, 화면 파일은 sys.path.insert(0, str(HERE)) 뒤에 import out_ch08_rules_bot as bot 으로 불러옵니다.
  6. AppTest 앞에서 st.cache_resource.clear() 를 부르고, at.secrets["OPENAI_API_KEY"] = "발급받은_인증키" 로 연습 모드를 고릅니다.

확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.

채점 기준배점
조항 단위로 나누고 metadata 를 붙였다10
고치기 전과 뒤의 검색 적중을 같은 질문으로 쟀다20
새 말투의 질문으로 따로 재고 한계를 적었다15
문턱을 근거 있게 정하고 범위 밖 질문을 넘겼다20
부품 파일과 화면 파일을 나누고 근거를 펼쳐 보인다20
시연 점검표와 피드백 기록 표가 출력과 맞다15

코드의 흐름

예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.

조항 조각, 적중 재기, 고치고 문턱, 파일 쓰기, 시연 점검의 다섯 단계를 이은 코드 흐름도

상자 하나가 예시 풀이의 단계 하나이고, 회청 화살표를 따라가면 실행 차례입니다. 1~3단계는 보통 파이썬으로 검색만 재는 단계이고, 4단계에서 그 결과를 부품 파일과 화면 파일로 옮기며, 5단계에서 AppTest 로 화면을 시연하듯 돌려 봅니다. 3단계의 "고치고 다시 재기"가 본문 "시연과 피드백" 절의 고리입니다.

실제 데이터로 해 보려면

우리 회사의 실제 규정으로 같은 도우미를 만들 수 있습니다. 먼저 사내 문서를 이런 데 써도 되는지, 문서를 OpenAI 같은 바깥 서비스에 보내도 되는지 회사의 보안 규정을 확인합니다. 직원 이름, 연락처, 급여처럼 개인정보가 든 부분은 지우거나 가린 뒤에 씁니다.

자료구하는 곳쓰는 법
사내 규정집사내 문서 관리 시스템, 인사팀보안 규정이 허락하는 문서만 쓴다. 조항의 짜임("제N조")을 먼저 확인한다
공공 기관의 복무 규정국가법령정보센터(law.go.kr)의 행정규칙·자치법규공개된 규정이라 연습에 쓰기 좋다. 이용 조건을 확인한다
직원이 실제로 물은 질문인사팀의 문의 기록이름을 지우고 말투만 살려 평가 질문으로 옮긴다
결과 보기 (예시 풀이)

1단계. 규정 PDF 를 조항 조각으로 나누기

Python
import re

import pandas as pd
from langchain_community.document_loaders import PyPDFLoader
from langchain_core.documents import Document

PDF = "solmaru_rules.pdf"
# 쪽마다 맨 위에 붙은 머리글. 조각에 섞이면 검색을 흐리므로 지운다(6차시 실습 2)
HEADER = "솔마루상사 복무·출장 규정 (연습용 가상 문서)"

pages = PyPDFLoader(PDF).load()
print("쪽 수:", len(pages), "/ 쪽별 글자 수:", [len(p.page_content) for p in pages])


def split_articles(pages):
    """머리글을 지우고 '제N조 ' 앞에서 잘라 조항 하나를 조각 하나로 만든다(6차시 실습 2와 같은 방법)"""
    docs = []
    for p in pages:
        text = re.sub(r"\s+", " ", p.page_content.replace(HEADER, "")).strip()
        parts = re.split(r"(?=제\d+조 )", text)
        chapter = parts[0].strip()                      # 첫 토막은 "제1장 근무" 같은 장 제목
        for part in parts[1:]:
            number = re.match(r"제\d+조", part).group()
            docs.append(Document(page_content=part.strip(),
                                 metadata={"article": number, "chapter": chapter, "page": p.metadata["page"] + 1}))
    return docs


docs = split_articles(pages)
print("조항 조각:", len(docs), "개")
for d in docs[2:4]:
    print(d.metadata, "|", d.page_content[:45], "…")
출력
쪽 수: 3 / 쪽별 글자 수: [430, 414, 429]
조항 조각: 12 개
{'article': '제3조', 'chapter': '제1장 근무', 'page': 1} | 제3조 재택근무 팀장의 승인을 받으면 한 주에 이틀까지 재택근무를 할 수 있다.  …
{'article': '제4조', 'chapter': '제1장 근무', 'page': 1} | 제4조 지각과 조퇴 지각이나 조퇴를 할 때는 근무 시작 전까지 팀장에게 알리고 근 …

코드가 의미하는 것 PyPDFLoader(PDF).load() 는 PDF 를 쪽마다 Document 하나로 읽고, split_articles 는 그것을 직원이 찾는 단위인 조항으로 다시 자릅니다. 조각마다 metadata 에 조항 번호, 장, 쪽을 넣어 두는 것이 이 단계의 핵심입니다. 나중에 화면이 "제9조(3쪽)"처럼 근거를 붙일 때 모델에게 묻지 않고 이 값을 그대로 꺼내 쓰기 때문입니다.

2단계. 평가 질문으로 검색 적중 재기

Python
import zlib

from langchain_community.vectorstores import FAISS
from langchain_core.embeddings import Embeddings


class BigramEmbeddings(Embeddings):
    """글자 두 개 묶음(바이그램)을 세어 숫자 목록을 만드는 연습용 임베딩"""

    def __init__(self, size=512):
        self.size = size

    def _vector(self, text):
        text = text.replace(" ", "").replace("\n", "")
        vec = [0.0] * self.size
        for i in range(len(text) - 1):
            # 글자 두 개를 crc32 로 0~511 사이의 칸 번호로 바꿔 그 칸을 1 올린다
            vec[zlib.crc32(text[i:i + 2].encode("utf-8")) % self.size] += 1.0
        norm = sum(v * v for v in vec) ** 0.5 or 1.0
        return [v / norm for v in vec]

    def embed_documents(self, texts):
        return [self._vector(t) for t in texts]

    def embed_query(self, text):
        return self._vector(text)


db = FAISS.from_documents(docs, BigramEmbeddings())
questions = pd.read_csv("rules_questions.csv")


def measure(db, questions, rewrite=lambda q: q):
    """질문마다 가까운 조각 3개를 찾아 1위 조항, 1위 거리, 적중(1위, 3위 안)을 표로 돌려준다"""
    rows = []
    for row in questions.itertuples():
        hits = db.similarity_search_with_score(rewrite(row.질문), k=3)
        found = [d.metadata["article"] for d, _ in hits]
        rows.append({"번호": row.번호, "정답": row.정답조항, "찾은 조항": " ".join(found),
                     "1위 거리": round(float(hits[0][1]), 3),
                     "1위 적중": found[0] == row.정답조항, "3위 안 적중": row.정답조항 in found})
    return pd.DataFrame(rows)


before = measure(db, questions)
print(before.to_string(index=False))
print("1위 적중:", int(before["1위 적중"].sum()), "/", len(before),
      "/ 3위 안 적중:", int(before["3위 안 적중"].sum()), "/", len(before))
출력
 번호   정답        찾은 조항  1위 거리  1위 적중  3위 안 적중
  1  제2조  제2조 제3조 제1조  1.592   True     True
  2  제3조 제7조 제5조 제11조  1.761  False    False
  3  제4조  제4조 제3조 제2조  1.581   True     True
  4  제5조 제5조 제2조 제11조  1.469   True     True
  5  제6조  제6조 제7조 제2조  1.570   True     True
  6  제7조  제3조 제8조 제2조  1.742  False    False
  7  제9조 제9조 제6조 제10조  1.414   True     True
  8 제10조 제10조 제9조 제2조  1.443   True     True
  9 제11조  제9조 제8조 제3조  1.769  False    False
 10 제12조  제9조 제6조 제2조  1.544  False    False
1위 적중: 6 / 10 / 3위 안 적중: 6 / 10

놓친 넷(2, 6, 9, 10번)은 셋째 안에도 정답 조항이 없었습니다. "집에서 일해도"에는 "재택근무"와 겹치는 글자 묶음이 없고, "아내가 아이를 낳으면"에는 "배우자 출산"과 겹치는 묶음이 없습니다. 연습용 임베딩은 낱말의 뜻을 모르고 글자 두 개 묶음만 세기 때문입니다.

코드가 의미하는 것 measure 는 이 프로젝트의 채점기입니다. 질문마다 similarity_search_with_score(…, k=3) 로 가까운 조각 셋과 거리를 받아, 1위가 정답인지(hit@1)와 셋 안에 정답이 있는지(hit@3)를 표에 적습니다. rewrite 인자의 기본값을 "그대로 두기"(lambda q: q)로 둔 것은, 3단계에서 질문을 고쳐 쓴 뒤에도 같은 채점기로 재어 고치기 전과 뒤를 같은 잣대로 견주려는 것입니다.

3단계. 피드백을 반영해 다시 재고 문턱 정하기

Python
# 시연에서 받은 피드백(지어낸 것): "직원들은 규정의 낱말 대신 평소 말로 묻는다"
# 그래서 평소 말 → 규정의 낱말로 바꾸는 별칭 사전을 만들어 질문을 고쳐 쓴다
ALIASES = {"집에서 일": "재택근무", "아내": "배우자", "낳으면": "출산하면",
           "택시비": "택시 교통비", "다녀와서": "돌아온 날부터 경비 정산"}


def rewrite(question):
    for word, rule_word in ALIASES.items():
        question = question.replace(word, rule_word)
    return question


after = measure(db, questions, rewrite)
print(after[["번호", "정답", "찾은 조항", "1위 거리", "1위 적중"]].to_string(index=False))
print("1위 적중:", int(after["1위 적중"].sum()), "/", len(after),
      "/ 3위 안 적중:", int(after["3위 안 적중"].sum()), "/", len(after))

# 사전에 없는 새 말투로도 찾는지 본다(평가 질문에 맞춰 사전을 만들었으므로 새 질문으로 따로 잰다)
NEW = [("재택은 한 주에 며칠까지예요?", "제3조"), ("와이프 출산 휴가 며칠이에요?", "제7조"),
       ("택시 타도 돼요?", "제11조"), ("출장 경비 정산은 언제 들어와요?", "제12조")]
for q, answer in NEW:
    d, s = db.similarity_search_with_score(rewrite(q), k=1)[0]
    print(f"새 질문 {q} → {d.metadata['article']} (정답 {answer}, 거리 {round(float(s), 3)})")

# 규정에 없는 질문. 1위 거리가 문턱보다 멀면 "찾지 못했습니다"라고 답하게 한다
OUTSIDE = ["회사 주차장은 몇 대까지 대요?", "올해 성과급은 언제 나와요?", "사내 식당 메뉴 알려 주세요."]
outside_dist = [round(float(db.similarity_search_with_score(rewrite(q), k=1)[0][1]), 3) for q in OUTSIDE]
hit_far = float(after.loc[after["1위 적중"], "1위 거리"].max())     # 맞게 찾은 질문 가운데 가장 먼 거리
out_near = min(outside_dist)                                       # 규정 밖 질문 가운데 가장 가까운 거리
THRESHOLD = round((hit_far + out_near) / 2, 2)
print("규정 밖 질문의 1위 거리:", outside_dist)
print("맞게 찾은 가장 먼 거리:", hit_far, "/ 밖 질문의 가장 가까운 거리:", out_near, "→ 문턱:", THRESHOLD)

after["판정"] = ["모름" if dist > THRESHOLD else ("맞음" if ok else "틀림")
                 for dist, ok in zip(after["1위 거리"], after["1위 적중"])]
print("평가 질문 판정:", after["판정"].value_counts().to_dict())
print("규정 밖 질문에 모름:", sum(d > THRESHOLD for d in outside_dist), "/", len(OUTSIDE))

report = before[["번호", "정답", "찾은 조항", "1위 적중"]].merge(
    after[["번호", "찾은 조항", "1위 거리", "1위 적중", "판정"]], on="번호", suffixes=("_전", "_후"))
report.to_csv("out_ch08_rules_eval.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch08_rules_eval.csv,", len(report), "행")
출력
 번호   정답        찾은 조항  1위 거리  1위 적중
  1  제2조  제2조 제3조 제1조  1.592   True
  2  제3조 제3조 제4조 제10조  1.381   True
  3  제4조  제4조 제3조 제2조  1.581   True
  4  제5조 제5조 제2조 제11조  1.469   True
  5  제6조  제6조 제7조 제2조  1.570   True
  6  제7조  제7조 제3조 제8조  1.664   True
  7  제9조 제9조 제6조 제10조  1.414   True
  8 제10조 제10조 제9조 제2조  1.443   True
  9 제11조 제9조 제11조 제2조  1.789  False
 10 제12조 제12조 제6조 제2조  1.318   True
1위 적중: 9 / 10 / 3위 안 적중: 10 / 10
새 질문 재택은 한 주에 며칠까지예요? → 제3조 (정답 제3조, 거리 1.524)
새 질문 와이프 출산 휴가 며칠이에요? → 제2조 (정답 제7조, 거리 1.622)
새 질문 택시 타도 돼요? → 제12조 (정답 제11조, 거리 1.848)
새 질문 출장 경비 정산은 언제 들어와요? → 제12조 (정답 제12조, 거리 1.485)
규정 밖 질문의 1위 거리: [1.689, 1.84, 1.68]
맞게 찾은 가장 먼 거리: 1.664 / 밖 질문의 가장 가까운 거리: 1.68 → 문턱: 1.67
평가 질문 판정: {'맞음': 9, '모름': 1}
규정 밖 질문에 모름: 3 / 3
저장: out_ch08_rules_eval.csv, 10 행

별칭 사전으로 질문을 고쳐 쓰자 1위 적중이 9개, 셋째 안 적중이 10개가 되었습니다. 남은 9번("택시 교통비도…")은 둘째에 제11조가 있지만 첫째는 제9조였습니다. 그런데 사전에 없는 새 말투 네 개로 재 보니 둘(재택, 정산)만 찾았습니다. "와이프"는 사전에 없어 제2조로, "택시 타도"는 제12조로 갔습니다. 문턱 1.67 을 두자 평가 질문은 9개가 맞게 답할 거리, 9번 하나가 "모름"이 되었고(1위 거리 1.789), 규정 밖 질문 셋은 모두 "모름"이 되었습니다. 9번은 틀린 조항으로 답하는 대신 인사팀으로 넘기는 쪽이 되었습니다.

코드가 의미하는 것 이 단계는 본문 "시연과 피드백" 절의 고리를 코드로 한 바퀴 돈 것입니다. ALIASES 와 rewrite 는 피드백 하나("평소 말로 묻는다")를 반영한 고침이고, 같은 measure 로 다시 재고, 사전을 만들 때 보지 않은 NEW 질문으로 따로 재어 평가 질문에만 맞춘 고침인지 확인합니다. THRESHOLD 는 본문 "평가표" 절의 셈 그대로 맞게 찾은 거리와 범위 밖 거리의 한가운데로 정하고, 고치기 전후를 out_ch08_rules_eval.csv 한 표로 남겨 시연에서 보일 근거로 씁니다.

4단계. 부품 파일과 화면 파일 쓰기

Python
from pathlib import Path

# 챗봇 로직(부품)을 한 파일에, 화면을 다른 파일에 둔다(7차시 "챗봇 로직과 웹 화면 잇기")
BOT_CODE = r'''
"""솔마루상사 규정 도우미의 부품: 조각 나누기, 연습용 임베딩, 검색, 문턱, 답하기"""
import re
import zlib

from langchain_community.document_loaders import PyPDFLoader
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

HEADER = "솔마루상사 복무·출장 규정 (연습용 가상 문서)"
ALIASES = {"집에서 일": "재택근무", "아내": "배우자", "낳으면": "출산하면",
           "택시비": "택시 교통비", "다녀와서": "돌아온 날부터 경비 정산"}
THRESHOLD = 1.67
NOT_FOUND = "규정에서 찾지 못했습니다. 인사팀에 물어 주세요."
PLACEHOLDER = "발급받은_인증키"


class BigramEmbeddings(Embeddings):
    """글자 두 개 묶음(바이그램)을 세어 숫자 목록을 만드는 연습용 임베딩"""

    def __init__(self, size=512):
        self.size = size

    def _vector(self, text):
        text = text.replace(" ", "").replace("\n", "")
        vec = [0.0] * self.size
        for i in range(len(text) - 1):
            # 글자 두 개를 crc32 로 0~511 사이의 칸 번호로 바꿔 그 칸을 1 올린다
            vec[zlib.crc32(text[i:i + 2].encode("utf-8")) % self.size] += 1.0
        norm = sum(v * v for v in vec) ** 0.5 or 1.0
        return [v / norm for v in vec]

    def embed_documents(self, texts):
        return [self._vector(t) for t in texts]

    def embed_query(self, text):
        return self._vector(text)


def load_chunks(pdf):
    """PDF 를 읽어 머리글을 지우고 조항 하나를 조각 하나로 만든다"""
    docs = []
    for p in PyPDFLoader(str(pdf)).load():
        text = re.sub(r"\s+", " ", p.page_content.replace(HEADER, "")).strip()
        for part in re.split(r"(?=제\d+조 )", text)[1:]:
            number = re.match(r"제\d+조", part).group()
            docs.append(Document(page_content=part.strip(), metadata={"article": number, "page": p.metadata["page"] + 1}))
    return docs


def build_index(docs):
    return FAISS.from_documents(docs, BigramEmbeddings())


def rewrite(question):
    """직원의 평소 말을 규정의 낱말로 바꾼다"""
    for word, rule_word in ALIASES.items():
        question = question.replace(word, rule_word)
    return question


def make_llm(api_key):
    """키가 있으면 진짜 모델, 없거나 자리표시면 연습 모드의 가짜 모델"""
    if api_key and api_key != PLACEHOLDER:
        import os

        from langchain_openai import ChatOpenAI
        return ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-4.1-mini"), api_key=api_key, temperature=0), "OpenAI"
    return FakeListChatModel(responses=["(연습 모드) 진짜 모델 없이 규정만 찾았습니다. 아래 근거 조항을 확인해 주세요."]), "연습 모드"


PROMPT = ChatPromptTemplate.from_messages([
    ("system", "너는 솔마루상사의 규정 안내 도우미다. 아래 [근거]의 조항에 적힌 내용으로만 두세 문장으로 답하고, "
               "숫자는 근거의 표기 그대로 쓴다. 근거에 없으면 '" + NOT_FOUND + "'라고만 답한다.\n\n[근거]\n{context}"),
    ("human", "{question}"),
])


def answer(db, llm, question, k=3):
    """답, 근거 목록, 1위 거리를 돌려준다. 1위가 문턱보다 멀면 모델을 부르지 않고 모른다고 답한다"""
    hits = [(d, float(s)) for d, s in db.similarity_search_with_score(rewrite(question), k=k)]
    best = hits[0][1]
    if best > THRESHOLD:
        return {"answer": NOT_FOUND, "sources": [], "best": best}
    near = [(d, s) for d, s in hits if s <= THRESHOLD]          # 문턱 안의 조각만 근거로 쓴다
    context = "\n".join(f"[{d.metadata['article']} · {d.metadata['page']}쪽] {d.page_content}" for d, _ in near)
    text = (PROMPT | llm | StrOutputParser()).invoke({"context": context, "question": question})
    sources = [f"{d.metadata['article']}({d.metadata['page']}쪽) · 거리 {s:.3f} · {d.page_content[:30]}…" for d, s in near]
    return {"answer": text, "sources": sources, "best": best}
'''

APP_CODE = r'''
import os
import sys
from pathlib import Path

import streamlit as st
from dotenv import find_dotenv, load_dotenv

# 앱 파일과 같은 폴더의 부품 파일을 불러온다
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
import out_ch08_rules_bot as bot  # noqa: E402


def get_api_key():
    """클라우드에서는 Secrets 칸, 내 PC 에서는 .env 에서 키를 읽는다(7차시 실습 3)"""
    try:
        return st.secrets["OPENAI_API_KEY"]
    except (FileNotFoundError, KeyError):
        load_dotenv(find_dotenv(usecwd=True))
        return os.getenv("OPENAI_API_KEY")


@st.cache_resource(show_spinner="규정으로 색인을 만드는 중입니다...")
def load_index():
    docs = bot.load_chunks(HERE / "solmaru_rules.pdf")
    return bot.build_index(docs), len(docs)


@st.cache_resource
def load_llm(api_key):
    return bot.make_llm(api_key)


def show(message):
    with st.chat_message(message["role"]):
        st.markdown(message["content"])
        if message.get("sources"):
            with st.expander("근거 조항 보기"):
                for s in message["sources"]:
                    st.caption(s)


st.set_page_config(page_title="솔마루상사 규정 도우미")
st.title("솔마루상사 규정 도우미")
db, n_chunks = load_index()
llm, mode = load_llm(get_api_key())
with st.sidebar:
    st.header("문서")
    st.caption(f"규정 조각: {n_chunks}개")
    st.caption(f"답하는 모델: {mode}")
    st.caption("연습용으로 지어낸 가상 회사의 규정입니다.")

if "rule_messages" not in st.session_state:
    st.session_state.rule_messages = []
for m in st.session_state.rule_messages:
    show(m)

if question := st.chat_input("근무, 휴가, 출장 규정을 물어보세요"):
    user = {"role": "user", "content": question}
    show(user)
    with st.spinner("규정에서 찾는 중입니다..."):
        result = bot.answer(db, llm, question)
    reply = {"role": "assistant", "content": result["answer"], "sources": result["sources"]}
    show(reply)
    st.session_state.rule_messages.extend([user, reply])
'''

Path("out_ch08_rules_bot.py").write_text(BOT_CODE.lstrip(), encoding="utf-8")
Path("out_ch08_rules_app.py").write_text(APP_CODE.lstrip(), encoding="utf-8")
print("저장: out_ch08_rules_bot.py,", len(BOT_CODE.strip().splitlines()), "줄")
print("저장: out_ch08_rules_app.py,", len(APP_CODE.strip().splitlines()), "줄")

# 부품 파일을 불러와 3단계의 값과 같은지 본다
import sys

sys.path.insert(0, ".")
import out_ch08_rules_bot as bot

print("문턱이 3단계와 같은가:", bot.THRESHOLD == THRESHOLD, "/ 별칭 사전이 같은가:", bot.ALIASES == ALIASES)
llm, mode = bot.make_llm(None)
r = bot.answer(bot.build_index(bot.load_chunks(PDF)), llm, "해외 출장은 언제까지 신청하나요?")
print("모드:", mode, "/ 1위 거리:", round(r["best"], 3))
print("근거:", r["sources"])
출력
저장: out_ch08_rules_bot.py, 92 줄
저장: out_ch08_rules_app.py, 65 줄
문턱이 3단계와 같은가: True / 별칭 사전이 같은가: True
모드: 연습 모드 / 1위 거리: 1.414
근거: ['제9조(3쪽) · 거리 1.414 · 제9조 출장 신청 출장은 떠나기 5일 전까지 출장 신청…', '제6조(2쪽) · 거리 1.517 · 제6조 휴가 신청 연차 휴가는 쓰기 3일 전까지 근태 …', '제10조(3쪽) · 거리 1.629 · 제10조 출장비 국내 출장의 하루 일비는 3만 원, 숙…']

코드가 의미하는 것 BOT_CODE 와 APP_CODE 를 write_text 로 두 파일에 나눠 쓰는 것은 7장에서 본 "로직과 화면 떼어 두기"입니다. 부품 파일의 answer 는 1위 거리가 THRESHOLD 보다 멀면 모델을 부르지 않고 NOT_FOUND 를 돌려주고, 근거 목록은 metadata 에서 코드가 만들며, make_llm 은 키가 없거나 자리표시면 가짜 모델을 골라 연습 모드로 돌게 합니다. 끝의 bot.THRESHOLD == THRESHOLD 비교는 평가에서 정한 값이 파일로 옮겨지며 어긋나지 않았는지 보는 것으로, 화면이 평가한 그대로의 로직을 쓰게 하는 장치입니다.

5단계. AppTest 로 시연 점검표 만들기

Python
import streamlit as st
from streamlit.testing.v1 import AppTest

KEY_PATTERN = re.compile(r"sk-[A-Za-z0-9_-]{20,}")
checks = []


def check(name, ok, why):
    checks.append({"점검": name, "결과": "통과" if ok else "고칠 것", "까닭": why})


st.cache_resource.clear()
at = AppTest.from_file("out_ch08_rules_app.py", default_timeout=30)
# 자리표시를 Secrets 에 넣어 연습 모드로 시험한다(진짜 모델을 부르지 않는다)
at.secrets["OPENAI_API_KEY"] = "발급받은_인증키"
at.run()
side = [c.value for c in at.sidebar.caption]
check("첫 화면", len(at.exception) == 0 and at.title[0].value == "솔마루상사 규정 도우미", " / ".join(side))

# 시연 질문 셋: 규정에 있는 질문, 사전에 없는 말투의 질문, 규정에 없는 질문
DEMO = [("해외 출장은 언제까지 신청하나요?", "제9조"), ("재택은 한 주에 며칠까지예요?", "제3조"),
        ("회사 주차장은 몇 대까지 대요?", None)]
for q, want in DEMO:
    at.chat_input[0].set_value(q).run()
    reply = at.chat_message[-1]
    sources = [c.value for c in reply.expander[0].caption] if reply.expander else []
    if want:
        first = sources[0].split("(")[0] if sources else "없음"
        check(f"질문: {q}", first == want, f"첫 근거 {first}, 근거 {len(sources)}개")
    else:
        said = reply.markdown[0].value
        check(f"질문: {q}", said == bot.NOT_FOUND and not sources, said)

check("기록", len(at.session_state["rule_messages"]) == 2 * len(DEMO), f"말풍선 {len(at.chat_message)}개")
leaks = [f for f in ["out_ch08_rules_bot.py", "out_ch08_rules_app.py"]
         if KEY_PATTERN.search(Path(f).read_text(encoding="utf-8"))]
check("코드에 적힌 키", not leaks, "키 꼴의 글이 든 파일: " + ", ".join(leaks) if leaks else "키 꼴의 글 없음")

demo = pd.DataFrame(checks)
print(demo.to_string(index=False))
demo.to_csv("out_ch08_rules_demo.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch08_rules_demo.csv,", len(demo), "행")
출력
                    점검 결과                                                   까닭
                  첫 화면 통과 규정 조각: 12개 / 답하는 모델: 연습 모드 / 연습용으로 지어낸 가상 회사의 규정입니다.
질문: 해외 출장은 언제까지 신청하나요? 통과                                      첫 근거 제9조, 근거 3개
  질문: 재택은 한 주에 며칠까지예요? 통과                                      첫 근거 제3조, 근거 2개
 질문: 회사 주차장은 몇 대까지 대요? 통과                          규정에서 찾지 못했습니다. 인사팀에 물어 주세요.
                    기록 통과                                               말풍선 6개
              코드에 적힌 키 통과                                            키 꼴의 글 없음
저장: out_ch08_rules_demo.csv, 6 행

시연 점검 여섯 가지가 모두 통과했습니다. 사이드바에는 규정 조각 12개와 "답하는 모델: 연습 모드"가 보였고, "해외 출장은 언제까지 신청하나요?"의 첫 근거는 제9조, 별칭 사전에 없는 "재택은 한 주에 며칠까지예요?"의 첫 근거는 제3조였습니다. 이 질문의 근거가 2개인 것은 셋째 조각이 문턱보다 멀어 근거에서 빠졌기 때문입니다. 주차장 질문에는 모델을 부르지 않고 "규정에서 찾지 못했습니다. 인사팀에 물어 주세요."라고 답했고 근거 칸도 비었습니다. 질문 셋을 보낸 뒤 말풍선은 6개였고, 두 파일 어디에도 키 꼴의 글이 없었습니다.

코드가 의미하는 것 AppTest.from_file(...) 은 브라우저 없이 화면 파일을 돌리고, at.chat_input[0].set_value(q).run() 은 직원이 입력창에 질문을 쳐서 보내는 일을 대신합니다. check 함수가 점검마다 결과와 까닭을 한 줄로 모으므로, 본문 "시연 전 점검표"의 항목(첫 화면, 정해 둔 질문, 범위 밖 질문, 키 노출)을 사람이 매번 눈으로 보지 않고 같은 기준으로 다시 돌릴 수 있습니다. 앱을 고칠 때마다 이 단계를 다시 돌리는 것이 회귀 시험입니다.

결론은 이렇습니다. 연습용 임베딩만으로도 조항 단위로 나누고, 별칭 사전으로 질문을 고쳐 쓰고, 거리 문턱을 두면 평가 질문 10개 가운데 9개는 맞는 조항을 찾고 나머지 하나와 규정 밖 질문 셋은 인사팀으로 넘기는 도우미가 됩니다. 다만 두 가지 한계가 숫자로 드러났습니다. 첫째, 별칭 사전은 올려 둔 말만 고칩니다. 새 말투 네 개 가운데 둘만 찾았고, "와이프 출산 휴가"는 문턱 안의 거리(1.622)로 제2조를 가져와 틀린 근거를 보여 줄 수 있습니다. 둘째, 맞게 찾은 거리와 규정 밖 거리 사이의 틈이 0.016 으로 아주 좁습니다. 그래서 다음 피드백 차례에는 별칭을 더 늘리기보다 진짜 임베딩(OpenAIEmbeddings)으로 바꿔 같은 평가와 새 말투 질문을 다시 돌리는 것을 계획으로 적습니다. 부품 파일의 build_index 한 줄만 바꾸면 됩니다.

받은 피드백(지어낸 것)고친 것다시 잰 결과
"집에서 일해도 되나요?"에 경조사 휴가 조항(제7조)이 나온다평소 말을 규정의 낱말로 바꾸는 별칭 사전 5쌍1위 적중 6/10 → 9/10, 셋째 안 적중 6/10 → 10/10
규정에 없는 주차장을 물어도 지각 조항(제4조)을 보여 준다거리 문턱 1.67, 문턱보다 멀면 인사팀 안내규정 밖 질문 3/3 모름, 평가 질문은 9개 답함·1개 모름
"와이프 출산 휴가"를 물으면 근무 시간 조항(제2조)이 나온다아직 고치지 않음(계획: 진짜 임베딩으로 바꿔 다시 재기)새 말투 4개 가운데 2개만 찾음

피드백은 시연에서 받았다고 지어낸 것이고, 표의 숫자는 위 예시 풀이의 실제 출력입니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 새로 온 인사팀 직원에게 규정집을 쥐여 주고, 모의 문의 10건으로 실력을 본 뒤, 선배의 조언을 듣고 고쳐서 창구에 앉히는 일과 같습니다. 규정집에 조항마다 쪽지를 붙이고(1단계), 모의 문의로 몇 개를 찾는지 세고(2단계), "직원들은 평소 말로 묻는다"는 조언대로 낱말 쪽지를 만들고 "모르면 인사팀에 넘긴다"는 선을 긋고(3단계), 창구를 차리고(4단계), 문 열기 전 점검을 합니다(5단계). 여기서 조항 쪽지가 조항 단위 조각, 낱말 쪽지가 별칭 사전, 넘기는 선이 문턱, 창구가 화면 파일입니다. 비유와 다른 점은, 사람 직원은 "와이프"가 "배우자"인 줄 스스로 알지만 연습용 임베딩은 사전에 적어 준 말밖에 모른다는 것입니다.

단계출력뜻
1. 조항 조각3쪽, 조각 12개머리글을 지우고 조항 하나가 조각 하나가 되었습니다
2. 적중 재기1위 6/10, 셋째 안 6/10평소 말로 물은 넷은 셋째 안에도 정답이 없었습니다
3. 고치고 문턱1위 9/10, 셋째 안 10/10, 새 말투 2/4, 문턱 1.67사전에 올린 말은 고쳐졌지만 새 말투는 반만 찾았습니다
4. 파일 쓰기부품 92줄, 화면 65줄, 문턱·사전 일치평가한 그대로의 로직이 화면에 들어갔습니다
5. 시연 점검여섯 가지 통과첫 화면, 질문 셋, 기록, 키 노출을 같은 기준으로 확인했습니다

한 줄로 줄이면, 실무 문서 챗봇은 "문서의 짜임대로 나누기 + 평가 질문으로 재기 + 피드백 하나씩 고치고 다시 재기 + 모르면 넘기기"로 만든다는 것을 키 없이 끝까지 돌려 본 프로젝트입니다. 같은 짜임에 여러분 회사의 규정을 넣으면 됩니다.

← 이전 절8장 · 시연과 피드백다음 절 →8장 · 프로젝트 2 — 전공 지식 챗봇: 정보통신 학습 도우미