fix: 감리사례 패널 인용 앵커 유실 수정 + 사이드카 지연시간 개선(per_coll 65→20)
Browse files- rag/app.py +57 -27
- rag/graph.py +5 -1
rag/app.py
CHANGED
|
@@ -158,6 +158,29 @@ def render_audit_section(cases):
|
|
| 158 |
st.divider()
|
| 159 |
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
# ---------------------------------------------------------------- 실시간 평가 (B트랙)
|
| 162 |
def render_eval(eval_cfg, question, ans, retrieved):
|
| 163 |
"""답변 후 판사 LLM으로 Faithfulness/Answer Relevancy 채점 + 표시.
|
|
@@ -290,15 +313,28 @@ def main():
|
|
| 290 |
|
| 291 |
api_key, local, eval_cfg = sidebar()
|
| 292 |
|
| 293 |
-
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
with st.chat_message(msg["role"]):
|
| 296 |
st.markdown(msg["content"])
|
| 297 |
|
| 298 |
-
q = st.chat_input("질문을 입력하세요")
|
| 299 |
if not q:
|
| 300 |
-
# 감리사례 버튼/닫기 등으로 인한 리런(q 없음): 직전
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
return
|
| 303 |
with st.chat_message("user"):
|
| 304 |
st.markdown(q)
|
|
@@ -360,28 +396,22 @@ def main():
|
|
| 360 |
valid = {h["ref_key"] or h["doc_no"] for h in final.get("retrieved", [])}
|
| 361 |
used = set(ans.get("used_refs", []))
|
| 362 |
anchors = {r: anchor_id(r) for r in used} # 인용 배지 → 근거 카드 스크롤
|
| 363 |
-
#
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
st.session_state.audit_panel_open = False
|
| 374 |
-
|
| 375 |
-
#
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
st.caption(f"🔗 사용 근거: {', '.join(ans.get('used_refs', [])) or '—'} · "
|
| 380 |
-
f"이 답변은 위 근거 원문에서 생성됐습니다.")
|
| 381 |
-
else:
|
| 382 |
-
st.caption("⚠️ 근거를 찾지 못해 답���하지 않았습니다 (환각 방지).")
|
| 383 |
-
|
| 384 |
-
# 3층-4(선택): 실시간 품질 평가 — 토글 on일 때만 판사 호출
|
| 385 |
render_eval(eval_cfg, q, ans, final.get("retrieved", []))
|
| 386 |
|
| 387 |
st.session_state.messages.append(
|
|
|
|
| 158 |
st.divider()
|
| 159 |
|
| 160 |
|
| 161 |
+
def render_turn_result(turn):
|
| 162 |
+
"""한 턴의 근거카드+답변(클릭 가능한 인용)+감리사례+해설을 렌더(3층-1~3, 2.5).
|
| 163 |
+
|
| 164 |
+
신선한 답변 직후와, q 없는 리런(감리사례 버튼/닫기 클릭) 양쪽에서 동일하게 호출된다.
|
| 165 |
+
st.session_state.last_turn에 저장된 값을 그대로 재생하므로 리런 후에도 highlight_citations
|
| 166 |
+
의 앵커 링크(인용 배지 클릭 → 근거카드 스크롤)가 계속 동작한다. eval(3층-4)은 판사 LLM
|
| 167 |
+
재호출·비용 문제로 여기 포함하지 않고 신선한 경로에서만 별도 호출한다.
|
| 168 |
+
"""
|
| 169 |
+
retrieved, used, anchors = turn.get("retrieved", []), turn.get("used"), turn.get("anchors")
|
| 170 |
+
if retrieved and used:
|
| 171 |
+
render_evidence(retrieved, used_refs=used, anchors=anchors)
|
| 172 |
+
st.markdown("##### 💬 답변")
|
| 173 |
+
st.markdown(turn.get("answer_html", ""))
|
| 174 |
+
render_audit_section(turn.get("audit_cases", []))
|
| 175 |
+
with st.container():
|
| 176 |
+
st.divider()
|
| 177 |
+
if turn.get("has_grounds"):
|
| 178 |
+
st.caption(f"🔗 사용 근거: {', '.join(turn.get('used_refs', [])) or '—'} · "
|
| 179 |
+
f"이 답변은 위 근거 원문에서 생성됐습니다.")
|
| 180 |
+
else:
|
| 181 |
+
st.caption("⚠️ 근거를 찾지 못해 답변하지 않았습니다 (환각 방지).")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
# ---------------------------------------------------------------- 실시간 평가 (B트랙)
|
| 185 |
def render_eval(eval_cfg, question, ans, retrieved):
|
| 186 |
"""답변 후 판사 LLM으로 Faithfulness/Answer Relevancy 채점 + 표시.
|
|
|
|
| 313 |
|
| 314 |
api_key, local, eval_cfg = sidebar()
|
| 315 |
|
| 316 |
+
q = st.chat_input("질문을 입력하세요")
|
| 317 |
+
|
| 318 |
+
# 지난 대화 렌더. q 없는 리런(감리사례 버튼/닫기 클릭 등)이면 마지막 assistant 메시지는
|
| 319 |
+
# 아래에서 last_turn으로 풍부하게(근거카드+클릭 가능한 인용 배지) 재렌더하므로 평문 중복 방지.
|
| 320 |
+
messages = st.session_state.messages
|
| 321 |
+
skip_last_idx = (len(messages) - 1) if (not q and messages
|
| 322 |
+
and messages[-1]["role"] == "assistant"
|
| 323 |
+
and st.session_state.get("last_turn")) else -1
|
| 324 |
+
for i, msg in enumerate(messages):
|
| 325 |
+
if i == skip_last_idx:
|
| 326 |
+
continue
|
| 327 |
with st.chat_message(msg["role"]):
|
| 328 |
st.markdown(msg["content"])
|
| 329 |
|
|
|
|
| 330 |
if not q:
|
| 331 |
+
# 감리사례 버튼/닫기 등으로 인한 리런(q 없음): 직전 턴 전체(근거카드·인용·해설·사이드카)를
|
| 332 |
+
# 세션 상태에서 재렌더 — highlight_citations의 앵커 링크(조항 클릭→근거카드 스크롤)가
|
| 333 |
+
# 리런 후에도 계속 동작해야 하므로 평문(messages)이 아니라 last_turn을 그대로 재생.
|
| 334 |
+
last_turn = st.session_state.get("last_turn")
|
| 335 |
+
if last_turn:
|
| 336 |
+
with st.chat_message("assistant"):
|
| 337 |
+
render_turn_result(last_turn)
|
| 338 |
return
|
| 339 |
with st.chat_message("user"):
|
| 340 |
st.markdown(q)
|
|
|
|
| 396 |
valid = {h["ref_key"] or h["doc_no"] for h in final.get("retrieved", [])}
|
| 397 |
used = set(ans.get("used_refs", []))
|
| 398 |
anchors = {r: anchor_id(r) for r in used} # 인용 배지 → 근거 카드 스크롤
|
| 399 |
+
# ev_box/ans_box(교체형 st.empty)를 비워, 아래 render_turn_result가 그리는 최종본과
|
| 400 |
+
# 중복 렌더되지 않게 한다(스트리밍 중 임시 렌더 제거).
|
| 401 |
+
ev_box.empty()
|
| 402 |
+
ans_box.empty()
|
| 403 |
+
st.session_state.last_turn = {
|
| 404 |
+
"retrieved": final.get("retrieved", []), "used": used, "anchors": anchors,
|
| 405 |
+
"answer_html": highlight_citations(ans.get("answer", ""), valid, anchors),
|
| 406 |
+
"has_grounds": ans.get("has_grounds"), "used_refs": ans.get("used_refs", []),
|
| 407 |
+
"audit_cases": final.get("audit_cases", []) or [],
|
| 408 |
+
}
|
| 409 |
+
st.session_state.audit_panel_open = False # 새 답변은 접힌 상태(버튼)로 시작
|
| 410 |
+
# 3층-1~3, 2.5: 근거카드(★+앵커)·클릭 가능한 인용·감리사례·해설 (q 없는 리런에서도
|
| 411 |
+
# render_turn_result(last_turn)으로 동일하게 재생돼 인용 앵커 링크가 유지된다)
|
| 412 |
+
render_turn_result(st.session_state.last_turn)
|
| 413 |
+
|
| 414 |
+
# 3층-4(선택): 실시간 품질 평가 — 토글 on일 때만, 신선한 답변 직후 1회만 호출(재실행 방지)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
render_eval(eval_cfg, q, ans, final.get("retrieved", []))
|
| 416 |
|
| 417 |
st.session_state.messages.append(
|
rag/graph.py
CHANGED
|
@@ -214,8 +214,12 @@ class Pipeline:
|
|
| 214 |
cases = []
|
| 215 |
if "audit_cases" in self.index.colls:
|
| 216 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
hits = self.index.retrieve_routed(state["rewritten"], ["audit_cases"],
|
| 218 |
-
k=3, min_standards=0, per_coll=
|
| 219 |
cases = [_audit_card(h) for h in _audit_filter(hits)]
|
| 220 |
except Exception: # noqa: BLE001 — 사이드카 실패는 답변에 영향 없이 조용히 무시
|
| 221 |
cases = []
|
|
|
|
| 214 |
cases = []
|
| 215 |
if "audit_cases" in self.index.colls:
|
| 216 |
try:
|
| 217 |
+
# per_coll=20(코드베이스 관행값): 65건 전체(구버전 per_coll=65)를 매 질문마다
|
| 218 |
+
# 리랭킹하면 12초대 지연 유발(실측). dense 사전필터 20위 안에 진짜 관련
|
| 219 |
+
# 사례가 안정적으로 들어옴(스모크테스트 자기회수 점수 0.86~1.0로 확인) →
|
| 220 |
+
# 후보를 줄여도 recall 손실 거의 없이 리랭킹 비용만 3배 이상 절감.
|
| 221 |
hits = self.index.retrieve_routed(state["rewritten"], ["audit_cases"],
|
| 222 |
+
k=3, min_standards=0, per_coll=20)
|
| 223 |
cases = [_audit_card(h) for h in _audit_filter(hits)]
|
| 224 |
except Exception: # noqa: BLE001 — 사이드카 실패는 답변에 영향 없이 조용히 무시
|
| 225 |
cases = []
|