Skip to content

Commit 8c54aac

Browse files
author
panda
committed
feat(rag/ui/eval): sync local workflow, app, parser, prompts and eval scripts
1 parent 55b4821 commit 8c54aac

7 files changed

Lines changed: 418 additions & 1507 deletions

File tree

app/components.py

Lines changed: 47 additions & 249 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,13 @@
1111
import streamlit as st
1212

1313

14-
_COMPONENTS_DIR = Path(__file__).parent
15-
_DATA_CSV_PATH = _COMPONENTS_DIR.parent / "data_index" / "data" / "data_list.csv"
16-
17-
FAQ_EVAL_ITEMS: list[dict] = [
18-
{
19-
"label": "정부기관 사업조회",
20-
"question": "등록된 정부기관의 사업 목록을 전체 조회해 주세요.",
21-
"institution": "",
22-
"keyword": "",
23-
"answer_type": "project_list",
24-
},
25-
{
26-
"label": "sw관련 사업 조회",
27-
"question": "SW(소프트웨어) 관련 사업의 목록과 예산을 조회해 주세요.",
28-
"institution": "",
29-
"keyword": "SW",
30-
"answer_type": "project_list",
31-
},
14+
FAQ_QUESTIONS = [
15+
"예산 규모와 집행 조건을 요약해줘",
16+
"사업 기간과 주요 마일스톤을 정리해줘",
17+
"참가 자격 요건을 알려줘",
18+
"필수 제출서류 체크리스트를 만들어줘",
19+
"평가 항목과 배점을 정리해줘",
20+
"마감일과 일정 리스크를 알려줘",
3221
]
3322

3423
CARD_META: dict[str, dict[str, str]] = {
@@ -41,109 +30,6 @@
4130
}
4231

4332

44-
def _build_csv_answer(df: pd.DataFrame, answer_type: str) -> str:
45-
"""CSV 데이터프레임에서 answer_type에 맞는 답변 텍스트를 생성합니다."""
46-
def _fmt_amount(val: Any) -> str:
47-
try:
48-
return f"{int(float(val)):,}원" if pd.notna(val) and str(val).strip() else "정보 없음"
49-
except (ValueError, TypeError):
50-
return str(val)
51-
52-
def _clean(val: Any) -> str:
53-
s = str(val).strip()
54-
return "" if s.lower() in ("nan", "none", "") else s
55-
56-
if answer_type == "budget":
57-
row = df.iloc[0]
58-
proj = _clean(row.get("사업명", ""))
59-
org = _clean(row.get("발주 기관", ""))
60-
amount_fmt = _fmt_amount(row.get("사업 금액", ""))
61-
return f"**{proj}** ({org})\n\n사업 금액: **{amount_fmt}** (VAT 포함)"
62-
63-
if answer_type == "project_list":
64-
org = _clean(df.iloc[0].get("발주 기관", ""))
65-
projects = [_clean(p) for p in df["사업명"].dropna().tolist() if _clean(p)]
66-
lines = [f"**{org}** 진행 사업 — 총 {len(projects)}\n"]
67-
lines += [f"- {p}" for p in projects]
68-
return "\n".join(lines)
69-
70-
if answer_type == "schedule":
71-
row = df.iloc[0]
72-
proj = _clean(row.get("사업명", ""))
73-
start = _clean(row.get("입찰 참여 시작일", "")) or "정보 없음"
74-
end = _clean(row.get("입찰 참여 마감일", "")) or "정보 없음"
75-
return (
76-
f"**{proj}**\n\n"
77-
f"- 입찰 참여 시작일: **{start}**\n"
78-
f"- 입찰 참여 마감일: **{end}**"
79-
)
80-
81-
# summary (default)
82-
row = df.iloc[0]
83-
proj = _clean(row.get("사업명", ""))
84-
org = _clean(row.get("발주 기관", ""))
85-
amount_fmt = _fmt_amount(row.get("사업 금액", ""))
86-
start = _clean(row.get("입찰 참여 시작일", ""))
87-
end = _clean(row.get("입찰 참여 마감일", ""))
88-
summary_text = _clean(row.get("사업 요약", ""))
89-
90-
parts = [f"**{proj}** ({org})\n", f"- 사업 금액: **{amount_fmt}**"]
91-
if start:
92-
parts.append(f"- 입찰 참여 시작일: {start}")
93-
if end:
94-
parts.append(f"- 입찰 참여 마감일: {end}")
95-
if summary_text:
96-
short = summary_text[:400] + ("…" if len(summary_text) > 400 else "")
97-
parts.append(f"\n**사업 요약:**\n{short}")
98-
return "\n".join(parts)
99-
100-
101-
def answer_from_csv(faq_item: dict) -> dict[str, Any]:
102-
"""FAQ 항목을 data_list.csv에서 직접 조회하여 답변을 생성합니다."""
103-
try:
104-
df = pd.read_csv(_DATA_CSV_PATH, encoding="utf-8-sig")
105-
except UnicodeDecodeError:
106-
try:
107-
df = pd.read_csv(_DATA_CSV_PATH, encoding="utf-8")
108-
except Exception as exc:
109-
return {"answer": f"CSV 파일 로드 실패: {exc}", "evidence": []}
110-
except Exception as exc:
111-
return {"answer": f"CSV 파일 로드 실패: {exc}", "evidence": []}
112-
113-
institution = faq_item.get("institution", "")
114-
keyword = faq_item.get("keyword", "")
115-
answer_type = faq_item.get("answer_type", "summary")
116-
117-
mask = pd.Series([True] * len(df), index=df.index)
118-
if institution:
119-
mask &= df["발주 기관"].str.contains(institution, na=False, case=False)
120-
if keyword:
121-
mask &= df["사업명"].str.contains(keyword, na=False, case=False)
122-
filtered = df[mask].copy()
123-
124-
# keyword 조건 미충족 시 institution만으로 재시도
125-
if filtered.empty and institution:
126-
inst_mask = df["발주 기관"].str.contains(institution, na=False, case=False)
127-
filtered = df[inst_mask].copy()
128-
129-
if filtered.empty:
130-
return {
131-
"answer": f"'{institution}' 관련 데이터를 CSV에서 찾을 수 없습니다.",
132-
"evidence": [],
133-
}
134-
135-
answer_text = _build_csv_answer(filtered, answer_type)
136-
evidence = [
137-
{
138-
"source": str(row.get("파일명", "") or "").strip() or "data_list.csv",
139-
"page": None,
140-
"text": f"{row.get('사업명', '')} | {row.get('발주 기관', '')}",
141-
}
142-
for _, row in filtered.head(5).iterrows()
143-
]
144-
return {"answer": answer_text, "evidence": evidence}
145-
146-
14733
def inject_styles() -> None:
14834
st.markdown(
14935
"""
@@ -302,32 +188,16 @@ def inject_styles() -> None:
302188
.section-title:first-child { margin-top: 0; }
303189
.answer-summary {
304190
color: #0F172A;
305-
font-size: 0.9rem;
191+
font-size: 0.95rem;
306192
line-height: 1.7;
307193
}
308-
.answer-bullets {
309-
margin: 4px 0 0;
310-
padding-left: 20px;
311-
}
312-
.answer-bullet {
313-
font-size: 0.9rem;
314-
line-height: 1.6;
315-
color: #0F172A;
316-
margin-bottom: 4px;
317-
}
318-
.answer-caption {
319-
font-size: 0.85rem;
320-
color: #64748B;
321-
margin-top: 4px;
322-
line-height: 1.5;
323-
}
324194
.answer-evidence {
325195
background: #F8FAFC;
326196
border-left: 3px solid #10A37F;
327197
border-radius: 0 8px 8px 0;
328198
padding: 8px 14px;
329199
margin-top: 6px;
330-
font-size: 0.9rem;
200+
font-size: 0.82rem;
331201
color: #475569;
332202
line-height: 1.5;
333203
}
@@ -472,20 +342,6 @@ def inject_styles() -> None:
472342
color: #0F172A !important;
473343
letter-spacing: -0.01em;
474344
}
475-
476-
/* ── Heading normalization inside content areas ── */
477-
.answer-box h1, .answer-box h2, .answer-box h3,
478-
.answer-box h4, .answer-box h5, .answer-box h6,
479-
.answer-summary h1, .answer-summary h2, .answer-summary h3,
480-
.answer-summary h4, .answer-summary h5, .answer-summary h6,
481-
.answer-bullet h1, .answer-bullet h2, .answer-bullet h3 {
482-
font-size: 0.9rem !important;
483-
font-weight: 600 !important;
484-
color: #0F172A !important;
485-
margin: 4px 0 !important;
486-
padding: 0 !important;
487-
border: none !important;
488-
}
489345
</style>
490346
""",
491347
unsafe_allow_html=True,
@@ -548,14 +404,9 @@ def _sentence_split(text: str) -> list[str]:
548404
return [p.strip() for p in parts if p.strip()]
549405

550406

551-
def _strip_heading_markers(text: str) -> str:
552-
"""마크다운 헤딩 기호(#~######)를 제거합니다."""
553-
return re.sub(r"^#{1,6}\s+", "", text.strip())
554-
555-
556407
def parse_answer_payload(result: dict[str, Any]) -> tuple[str, list[str], list[dict[str, Any]]]:
557408
answer = str(result.get("answer", "") or "").strip()
558-
sentences = [_strip_heading_markers(s) for s in _sentence_split(answer)]
409+
sentences = _sentence_split(answer)
559410
summary = " ".join(sentences[:2]) if sentences else "답변이 생성되지 않았습니다."
560411

561412
bullets: list[str] = []
@@ -564,14 +415,10 @@ def parse_answer_payload(result: dict[str, Any]) -> tuple[str, list[str], list[d
564415
if not stripped:
565416
continue
566417
if stripped.startswith("-") or stripped.startswith("*"):
567-
bullets.append(_strip_heading_markers(stripped.lstrip("-* ")))
418+
bullets.append(stripped.lstrip("-* "))
568419
elif stripped.startswith("|"):
569420
continue
570-
elif stripped.startswith("#"):
571-
clean = _strip_heading_markers(stripped)
572-
if len(bullets) < 5 and len(clean) > 2:
573-
bullets.append(clean)
574-
elif len(bullets) < 5 and len(stripped) > 8:
421+
elif len(bullets) < 5 and len(stripped) > 8 and not stripped.startswith("###"):
575422
bullets.append(stripped)
576423
if len(bullets) >= 5:
577424
break
@@ -583,21 +430,18 @@ def parse_answer_payload(result: dict[str, Any]) -> tuple[str, list[str], list[d
583430

584431

585432
def render_answer_sections(result: dict[str, Any]) -> None:
586-
# raw_answer: RAG/LLM이 생성한 원본 답변 (post-processing 이전)
587-
# answer: 후처리 완료 답변 (raw_answer가 없을 때 fallback)
588-
raw = str(result.get("raw_answer", "") or "").strip()
589-
answer = raw if raw else str(result.get("answer", "") or "").strip()
590-
evidence: list[dict[str, Any]] = result.get("evidence", [])
591-
if not isinstance(evidence, list):
592-
evidence = []
593-
433+
summary, bullets, evidence = parse_answer_payload(result)
594434
st.markdown('<div class="answer-box">', unsafe_allow_html=True)
595435

596-
st.markdown("<div class='section-title'>📝 답변</div>", unsafe_allow_html=True)
597-
if answer:
598-
st.markdown(answer)
436+
st.markdown("<div class='section-title'>📝 요약</div>", unsafe_allow_html=True)
437+
st.markdown(f"<div class='answer-summary'>{summary}</div>", unsafe_allow_html=True)
438+
439+
st.markdown("<div class='section-title'>💡 핵심 포인트</div>", unsafe_allow_html=True)
440+
if bullets:
441+
for item in bullets:
442+
st.markdown(f"- {item}")
599443
else:
600-
st.markdown("<div class='answer-caption'>답변이 생성되지 않았습니다.</div>", unsafe_allow_html=True)
444+
st.caption("핵심 포인트를 자동 추출하지 못했습니다.")
601445

602446
st.markdown("<div class='section-title'>📎 근거 (페이지/섹션)</div>", unsafe_allow_html=True)
603447
if evidence:
@@ -608,7 +452,7 @@ def render_answer_sections(result: dict[str, Any]) -> None:
608452

609453
page_val = str(page).strip() if page not in (None, "", "-", "None") else ""
610454
page_badge = (
611-
f" <span style='font-size:0.75rem;background:#ECFDF5;border:1px solid #10A37F;"
455+
f" <span style='font-size:0.7rem;background:#ECFDF5;border:1px solid #10A37F;"
612456
f"border-radius:4px;padding:1px 6px;color:#10A37F;font-weight:600;"
613457
f"vertical-align:middle;'>p.{page_val}</span>"
614458
if page_val else ""
@@ -620,9 +464,10 @@ def render_answer_sections(result: dict[str, Any]) -> None:
620464
unsafe_allow_html=True,
621465
)
622466
if text:
623-
st.markdown(f"<div class='answer-caption'>{text}</div>", unsafe_allow_html=True)
467+
preview = text[:200] + ("…" if len(text) > 200 else "")
468+
st.caption(preview)
624469
else:
625-
st.markdown("<div class='answer-caption'>근거 정보가 제공되지 않았습니다.</div>", unsafe_allow_html=True)
470+
st.caption("근거 정보가 제공되지 않았습니다.")
626471
st.markdown("</div>", unsafe_allow_html=True)
627472

628473

@@ -668,28 +513,16 @@ def _split_filename(filename: str) -> tuple[str, str]:
668513
return org, project
669514

670515

671-
def parse_budget_input(val_str: str, default: int) -> int:
672-
"""쉼표가 포함된 예산 문자열에서 정수를 파싱합니다."""
673-
cleaned = re.sub(r"[,\s원]", "", str(val_str))
674-
try:
675-
result = int(float(cleaned))
676-
return result if result >= 0 else default
677-
except (ValueError, TypeError):
678-
return default
679-
680-
681516
def real_bids_dataframe(chatbot: Any) -> pd.DataFrame:
682-
"""벡터스토어 org_registry, CSV, 또는 파일명 파싱으로 실제 입찰 목록을 생성합니다.
517+
"""벡터스토어 org_registry 또는 파일명 파싱으로 실제 입찰 목록을 생성합니다.
683518
684519
우선순위:
685520
1. chatbot.vector_store.org_registry (기관명·예산·사업명 추출 완료)
686-
2. data/data_list.csv (예산·마감일 포함 완전 데이터)
687-
3. data/files/ 파일명 파싱 (예산·마감일 없음)
688-
4. 빈 DataFrame 반환 (데이터 없음 안내)
521+
2. data/files/ 파일명 파싱 (예산·마감일 없음)
522+
3. 빈 DataFrame 반환 (데이터 없음 안내)
689523
"""
690524
rows: list[dict] = []
691525

692-
# Priority 1: org_registry
693526
try:
694527
registry = chatbot.vector_store.org_registry
695528
if registry:
@@ -710,60 +543,25 @@ def real_bids_dataframe(chatbot: Any) -> pd.DataFrame:
710543
except Exception:
711544
pass
712545

713-
if rows and any(r.get("예산") is not None or r.get("마감일") is not None for r in rows):
714-
return pd.DataFrame(rows)
715-
716-
rows = [] # 유효 데이터 없으면 CSV로 재시도
717-
718-
# Priority 2: CSV data (예산·마감일 포함)
719-
try:
720-
df_csv = pd.read_csv(_DATA_CSV_PATH, encoding="utf-8-sig")
721-
for _, row in df_csv.iterrows():
722-
budget_val: int | None = None
723-
amount = row.get("사업 금액")
724-
if pd.notna(amount):
725-
try:
726-
budget_val = int(float(amount))
727-
except (ValueError, TypeError):
728-
pass
729-
deadline = _parse_date_safe(str(row.get("입찰 참여 마감일", "") or ""))
730-
공고번호_raw = str(row.get("공고 번호", "")).strip()
731-
공고번호 = (
732-
공고번호_raw
733-
if 공고번호_raw not in ("", "nan", "None")
734-
else f"RFP-{len(rows) + 1:03d}"
735-
)
736-
rows.append({
737-
"공고번호": 공고번호,
738-
"사업명": str(row.get("사업명", "") or "").strip(),
739-
"기관": str(row.get("발주 기관", "") or "").strip(),
740-
"예산": budget_val,
741-
"마감일": deadline,
742-
})
743-
if rows:
744-
return pd.DataFrame(rows)
745-
except Exception:
746-
pass
747-
748-
# Priority 3: files directory (파일명 파싱만)
749-
try:
750-
from src.utils.config import get_data_dir
751-
files_dir = get_data_dir() / "files"
752-
if files_dir.exists():
753-
all_files: list[Path] = []
754-
for pat in ("*.hwp", "*.hwpx", "*.pdf"):
755-
all_files.extend(sorted(files_dir.glob(pat)))
756-
for idx, f in enumerate(all_files, start=1):
757-
org, project = _split_filename(f.name)
758-
rows.append({
759-
"공고번호": f"RFP-{idx:03d}",
760-
"사업명": project,
761-
"기관": org,
762-
"예산": None,
763-
"마감일": None,
764-
})
765-
except Exception:
766-
pass
546+
if not rows:
547+
try:
548+
from src.utils.config import get_data_dir
549+
files_dir = get_data_dir() / "files"
550+
if files_dir.exists():
551+
all_files: list[Path] = []
552+
for pat in ("*.hwp", "*.hwpx", "*.pdf"):
553+
all_files.extend(sorted(files_dir.glob(pat)))
554+
for idx, f in enumerate(all_files, start=1):
555+
org, project = _split_filename(f.name)
556+
rows.append({
557+
"공고번호": f"RFP-{idx:03d}",
558+
"사업명": project,
559+
"기관": org,
560+
"예산": None,
561+
"마감일": None,
562+
})
563+
except Exception:
564+
pass
767565

768566
if not rows:
769567
return pd.DataFrame(columns=["공고번호", "사업명", "기관", "예산", "마감일"])

0 commit comments

Comments
 (0)