1111import 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
3423CARD_META : dict [str , dict [str , str ]] = {
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-
14733def 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-
556407def 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
585432def 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-
681516def 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