fix: cross-source reranker → RetrievalResult.excerpts (UI 정렬 일치)
Browse files- pipeline.build_context: ranker.rank 후 cross-source rerank 적용
- context_builder: rerank 호출 분리, build()는 슬라이스만 (NameError 버그 동시 수정)
- ui/gradio + server: 3-tier 정렬 제거 → reranker 적합도 순으로 카드 표시
- server: constitutional/oldnew/article_history 라벨·배경색 누락 보강
- src/kpaa/pipeline.py +6 -2
- src/kpaa/retrieval/context_builder.py +41 -35
- src/kpaa/server.py +27 -15
- src/kpaa/ui/gradio.py +13 -23
src/kpaa/pipeline.py
CHANGED
|
@@ -78,10 +78,14 @@ async def build_context(
|
|
| 78 |
)
|
| 79 |
raw = await retrieve(plan, client=client, on_progress=on_progress)
|
| 80 |
ranked = ranker.rank(raw)
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
return RetrievalResult(
|
| 83 |
plan=plan,
|
| 84 |
-
excerpts=
|
| 85 |
context_block=block,
|
| 86 |
elapsed_ms=int((time.monotonic() - t0) * 1000),
|
| 87 |
)
|
|
|
|
| 78 |
)
|
| 79 |
raw = await retrieve(plan, client=client, on_progress=on_progress)
|
| 80 |
ranked = ranker.rank(raw)
|
| 81 |
+
# Cross-source reranker 를 *전체 excerpts* 에 적용해 source 무관 적합도 순으로
|
| 82 |
+
# 재정렬. RetrievalResult.excerpts 가 이 순서를 그대로 가져 LLM 컨텍스트
|
| 83 |
+
# ([근거1]..[근거N]) 와 UI references 패널 모두 reranker 신호로 정렬됨.
|
| 84 |
+
reranked = context_builder.cross_source_rerank(plan.query, ranked, top_k=None)
|
| 85 |
+
block = context_builder.build(reranked, query=plan.query)
|
| 86 |
return RetrievalResult(
|
| 87 |
plan=plan,
|
| 88 |
+
excerpts=reranked,
|
| 89 |
context_block=block,
|
| 90 |
elapsed_ms=int((time.monotonic() - t0) * 1000),
|
| 91 |
)
|
src/kpaa/retrieval/context_builder.py
CHANGED
|
@@ -5,20 +5,15 @@
|
|
| 5 |
|
| 6 |
토큰 예산은 글자수로 근사 (한국어 1글자 ≈ 1.5~2 토큰).
|
| 7 |
|
| 8 |
-
KPAA v2.
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
나
|
| 16 |
-
|
| 17 |
-
- **다양화 픽 (round-robin by source_type)**: 단순 상위 N 자르기는 ranker
|
| 18 |
-
priority 가 같은 type (예: 법조문 3건 연속) 이 7건을 독점
|
| 19 |
-
가능 → 7건이어도 다양성 부족. 그래서 build() 가
|
| 20 |
-
source_type 별 큐를 만들어 라운드로빈으로 N개 채움. 같은
|
| 21 |
-
타입 안에선 ranker 순서 유지.
|
| 22 |
- DEFAULT_MAX_CHARS=8_000: 7건 × 평균 1000자 ≈ 7K자 (≈ 3,500 토큰).
|
| 23 |
excerpt 가 길면 cap 에 닿아 마지막 항목 절단.
|
| 24 |
"""
|
|
@@ -98,23 +93,29 @@ def _rerank_text(e: Excerpt) -> str:
|
|
| 98 |
return f"{title}\n{body}" if title else body
|
| 99 |
|
| 100 |
|
| 101 |
-
def
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
이전 버전(2026-05-05까지)에는 `_diversified_pick` 라운드로빈으로 source_type
|
| 105 |
다양성을 강제했으나, cross-encoder 도입 후엔 source 무관 적합도가 가장 신뢰할
|
| 106 |
만한 신호 — 정답이 한 source 에 집중돼 있어도 reranker 가 옳게 골라내고,
|
| 107 |
실제로 다양한 source 가 적합하면 자연스럽게 섞임.
|
| 108 |
-
|
| 109 |
-
Reranker 미설치/disabled 또는 query 없으면 입력 순서 그대로 슬라이스 (이때는
|
| 110 |
-
ranker.rank 의 sort_priority 순).
|
| 111 |
"""
|
| 112 |
-
if not excerpts
|
| 113 |
-
return
|
| 114 |
-
if len(excerpts)
|
| 115 |
-
|
|
|
|
| 116 |
if not query:
|
| 117 |
-
return excerpts[:k]
|
| 118 |
try:
|
| 119 |
from kpaa.retrieval.reranker import Reranker
|
| 120 |
|
|
@@ -122,35 +123,40 @@ def _cross_source_rerank(query: str, excerpts: list[Excerpt], *, k: int) -> list
|
|
| 122 |
except Exception: # noqa: BLE001
|
| 123 |
rr = None
|
| 124 |
if rr is None:
|
| 125 |
-
return excerpts[:k]
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
|
| 129 |
def build(
|
| 130 |
excerpts: list[Excerpt],
|
| 131 |
*,
|
| 132 |
-
query: str | None = None,
|
| 133 |
max_chars: int = DEFAULT_MAX_CHARS,
|
| 134 |
max_excerpts: int | None = DEFAULT_MAX_EXCERPTS,
|
| 135 |
) -> str:
|
| 136 |
"""`[근거1] ... [근거2] ...` 형태의 단일 문자열 반환.
|
| 137 |
|
| 138 |
Args:
|
| 139 |
-
excerpts:
|
| 140 |
-
query:
|
| 141 |
max_chars: 최종 블록 글자 수 cap. 초과 시 마지막 항목 절단.
|
| 142 |
max_excerpts: LLM 에 전달할 상위 N건 cap. None 이면 무제한 (전체 사용).
|
| 143 |
기본 7 — 답변 LLM prefill 토큰을 줄여 응답 속도 ↑.
|
| 144 |
-
|
| 145 |
-
을 골라냄. 라운드로빈 다양화 X (reranker 신호 우선).
|
| 146 |
-
retrieval result 의 `excerpts` 자체는 안 건드리므로 UI
|
| 147 |
-
references 패널엔 전체가 그대로 노출됨.
|
| 148 |
"""
|
| 149 |
if not excerpts:
|
| 150 |
return "(검색된 근거가 없습니다.)"
|
| 151 |
|
| 152 |
if max_excerpts is not None:
|
| 153 |
-
excerpts =
|
| 154 |
|
| 155 |
blocks: list[str] = []
|
| 156 |
used = 0
|
|
@@ -176,4 +182,4 @@ def build(
|
|
| 176 |
return "\n\n".join(blocks)
|
| 177 |
|
| 178 |
|
| 179 |
-
__all__ = ["build", "DEFAULT_MAX_CHARS", "DEFAULT_MAX_EXCERPTS"]
|
|
|
|
| 5 |
|
| 6 |
토큰 예산은 글자수로 근사 (한국어 1글자 ≈ 1.5~2 토큰).
|
| 7 |
|
| 8 |
+
KPAA v2.5 — cross-source reranker 는 pipeline 단계에서 *전체 excerpts* 에
|
| 9 |
+
적용되어 `RetrievalResult.excerpts` 자체가 reranker 적합도 순. build() 는
|
| 10 |
+
단순히 상위 N건을 슬라이스해 LLM 컨텍스트로 만든다 (재정렬·라운드로빈 ✗).
|
| 11 |
+
UI references 패널도 `RetrievalResult.excerpts` 그대로 표시 → 답변·UI 양쪽
|
| 12 |
+
모두 reranker 신호 우선.
|
| 13 |
+
|
| 14 |
+
- max_excerpts=7: 상위 7건을 LLM 에 전달. 7→3 큐레이션 — 5건 cap 에서
|
| 15 |
+
빠져나간 좋은 후보까지 LLM 판단 풀에 들어오면서, 8건 대비
|
| 16 |
+
prefill 토큰은 절감.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
- DEFAULT_MAX_CHARS=8_000: 7건 × 평균 1000자 ≈ 7K자 (≈ 3,500 토큰).
|
| 18 |
excerpt 가 길면 cap 에 닿아 마지막 항목 절단.
|
| 19 |
"""
|
|
|
|
| 93 |
return f"{title}\n{body}" if title else body
|
| 94 |
|
| 95 |
|
| 96 |
+
def cross_source_rerank(
|
| 97 |
+
query: str, excerpts: list[Excerpt], *, top_k: int | None = None
|
| 98 |
+
) -> list[Excerpt]:
|
| 99 |
+
"""Cross-encoder reranker 로 source 무관 적합도 순 정렬.
|
| 100 |
+
|
| 101 |
+
`top_k=None` (기본): 전체를 reranker 점수 순으로 *재정렬*만 (cap ✗).
|
| 102 |
+
`top_k=N`: 상위 N건만.
|
| 103 |
+
|
| 104 |
+
Reranker 미설치/disabled 또는 query 없으면 입력 순서 그대로 (이때는
|
| 105 |
+
ranker.rank 의 sort_priority 순).
|
| 106 |
|
| 107 |
이전 버전(2026-05-05까지)에는 `_diversified_pick` 라운드로빈으로 source_type
|
| 108 |
다양성을 강제했으나, cross-encoder 도입 후엔 source 무관 적합도가 가장 신뢰할
|
| 109 |
만한 신호 — 정답이 한 source 에 집중돼 있어도 reranker 가 옳게 골라내고,
|
| 110 |
실제로 다양한 source 가 적합하면 자연스럽게 섞임.
|
|
|
|
|
|
|
|
|
|
| 111 |
"""
|
| 112 |
+
if not excerpts:
|
| 113 |
+
return []
|
| 114 |
+
k = top_k if top_k is not None else len(excerpts)
|
| 115 |
+
if k <= 0:
|
| 116 |
+
return []
|
| 117 |
if not query:
|
| 118 |
+
return list(excerpts[:k])
|
| 119 |
try:
|
| 120 |
from kpaa.retrieval.reranker import Reranker
|
| 121 |
|
|
|
|
| 123 |
except Exception: # noqa: BLE001
|
| 124 |
rr = None
|
| 125 |
if rr is None:
|
| 126 |
+
return list(excerpts[:k])
|
| 127 |
+
# Reranker.rerank 는 len<=top_k 일 때 short-circuit 으로 입력 순서를 반환하므로,
|
| 128 |
+
# *전체 재정렬* 이 필요한 본 함수에선 직접 score+sort.
|
| 129 |
+
try:
|
| 130 |
+
pairs = [(query, _rerank_text(e)) for e in excerpts]
|
| 131 |
+
scores = rr.model.predict(pairs, show_progress_bar=False)
|
| 132 |
+
ranked = sorted(zip(excerpts, scores), key=lambda x: -float(x[1]))
|
| 133 |
+
return [e for e, _ in ranked[:k]]
|
| 134 |
+
except Exception: # noqa: BLE001
|
| 135 |
+
return list(excerpts[:k])
|
| 136 |
|
| 137 |
|
| 138 |
def build(
|
| 139 |
excerpts: list[Excerpt],
|
| 140 |
*,
|
| 141 |
+
query: str | None = None, # noqa: ARG001 — 호환용. rerank 는 pipeline 단계로 이동됨.
|
| 142 |
max_chars: int = DEFAULT_MAX_CHARS,
|
| 143 |
max_excerpts: int | None = DEFAULT_MAX_EXCERPTS,
|
| 144 |
) -> str:
|
| 145 |
"""`[근거1] ... [근거2] ...` 형태의 단일 문자열 반환.
|
| 146 |
|
| 147 |
Args:
|
| 148 |
+
excerpts: pipeline 에서 cross-source reranker 적용 후의 리스트 (적합도 순).
|
| 149 |
+
query: (deprecated) 호환 위해 남김. 실제 rerank 는 pipeline.build_context 가 수행.
|
| 150 |
max_chars: 최종 블록 글자 수 cap. 초과 시 마지막 항목 절단.
|
| 151 |
max_excerpts: LLM 에 전달할 상위 N건 cap. None 이면 무제한 (전체 사용).
|
| 152 |
기본 7 — 답변 LLM prefill 토큰을 줄여 응답 속도 ↑.
|
| 153 |
+
입력이 이미 reranker 적합도 순이므로 단순 슬라이스.
|
|
|
|
|
|
|
|
|
|
| 154 |
"""
|
| 155 |
if not excerpts:
|
| 156 |
return "(검색된 근거가 없습니다.)"
|
| 157 |
|
| 158 |
if max_excerpts is not None:
|
| 159 |
+
excerpts = excerpts[:max_excerpts]
|
| 160 |
|
| 161 |
blocks: list[str] = []
|
| 162 |
used = 0
|
|
|
|
| 182 |
return "\n\n".join(blocks)
|
| 183 |
|
| 184 |
|
| 185 |
+
__all__ = ["build", "cross_source_rerank", "DEFAULT_MAX_CHARS", "DEFAULT_MAX_EXCERPTS"]
|
src/kpaa/server.py
CHANGED
|
@@ -556,6 +556,19 @@ def create_app() -> FastAPI:
|
|
| 556 |
@app.get("/", response_class=HTMLResponse)
|
| 557 |
async def index() -> str:
|
| 558 |
# 루트 = Open WebUI + 참고자료 분할 화면. 백엔드 정보 페이지는 /info.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
return _SPLIT_HTML
|
| 560 |
|
| 561 |
@app.get("/info", response_class=HTMLResponse)
|
|
@@ -819,6 +832,9 @@ _SPLIT_HTML = """<!doctype html>
|
|
| 819 |
--interp-bg: #6633bb;
|
| 820 |
--prec-bg: #b03060;
|
| 821 |
--admin-bg: #555555;
|
|
|
|
|
|
|
|
|
|
| 822 |
}
|
| 823 |
/* ─ 다크 변수 (OS 자동 — 토글 없음). 컴포넌트 룰은 CSS 끝의 별도 @media 참고. */
|
| 824 |
@media (prefers-color-scheme: dark) {
|
|
@@ -842,6 +858,9 @@ _SPLIT_HTML = """<!doctype html>
|
|
| 842 |
--interp-bg: #8b5cf6;
|
| 843 |
--prec-bg: #db2777;
|
| 844 |
--admin-bg: #6b7280;
|
|
|
|
|
|
|
|
|
|
| 845 |
}
|
| 846 |
}
|
| 847 |
|
|
@@ -879,6 +898,9 @@ _SPLIT_HTML = """<!doctype html>
|
|
| 879 |
.badge.interpretation { background: var(--interp-bg); }
|
| 880 |
.badge.precedent { background: var(--prec-bg); }
|
| 881 |
.badge.admin_rule { background: var(--admin-bg); }
|
|
|
|
|
|
|
|
|
|
| 882 |
.ref .citation { font-weight: 600; font-size: 0.9em; color: var(--text); }
|
| 883 |
.ref .title { color: var(--muted); font-size: 0.86em; margin-bottom: 6px; }
|
| 884 |
.ref .content { font-size: 0.84em; line-height: 1.55; color: var(--text); background: var(--content-bg); padding: 8px 10px; border-radius: 6px; white-space: pre-wrap; max-height: 240px; overflow-y: auto; border: 1px solid var(--border-soft); }
|
|
@@ -924,7 +946,7 @@ _SPLIT_HTML = """<!doctype html>
|
|
| 924 |
// 테마는 OS prefers-color-scheme 자동 — 별도 토글 없음.
|
| 925 |
|
| 926 |
// ─ references polling ─
|
| 927 |
-
const LABEL = { case: "상담사례", guide: "안내서", law: "법조문", related_law: "관련 법령", pipc: "PIPC 결정", interpretation: "법령해석례", precedent: "판례", admin_rule: "행정규칙" };
|
| 928 |
const refsEl = document.getElementById("refs");
|
| 929 |
const refsCountEl = document.getElementById("refs-count");
|
| 930 |
const metaEl = document.getElementById("meta");
|
|
@@ -939,19 +961,9 @@ function render(payload) {
|
|
| 939 |
const geungeoSet = new Set(payload.geungeo_indices_in_answer || []); // 답변 본문 (근거N) 의 N
|
| 940 |
const llmCount = rawExcerpts.filter(e => llmSet.has((e.citation || "").trim())).length;
|
| 941 |
|
| 942 |
-
//
|
| 943 |
-
//
|
| 944 |
-
const
|
| 945 |
-
|
| 946 |
-
// 3-tier 정렬: LLM 명시 인용(근거N)(0) → LLM 전달(1) → 검색만(2). stable.
|
| 947 |
-
// (근거N) chip 카드를 위로 올려 사용자가 명시 인용된 카드 빨리 찾을 수 있게.
|
| 948 |
-
const tier = (e) => {
|
| 949 |
-
const passed = llmSet.has((e.citation || "").trim());
|
| 950 |
-
if (passed && geungeoSet.has(e._idx)) return 0;
|
| 951 |
-
if (passed) return 1;
|
| 952 |
-
return 2;
|
| 953 |
-
};
|
| 954 |
-
const excerpts = [...indexed].sort((a, b) => tier(a) - tier(b));
|
| 955 |
|
| 956 |
// 헤더 — "8건 · LLM 전달 7건"
|
| 957 |
if (rawExcerpts.length) {
|
|
@@ -1133,7 +1145,7 @@ _CHAT_HTML = """<!doctype html>
|
|
| 1133 |
</section>
|
| 1134 |
</div>
|
| 1135 |
<script>
|
| 1136 |
-
const LABEL = { case: "상담사례", guide: "안내서", law: "법조문", related_law: "관련 법령", pipc: "PIPC 결정", interpretation: "법령해석례", precedent: "판례", admin_rule: "행정규칙" };
|
| 1137 |
const messagesEl = document.getElementById("messages");
|
| 1138 |
const refsEl = document.getElementById("refs");
|
| 1139 |
const refsCountEl = document.getElementById("refs-count");
|
|
|
|
| 556 |
@app.get("/", response_class=HTMLResponse)
|
| 557 |
async def index() -> str:
|
| 558 |
# 루트 = Open WebUI + 참고자료 분할 화면. 백엔드 정보 페이지는 /info.
|
| 559 |
+
# 페이지 진입(리로드 포함) 시 우측 참고자료 서버 상태를 비움 — 이전 세션
|
| 560 |
+
# 잔여 _last_refs 가 폴링에 의해 즉시 렌더되는 것을 방지. HF 백엔드의
|
| 561 |
+
# _split_handler 와 동일 정책.
|
| 562 |
+
_last_refs.update({
|
| 563 |
+
"ts": time.time(),
|
| 564 |
+
"query": "",
|
| 565 |
+
"intents": [],
|
| 566 |
+
"jo_targets": [],
|
| 567 |
+
"elapsed_ms": 0,
|
| 568 |
+
"excerpts": [],
|
| 569 |
+
"llm_excerpt_citations": [],
|
| 570 |
+
"geungeo_indices_in_answer": [],
|
| 571 |
+
})
|
| 572 |
return _SPLIT_HTML
|
| 573 |
|
| 574 |
@app.get("/info", response_class=HTMLResponse)
|
|
|
|
| 832 |
--interp-bg: #6633bb;
|
| 833 |
--prec-bg: #b03060;
|
| 834 |
--admin-bg: #555555;
|
| 835 |
+
--const-bg: #8b1e3f; /* 헌재 — 짙은 와인색 */
|
| 836 |
+
--oldnew-bg: #4a5568; /* 구·신 비교 — 슬레이트 그레이 */
|
| 837 |
+
--history-bg: #6b4c00; /* 조문 변천 — 다크 골드 */
|
| 838 |
}
|
| 839 |
/* ─ 다크 변수 (OS 자동 — 토글 없음). 컴포넌트 룰은 CSS 끝의 별도 @media 참고. */
|
| 840 |
@media (prefers-color-scheme: dark) {
|
|
|
|
| 858 |
--interp-bg: #8b5cf6;
|
| 859 |
--prec-bg: #db2777;
|
| 860 |
--admin-bg: #6b7280;
|
| 861 |
+
--const-bg: #c2456a;
|
| 862 |
+
--oldnew-bg: #94a3b8;
|
| 863 |
+
--history-bg: #d4a017;
|
| 864 |
}
|
| 865 |
}
|
| 866 |
|
|
|
|
| 898 |
.badge.interpretation { background: var(--interp-bg); }
|
| 899 |
.badge.precedent { background: var(--prec-bg); }
|
| 900 |
.badge.admin_rule { background: var(--admin-bg); }
|
| 901 |
+
.badge.constitutional { background: var(--const-bg); }
|
| 902 |
+
.badge.oldnew { background: var(--oldnew-bg); }
|
| 903 |
+
.badge.article_history { background: var(--history-bg); }
|
| 904 |
.ref .citation { font-weight: 600; font-size: 0.9em; color: var(--text); }
|
| 905 |
.ref .title { color: var(--muted); font-size: 0.86em; margin-bottom: 6px; }
|
| 906 |
.ref .content { font-size: 0.84em; line-height: 1.55; color: var(--text); background: var(--content-bg); padding: 8px 10px; border-radius: 6px; white-space: pre-wrap; max-height: 240px; overflow-y: auto; border: 1px solid var(--border-soft); }
|
|
|
|
| 946 |
// 테마는 OS prefers-color-scheme 자동 — 별도 토글 없음.
|
| 947 |
|
| 948 |
// ─ references polling ─
|
| 949 |
+
const LABEL = { case: "상담사례", guide: "안내서", law: "법조문", related_law: "관련 법령", pipc: "PIPC 결정", interpretation: "법령해석례", precedent: "판례", admin_rule: "행정규칙", constitutional: "헌법재판소", oldnew: "구·신 비교", article_history: "조문 변천" };
|
| 950 |
const refsEl = document.getElementById("refs");
|
| 951 |
const refsCountEl = document.getElementById("refs-count");
|
| 952 |
const metaEl = document.getElementById("meta");
|
|
|
|
| 961 |
const geungeoSet = new Set(payload.geungeo_indices_in_answer || []); // 답변 본문 (근거N) 의 N
|
| 962 |
const llmCount = rawExcerpts.filter(e => llmSet.has((e.citation || "").trim())).length;
|
| 963 |
|
| 964 |
+
// pipeline 의 cross-source reranker 가 매긴 적합도 순 그대로 표시.
|
| 965 |
+
// 1-based 인덱스가 답변 본문 (근거N) 의 N 과 일치 (context_builder 가 같은 순서로 [근거N] 박음).
|
| 966 |
+
const excerpts = rawExcerpts.map((e, i) => ({ ...e, _idx: i + 1 }));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 967 |
|
| 968 |
// 헤더 — "8건 · LLM 전달 7건"
|
| 969 |
if (rawExcerpts.length) {
|
|
|
|
| 1145 |
</section>
|
| 1146 |
</div>
|
| 1147 |
<script>
|
| 1148 |
+
const LABEL = { case: "상담사례", guide: "안내서", law: "법조문", related_law: "관련 법령", pipc: "PIPC 결정", interpretation: "법령해석례", precedent: "판례", admin_rule: "행정규칙", constitutional: "헌법재판소", oldnew: "구·신 비교", article_history: "조문 변천" };
|
| 1149 |
const messagesEl = document.getElementById("messages");
|
| 1150 |
const refsEl = document.getElementById("refs");
|
| 1151 |
const refsCountEl = document.getElementById("refs-count");
|
src/kpaa/ui/gradio.py
CHANGED
|
@@ -60,6 +60,9 @@ _BADGE_COLOR = {
|
|
| 60 |
"interpretation": "#6633bb",
|
| 61 |
"precedent": "#b03060",
|
| 62 |
"admin_rule": "#555",
|
|
|
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
_EXAMPLE_QUESTIONS = [
|
|
@@ -79,16 +82,15 @@ def _render_references_html(
|
|
| 79 |
) -> str:
|
| 80 |
"""우측 패널 HTML 카드 묶음 — server.py 분할 화면과 동일 정책.
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
있으므로 별도 "채택" 강조 색상은 두지 않음.)
|
| 89 |
|
| 90 |
Args:
|
| 91 |
-
excerpts:
|
| 92 |
elapsed_ms: 검색 시간.
|
| 93 |
llm_passed_citations: LLM 입력으로 전달된 상위 N건 citation set.
|
| 94 |
geungeo_indices: 답변에 (근거N) 으로 등장한 N 들 (1-based).
|
|
@@ -104,20 +106,8 @@ def _render_references_html(
|
|
| 104 |
|
| 105 |
llm_count = sum(1 for e in excerpts if (e.citation or "").strip() in llm_set)
|
| 106 |
|
| 107 |
-
#
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
# 3-tier 정렬 (stable): LLM 명시 인용(근거N) → LLM 전달 → 나머지.
|
| 111 |
-
def _tier(item: tuple[int, Excerpt]) -> int:
|
| 112 |
-
idx, e = item
|
| 113 |
-
passed = (e.citation or "").strip() in llm_set
|
| 114 |
-
if passed and idx in geungeo_set:
|
| 115 |
-
return 0
|
| 116 |
-
if passed:
|
| 117 |
-
return 1
|
| 118 |
-
return 2
|
| 119 |
-
|
| 120 |
-
sorted_items = sorted(indexed, key=_tier)
|
| 121 |
|
| 122 |
# 헤더 요약
|
| 123 |
summary_parts = [f"총 {len(excerpts)}건 · 검색 {elapsed_ms}ms"]
|
|
@@ -130,7 +120,7 @@ def _render_references_html(
|
|
| 130 |
f'<div style="padding:8px 12px;color:#888;font-size:0.82em;">{summary}</div>'
|
| 131 |
]
|
| 132 |
|
| 133 |
-
for idx, e in
|
| 134 |
label = _SOURCE_LABEL.get(e.source_type, e.source_type)
|
| 135 |
color = _BADGE_COLOR.get(e.source_type, "#666")
|
| 136 |
url = (e.metadata or {}).get("url", "").strip()
|
|
|
|
| 60 |
"interpretation": "#6633bb",
|
| 61 |
"precedent": "#b03060",
|
| 62 |
"admin_rule": "#555",
|
| 63 |
+
"constitutional": "#8b1e3f",
|
| 64 |
+
"oldnew": "#4a5568",
|
| 65 |
+
"article_history": "#6b4c00",
|
| 66 |
}
|
| 67 |
|
| 68 |
_EXAMPLE_QUESTIONS = [
|
|
|
|
| 82 |
) -> str:
|
| 83 |
"""우측 패널 HTML 카드 묶음 — server.py 분할 화면과 동일 정책.
|
| 84 |
|
| 85 |
+
카드 순서: pipeline 의 cross-source reranker 가 매긴 적합도 순 (=
|
| 86 |
+
`excerpts` 입력 순서) 그대로. UI 단계에서 별도 정렬 ✗.
|
| 87 |
+
|
| 88 |
+
배지:
|
| 89 |
+
- "LLM 전달" (llm_passed_citations 매칭): 회색 좌측 표지 + 회색 배지.
|
| 90 |
+
- "근거N" (geungeo_indices 매칭): 답변 본문에 (근거N) 으로 명시 등장한 카드.
|
|
|
|
| 91 |
|
| 92 |
Args:
|
| 93 |
+
excerpts: pipeline reranker 적합도 순. 1-based 위치 = (근거N) N.
|
| 94 |
elapsed_ms: 검색 시간.
|
| 95 |
llm_passed_citations: LLM 입력으로 전달된 상위 N건 citation set.
|
| 96 |
geungeo_indices: 답변에 (근거N) 으로 등장한 N 들 (1-based).
|
|
|
|
| 106 |
|
| 107 |
llm_count = sum(1 for e in excerpts if (e.citation or "").strip() in llm_set)
|
| 108 |
|
| 109 |
+
# reranker 가 매긴 순서를 그대로 표시. 1-based 인덱스가 (근거N) 의 N 과 일치.
|
| 110 |
+
indexed_items: list[tuple[int, Excerpt]] = list(enumerate(excerpts, 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
# 헤더 요약
|
| 113 |
summary_parts = [f"총 {len(excerpts)}건 · 검색 {elapsed_ms}ms"]
|
|
|
|
| 120 |
f'<div style="padding:8px 12px;color:#888;font-size:0.82em;">{summary}</div>'
|
| 121 |
]
|
| 122 |
|
| 123 |
+
for idx, e in indexed_items:
|
| 124 |
label = _SOURCE_LABEL.get(e.source_type, e.source_type)
|
| 125 |
color = _BADGE_COLOR.get(e.source_type, "#666")
|
| 126 |
url = (e.metadata or {}).get("url", "").strip()
|