ui: simplify references panel — drop 'adopted' tier, keep 'LLM 전달' + (근거N) chip
Browse filesTop-N excerpts are all LLM context candidates; semantic adoption tracking
is unreliable on small models. So:
- Remove the green 'cited' tier (was over-claiming since paraphrased uses had no green)
- Keep gray 'LLM 전달' for all top-N (true signal — model saw these)
- Keep '근거N' chip only on cards explicitly tagged in answer body (verifiable signal)
- Drop cited_citations from /api/last-references payload + clean unused CSS
- src/kpaa/server.py +32 -70
- src/kpaa/ui/gradio.py +25 -71
src/kpaa/server.py
CHANGED
|
@@ -95,10 +95,10 @@ def _excerpt_to_dict(e: Excerpt) -> dict[str, Any]:
|
|
| 95 |
|
| 96 |
# 모듈 레벨 single-user 캐시 — `/v1/chat/completions` 호출 (Open WebUI에서 들어옴)
|
| 97 |
# 시 retrieval 결과를 저장 → /api/last-references 에서 polling, `/` (분할) UI에서
|
| 98 |
-
# 우측 패널이 갱신.
|
| 99 |
-
#
|
| 100 |
-
#
|
| 101 |
-
#
|
| 102 |
_last_refs: dict[str, Any] = {
|
| 103 |
"ts": 0.0,
|
| 104 |
"query": "",
|
|
@@ -106,35 +106,24 @@ _last_refs: dict[str, Any] = {
|
|
| 106 |
"jo_targets": [],
|
| 107 |
"elapsed_ms": 0,
|
| 108 |
"excerpts": [],
|
| 109 |
-
"cited_citations": [],
|
| 110 |
"llm_excerpt_citations": [],
|
| 111 |
-
# 답변 본문에서 추출된 (근거N) 의 N 들 — UI 가 카드 배지에 [근거N] 표시.
|
| 112 |
"geungeo_indices_in_answer": [],
|
| 113 |
}
|
| 114 |
|
| 115 |
|
| 116 |
from kpaa.retrieval.citation_match import (
|
| 117 |
-
compute_cited as _compute_cited_helper,
|
| 118 |
extract_geungeo_indices as _extract_geungeo_indices,
|
| 119 |
)
|
| 120 |
|
| 121 |
|
| 122 |
-
def
|
| 123 |
-
"""
|
| 124 |
-
return _compute_cited_helper(answer, [e.citation for e in excerpts])
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
polling 측 (1초 주기) 이 ts 변경을 감지해 다시 그림. 매칭 정책은
|
| 131 |
-
`kpaa.retrieval.citation_match` 의 공통 헬퍼 사용 — Gradio UI 와 동일.
|
| 132 |
"""
|
| 133 |
-
excerpts_dicts = _last_refs.get("excerpts") or []
|
| 134 |
-
citations = [str(ed.get("citation") or "") for ed in excerpts_dicts]
|
| 135 |
-
cited = _compute_cited_helper(answer, citations)
|
| 136 |
-
_last_refs["cited_citations"] = cited
|
| 137 |
-
# UI 카드에 [근거N] 표시용 — answer 에 실제 등장한 N 만.
|
| 138 |
_last_refs["geungeo_indices_in_answer"] = sorted(_extract_geungeo_indices(answer))
|
| 139 |
_last_refs["ts"] = time.time()
|
| 140 |
|
|
@@ -175,11 +164,11 @@ def _update_last_refs(query: str, retrieval_result) -> None:
|
|
| 175 |
_last_refs["elapsed_ms"] = retrieval_result.elapsed_ms
|
| 176 |
_last_refs["excerpts"] = [_excerpt_to_dict(e) for e in retrieval_result.excerpts]
|
| 177 |
# 상위 N건이 LLM 입력으로 — context_builder.build() 의 cap 과 동일 정책.
|
|
|
|
| 178 |
_last_refs["llm_excerpt_citations"] = [
|
| 179 |
e.citation for e in retrieval_result.excerpts[:DEFAULT_MAX_EXCERPTS]
|
| 180 |
]
|
| 181 |
-
#
|
| 182 |
-
_last_refs["cited_citations"] = []
|
| 183 |
_last_refs["geungeo_indices_in_answer"] = []
|
| 184 |
|
| 185 |
|
|
@@ -528,9 +517,9 @@ async def _stream_chat(
|
|
| 528 |
yield _sse(_delta(evt["delta"]))
|
| 529 |
elif evt["event"] == "done":
|
| 530 |
finish_reason = "stop"
|
| 531 |
-
# 답변 완료 —
|
| 532 |
if not _is_meta_query(query):
|
| 533 |
-
|
| 534 |
finally:
|
| 535 |
if ticker and not ticker.done():
|
| 536 |
ticker.cancel()
|
|
@@ -654,7 +643,6 @@ def create_app() -> FastAPI:
|
|
| 654 |
"jo_targets": [],
|
| 655 |
"elapsed_ms": 0,
|
| 656 |
"excerpts": [],
|
| 657 |
-
"cited_citations": [],
|
| 658 |
"llm_excerpt_citations": [],
|
| 659 |
"geungeo_indices_in_answer": [],
|
| 660 |
})
|
|
@@ -686,9 +674,9 @@ def create_app() -> FastAPI:
|
|
| 686 |
elif evt["event"] == "done":
|
| 687 |
final_answer = evt["answer"]
|
| 688 |
text = final_answer if final_answer is not None else "".join(chunks)
|
| 689 |
-
# 비스트리밍 분기도 답변 완료 후
|
| 690 |
if text and not _is_meta_query(query):
|
| 691 |
-
|
| 692 |
return ChatResponse(
|
| 693 |
id=_new_id(),
|
| 694 |
created=int(time.time()),
|
|
@@ -748,7 +736,6 @@ def create_app() -> FastAPI:
|
|
| 748 |
"jo_targets": [],
|
| 749 |
"elapsed_ms": 0,
|
| 750 |
"excerpts": [],
|
| 751 |
-
"cited_citations": [],
|
| 752 |
"llm_excerpt_citations": [],
|
| 753 |
"geungeo_indices_in_answer": [],
|
| 754 |
})
|
|
@@ -875,18 +862,14 @@ _SPLIT_HTML = """<!doctype html>
|
|
| 875 |
.refs-empty { color: var(--muted); padding: 24px; text-align: center; font-size: 0.9em; }
|
| 876 |
|
| 877 |
.ref { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; transition: all .2s; }
|
| 878 |
-
/* LLM 에 전달된 후보 (상위 N건) — 옅은 좌측 회색 표지 *
|
|
|
|
| 879 |
.ref.llm-passed { border-left: 4px solid #9aa0a6; }
|
| 880 |
-
/* 답변에 채택된 카드 — 좌측 두꺼운 녹색 border + 옅은 녹색 배경 (.cited 가 .llm-passed 보다 우선) */
|
| 881 |
-
.ref.cited { border-color: #15833a; border-left: 4px solid #15833a; background: #f3fbf5; }
|
| 882 |
.ref .head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
|
| 883 |
.ref .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 0.72em; font-weight: 600; color: #fff; }
|
| 884 |
-
.badge.cited-flag { background: #15833a; }
|
| 885 |
.badge.llm-flag { background: #9aa0a6; }
|
| 886 |
-
/* "근거N"
|
| 887 |
-
.badge.
|
| 888 |
-
.badge.llm-idx { background: #f1f3f4; color: #5f6368; border: 1px solid #9aa0a6; }
|
| 889 |
-
.cited-summary { color: #15833a; font-weight: 600; }
|
| 890 |
.llm-summary { color: #6c727b; }
|
| 891 |
.badge.case { background: var(--case-bg); }
|
| 892 |
.badge.law { background: var(--law-bg); }
|
|
@@ -907,12 +890,8 @@ _SPLIT_HTML = """<!doctype html>
|
|
| 907 |
/* ─ 다크 컴포넌트 색 (라이트 룰들 *뒤* 에 두어야 source order 우선으로 적용) ─ */
|
| 908 |
@media (prefers-color-scheme: dark) {
|
| 909 |
.ref.llm-passed { border-left-color: #6b7280; }
|
| 910 |
-
.ref.cited { background: #11261a; border-color: #4ade80; border-left-color: #4ade80; }
|
| 911 |
-
.badge.cited-flag { background: #16a34a; }
|
| 912 |
.badge.llm-flag { background: #6b7280; }
|
| 913 |
-
.badge.
|
| 914 |
-
.badge.llm-idx { background: #2a2a2a; color: #c4c7c5; border-color: #6b7280; }
|
| 915 |
-
.cited-summary { color: #4ade80; }
|
| 916 |
.llm-summary { color: #9aa0a6; }
|
| 917 |
}
|
| 918 |
|
|
@@ -960,34 +939,24 @@ function escapeHtml(s) {
|
|
| 960 |
|
| 961 |
function render(payload) {
|
| 962 |
const rawExcerpts = payload.excerpts || [];
|
| 963 |
-
const citedSet = new Set(payload.cited_citations || []);
|
| 964 |
const llmSet = new Set(payload.llm_excerpt_citations || []);
|
| 965 |
-
const geungeoSet = new Set(payload.geungeo_indices_in_answer || []); // 답변
|
| 966 |
-
const citedCount = rawExcerpts.filter(e => citedSet.has((e.citation || "").trim())).length;
|
| 967 |
const llmCount = rawExcerpts.filter(e => llmSet.has((e.citation || "").trim())).length;
|
| 968 |
|
| 969 |
// 카드별 *원본 LLM 입력 순서* (1-based) 보존 — 정렬 후에도 유지되도록 미리 부여.
|
| 970 |
// 이 순서가 답변 본문의 (근거N) 의 N 과 일치 (context_builder 가 같은 순서로 [근거N] 박음).
|
| 971 |
const indexed = rawExcerpts.map((e, i) => ({ ...e, _idx: i + 1 }));
|
| 972 |
|
| 973 |
-
//
|
| 974 |
-
const tier = (e) =>
|
| 975 |
-
const c = (e.citation || "").trim();
|
| 976 |
-
if (citedSet.has(c)) return 0;
|
| 977 |
-
if (llmSet.has(c)) return 1;
|
| 978 |
-
return 2;
|
| 979 |
-
};
|
| 980 |
const excerpts = [...indexed].sort((a, b) => tier(a) - tier(b));
|
| 981 |
|
| 982 |
-
// 헤더 — "
|
| 983 |
if (rawExcerpts.length) {
|
| 984 |
const parts = [`${rawExcerpts.length}건`];
|
| 985 |
if (llmCount) {
|
| 986 |
parts.push(`<span class="llm-summary">LLM 전달 ${llmCount}건</span>`);
|
| 987 |
}
|
| 988 |
-
if (citedCount) {
|
| 989 |
-
parts.push(`<span class="cited-summary">답변에 채택 ${citedCount}건</span>`);
|
| 990 |
-
}
|
| 991 |
refsCountEl.innerHTML = parts.join(" · ");
|
| 992 |
} else {
|
| 993 |
refsCountEl.textContent = "";
|
|
@@ -1004,27 +973,20 @@ function render(payload) {
|
|
| 1004 |
for (const e of excerpts) {
|
| 1005 |
const card = document.createElement("div");
|
| 1006 |
const cit = (e.citation || "").trim();
|
| 1007 |
-
const isCited = citedSet.has(cit);
|
| 1008 |
const isLlmPassed = llmSet.has(cit);
|
| 1009 |
-
|
| 1010 |
-
card.className = "ref" + (isCited ? " cited" : (isLlmPassed ? " llm-passed" : ""));
|
| 1011 |
const label = LABEL[e.source_type] || e.source_type;
|
| 1012 |
const link = e.url
|
| 1013 |
? `<a class="orig" href="${escapeHtml(e.url)}" target="_blank" rel="noopener noreferrer">원문 페이지 열기 ↗</a>`
|
| 1014 |
: `<span class="nolink">원문 페이지 미제공 — 위 본문을 LLM이 직접 참조</span>`;
|
| 1015 |
-
// 답변 본문에 (근거N) 으로 등장한 카드만
|
| 1016 |
const showIdx = geungeoSet.has(e._idx);
|
| 1017 |
let stateBadge = "";
|
| 1018 |
-
if (
|
| 1019 |
-
stateBadge = `<span class="badge
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
}
|
| 1023 |
-
} else if (isLlmPassed) {
|
| 1024 |
-
stateBadge = `<span class="badge llm-flag" title="LLM 입력으로 전달됨">LLM 전달</span>`;
|
| 1025 |
-
if (showIdx) {
|
| 1026 |
-
stateBadge += `<span class="badge llm-idx" title="답변의 (근거${e._idx}) 표기와 매칭">근거${e._idx}</span>`;
|
| 1027 |
-
}
|
| 1028 |
}
|
| 1029 |
card.innerHTML = `
|
| 1030 |
<div class="head">
|
|
|
|
| 95 |
|
| 96 |
# 모듈 레벨 single-user 캐시 — `/v1/chat/completions` 호출 (Open WebUI에서 들어옴)
|
| 97 |
# 시 retrieval 결과를 저장 → /api/last-references 에서 polling, `/` (분할) UI에서
|
| 98 |
+
# 우측 패널이 갱신. llm_excerpt_citations 는 retrieval 시점에 상위
|
| 99 |
+
# DEFAULT_MAX_EXCERPTS 건의 citation — *그 7건 모두* LLM 이 컨텍스트로 참고한
|
| 100 |
+
# 후보. geungeo_indices_in_answer 는 답변 본문에 LLM 이 명시적으로 (근거N) 으로
|
| 101 |
+
# 적은 N 들 — UI 카드의 "근거N" chip 표시에만 사용. 멀티유저 가정 안 함.
|
| 102 |
_last_refs: dict[str, Any] = {
|
| 103 |
"ts": 0.0,
|
| 104 |
"query": "",
|
|
|
|
| 106 |
"jo_targets": [],
|
| 107 |
"elapsed_ms": 0,
|
| 108 |
"excerpts": [],
|
|
|
|
| 109 |
"llm_excerpt_citations": [],
|
|
|
|
| 110 |
"geungeo_indices_in_answer": [],
|
| 111 |
}
|
| 112 |
|
| 113 |
|
| 114 |
from kpaa.retrieval.citation_match import (
|
|
|
|
| 115 |
extract_geungeo_indices as _extract_geungeo_indices,
|
| 116 |
)
|
| 117 |
|
| 118 |
|
| 119 |
+
def _mark_geungeo(answer: str) -> None:
|
| 120 |
+
"""답변 완료 후 _last_refs 의 geungeo_indices_in_answer + ts 갱신.
|
|
|
|
| 121 |
|
| 122 |
+
답변 본문에 LLM 이 명시적으로 적은 (근거N) 의 N 만 추출 — UI 카드에 "근거N"
|
| 123 |
+
chip 표시용. polling 측 (1초 주기) 이 ts 변경을 감지해 다시 그림.
|
| 124 |
+
의미 매칭 (paraphrase 추적) 은 하지 않음 — top-N 모두 LLM 이 본 후보이고
|
| 125 |
+
그 외엔 *명시 인용* 만 신뢰 가능한 신호이기 때문.
|
|
|
|
|
|
|
| 126 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
_last_refs["geungeo_indices_in_answer"] = sorted(_extract_geungeo_indices(answer))
|
| 128 |
_last_refs["ts"] = time.time()
|
| 129 |
|
|
|
|
| 164 |
_last_refs["elapsed_ms"] = retrieval_result.elapsed_ms
|
| 165 |
_last_refs["excerpts"] = [_excerpt_to_dict(e) for e in retrieval_result.excerpts]
|
| 166 |
# 상위 N건이 LLM 입력으로 — context_builder.build() 의 cap 과 동일 정책.
|
| 167 |
+
# 이 N건 *전부* 가 LLM 이 컨텍스트로 본 후보. 별도 "채택" 표기 없음.
|
| 168 |
_last_refs["llm_excerpt_citations"] = [
|
| 169 |
e.citation for e in retrieval_result.excerpts[:DEFAULT_MAX_EXCERPTS]
|
| 170 |
]
|
| 171 |
+
# 답변 완료 시점에 _mark_geungeo 가 (근거N) 추출해 채움.
|
|
|
|
| 172 |
_last_refs["geungeo_indices_in_answer"] = []
|
| 173 |
|
| 174 |
|
|
|
|
| 517 |
yield _sse(_delta(evt["delta"]))
|
| 518 |
elif evt["event"] == "done":
|
| 519 |
finish_reason = "stop"
|
| 520 |
+
# 답변 완료 — (근거N) chip 인덱스만 추출해서 우측 패널 polling 갱신.
|
| 521 |
if not _is_meta_query(query):
|
| 522 |
+
_mark_geungeo(evt.get("answer") or "")
|
| 523 |
finally:
|
| 524 |
if ticker and not ticker.done():
|
| 525 |
ticker.cancel()
|
|
|
|
| 643 |
"jo_targets": [],
|
| 644 |
"elapsed_ms": 0,
|
| 645 |
"excerpts": [],
|
|
|
|
| 646 |
"llm_excerpt_citations": [],
|
| 647 |
"geungeo_indices_in_answer": [],
|
| 648 |
})
|
|
|
|
| 674 |
elif evt["event"] == "done":
|
| 675 |
final_answer = evt["answer"]
|
| 676 |
text = final_answer if final_answer is not None else "".join(chunks)
|
| 677 |
+
# 비스트리밍 분기도 답변 완료 후 (근거N) chip 갱신.
|
| 678 |
if text and not _is_meta_query(query):
|
| 679 |
+
_mark_geungeo(text)
|
| 680 |
return ChatResponse(
|
| 681 |
id=_new_id(),
|
| 682 |
created=int(time.time()),
|
|
|
|
| 736 |
"jo_targets": [],
|
| 737 |
"elapsed_ms": 0,
|
| 738 |
"excerpts": [],
|
|
|
|
| 739 |
"llm_excerpt_citations": [],
|
| 740 |
"geungeo_indices_in_answer": [],
|
| 741 |
})
|
|
|
|
| 862 |
.refs-empty { color: var(--muted); padding: 24px; text-align: center; font-size: 0.9em; }
|
| 863 |
|
| 864 |
.ref { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; transition: all .2s; }
|
| 865 |
+
/* LLM 에 전달된 후보 (상위 N건) — 옅은 좌측 회색 표지. *그 N건 모두* 가
|
| 866 |
+
LLM 이 본 후보. 의미 매칭(paraphrase) 추적은 안 하므로 "채택" tier 없음. */
|
| 867 |
.ref.llm-passed { border-left: 4px solid #9aa0a6; }
|
|
|
|
|
|
|
| 868 |
.ref .head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
|
| 869 |
.ref .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 0.72em; font-weight: 600; color: #fff; }
|
|
|
|
| 870 |
.badge.llm-flag { background: #9aa0a6; }
|
| 871 |
+
/* "근거N" chip — 답변 본문에 (근거N) 으로 *명시* 등장한 카드만. outline 스타일. */
|
| 872 |
+
.badge.geungeo-idx { background: #f1f3f4; color: #5f6368; border: 1px solid #9aa0a6; }
|
|
|
|
|
|
|
| 873 |
.llm-summary { color: #6c727b; }
|
| 874 |
.badge.case { background: var(--case-bg); }
|
| 875 |
.badge.law { background: var(--law-bg); }
|
|
|
|
| 890 |
/* ─ 다크 컴포넌트 색 (라이트 룰들 *뒤* 에 두어야 source order 우선으로 적용) ─ */
|
| 891 |
@media (prefers-color-scheme: dark) {
|
| 892 |
.ref.llm-passed { border-left-color: #6b7280; }
|
|
|
|
|
|
|
| 893 |
.badge.llm-flag { background: #6b7280; }
|
| 894 |
+
.badge.geungeo-idx { background: #2a2a2a; color: #c4c7c5; border-color: #6b7280; }
|
|
|
|
|
|
|
| 895 |
.llm-summary { color: #9aa0a6; }
|
| 896 |
}
|
| 897 |
|
|
|
|
| 939 |
|
| 940 |
function render(payload) {
|
| 941 |
const rawExcerpts = payload.excerpts || [];
|
|
|
|
| 942 |
const llmSet = new Set(payload.llm_excerpt_citations || []);
|
| 943 |
+
const geungeoSet = new Set(payload.geungeo_indices_in_answer || []); // 답변 본문 (근거N) 의 N
|
|
|
|
| 944 |
const llmCount = rawExcerpts.filter(e => llmSet.has((e.citation || "").trim())).length;
|
| 945 |
|
| 946 |
// 카드별 *원본 LLM 입력 순서* (1-based) 보존 — 정렬 후에도 유지되도록 미리 부여.
|
| 947 |
// 이 순서가 답변 본문의 (근거N) 의 N 과 일치 (context_builder 가 같은 순서로 [근거N] 박음).
|
| 948 |
const indexed = rawExcerpts.map((e, i) => ({ ...e, _idx: i + 1 }));
|
| 949 |
|
| 950 |
+
// 2-tier 정렬: LLM 후보(0) → 검색만(1). stable.
|
| 951 |
+
const tier = (e) => llmSet.has((e.citation || "").trim()) ? 0 : 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 952 |
const excerpts = [...indexed].sort((a, b) => tier(a) - tier(b));
|
| 953 |
|
| 954 |
+
// 헤더 — "8건 · LLM 전달 7건"
|
| 955 |
if (rawExcerpts.length) {
|
| 956 |
const parts = [`${rawExcerpts.length}건`];
|
| 957 |
if (llmCount) {
|
| 958 |
parts.push(`<span class="llm-summary">LLM 전달 ${llmCount}건</span>`);
|
| 959 |
}
|
|
|
|
|
|
|
|
|
|
| 960 |
refsCountEl.innerHTML = parts.join(" · ");
|
| 961 |
} else {
|
| 962 |
refsCountEl.textContent = "";
|
|
|
|
| 973 |
for (const e of excerpts) {
|
| 974 |
const card = document.createElement("div");
|
| 975 |
const cit = (e.citation || "").trim();
|
|
|
|
| 976 |
const isLlmPassed = llmSet.has(cit);
|
| 977 |
+
card.className = "ref" + (isLlmPassed ? " llm-passed" : "");
|
|
|
|
| 978 |
const label = LABEL[e.source_type] || e.source_type;
|
| 979 |
const link = e.url
|
| 980 |
? `<a class="orig" href="${escapeHtml(e.url)}" target="_blank" rel="noopener noreferrer">원문 페이지 열기 ↗</a>`
|
| 981 |
: `<span class="nolink">원문 페이지 미제공 — 위 본문을 LLM이 직접 참조</span>`;
|
| 982 |
+
// 답변 본문에 (근거N) 으로 *명시* 등장한 카드만 "근거N" chip — 순수 정보 표시.
|
| 983 |
const showIdx = geungeoSet.has(e._idx);
|
| 984 |
let stateBadge = "";
|
| 985 |
+
if (isLlmPassed) {
|
| 986 |
+
stateBadge = `<span class="badge llm-flag" title="LLM 입력으로 전달됨 — 모델이 이 카드를 컨텍스트로 봄">LLM 전달</span>`;
|
| 987 |
+
}
|
| 988 |
+
if (showIdx) {
|
| 989 |
+
stateBadge += `<span class="badge geungeo-idx" title="답변 본문의 (근거${e._idx}) 표기와 매칭">근거${e._idx}</span>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 990 |
}
|
| 991 |
card.innerHTML = `
|
| 992 |
<div class="head">
|
src/kpaa/ui/gradio.py
CHANGED
|
@@ -29,10 +29,7 @@ from kpaa.llm import LLMOptions
|
|
| 29 |
from kpaa.llm.manager import get_manager
|
| 30 |
from kpaa.llm.presets import list_presets
|
| 31 |
from kpaa.pipeline import generate
|
| 32 |
-
from kpaa.retrieval.citation_match import
|
| 33 |
-
compute_cited_with_indices,
|
| 34 |
-
extract_geungeo_indices,
|
| 35 |
-
)
|
| 36 |
from kpaa.retrieval.context_builder import DEFAULT_MAX_EXCERPTS
|
| 37 |
from kpaa.retrieval.excerpts import Excerpt
|
| 38 |
|
|
@@ -74,35 +71,27 @@ _EXAMPLE_QUESTIONS = [
|
|
| 74 |
]
|
| 75 |
|
| 76 |
|
| 77 |
-
def _cited_excerpts(answer: str, excerpts: list[Excerpt]) -> set[str]:
|
| 78 |
-
"""[backward-compat] 답변에 인용된 excerpt citation 집합 — 공통 헬퍼 wrap."""
|
| 79 |
-
from kpaa.retrieval.citation_match import compute_cited
|
| 80 |
-
|
| 81 |
-
return set(compute_cited(answer, [e.citation for e in excerpts]))
|
| 82 |
-
|
| 83 |
-
|
| 84 |
def _render_references_html(
|
| 85 |
excerpts: list[Excerpt],
|
| 86 |
elapsed_ms: int,
|
| 87 |
-
cited_citations: set[str] | None = None,
|
| 88 |
llm_passed_citations: set[str] | None = None,
|
| 89 |
geungeo_indices: set[int] | None = None,
|
| 90 |
) -> str:
|
| 91 |
"""우측 패널 HTML 카드 묶음 — server.py 분할 화면과 동일 정책.
|
| 92 |
|
| 93 |
-
표시 단계 (
|
| 94 |
-
-
|
| 95 |
-
|
| 96 |
-
- 검색만: 표시 없음
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
Args:
|
| 99 |
excerpts: 검색된 전체 (ranker 정렬 순서). 1-based 위치 = (근거N) N.
|
| 100 |
elapsed_ms: 검색 시간.
|
| 101 |
-
cited_citations: 답변에 인용된 citation set. None 이면 강조 X.
|
| 102 |
llm_passed_citations: LLM 입력으로 전달된 상위 N건 citation set.
|
| 103 |
-
|
| 104 |
-
geungeo_indices: 답변에 (근거N) 으로 등장한 N 들 (1-based). 카드 배지에
|
| 105 |
-
[근거N] 태그 추가용. None 이면 태그 없음.
|
| 106 |
"""
|
| 107 |
if not excerpts:
|
| 108 |
return (
|
|
@@ -110,24 +99,17 @@ def _render_references_html(
|
|
| 110 |
"근거가 검색되지 않았습니다."
|
| 111 |
"</div>"
|
| 112 |
)
|
| 113 |
-
cited_set = cited_citations or set()
|
| 114 |
llm_set = llm_passed_citations or set()
|
| 115 |
geungeo_set = geungeo_indices or set()
|
| 116 |
|
| 117 |
-
cited_count = sum(1 for e in excerpts if (e.citation or "").strip() in cited_set)
|
| 118 |
llm_count = sum(1 for e in excerpts if (e.citation or "").strip() in llm_set)
|
| 119 |
|
| 120 |
-
# 카드별 원본 LLM 입력 순서(1-based) 보존 — 정렬 후에도
|
| 121 |
indexed: list[tuple[int, Excerpt]] = list(enumerate(excerpts, 1))
|
| 122 |
|
| 123 |
-
#
|
| 124 |
def _tier(item: tuple[int, Excerpt]) -> int:
|
| 125 |
-
|
| 126 |
-
if c in cited_set:
|
| 127 |
-
return 0
|
| 128 |
-
if c in llm_set:
|
| 129 |
-
return 1
|
| 130 |
-
return 2
|
| 131 |
|
| 132 |
sorted_items = sorted(indexed, key=_tier)
|
| 133 |
|
|
@@ -137,10 +119,6 @@ def _render_references_html(
|
|
| 137 |
summary_parts.append(
|
| 138 |
f'<span style="color:#6c727b;">LLM 전달 {llm_count}건</span>'
|
| 139 |
)
|
| 140 |
-
if cited_count:
|
| 141 |
-
summary_parts.append(
|
| 142 |
-
f'<span style="color:#15833a;font-weight:600;">답변에 채택 {cited_count}건</span>'
|
| 143 |
-
)
|
| 144 |
summary = " · ".join(summary_parts)
|
| 145 |
parts: list[str] = [
|
| 146 |
f'<div style="padding:8px 12px;color:#888;font-size:0.82em;">{summary}</div>'
|
|
@@ -151,7 +129,6 @@ def _render_references_html(
|
|
| 151 |
color = _BADGE_COLOR.get(e.source_type, "#666")
|
| 152 |
url = (e.metadata or {}).get("url", "").strip()
|
| 153 |
cit = (e.citation or "").strip()
|
| 154 |
-
is_cited = cit in cited_set
|
| 155 |
is_llm_passed = cit in llm_set
|
| 156 |
show_idx = idx in geungeo_set
|
| 157 |
|
|
@@ -169,34 +146,8 @@ def _render_references_html(
|
|
| 169 |
content = html.escape(e.content or "")
|
| 170 |
citation = html.escape(e.citation)
|
| 171 |
|
| 172 |
-
#
|
| 173 |
-
|
| 174 |
-
def _idx_chip(scheme: str) -> str:
|
| 175 |
-
# scheme: "cited" (녹) 또는 "llm" (회)
|
| 176 |
-
if scheme == "cited":
|
| 177 |
-
bg, fg, bd = "#e6f4ea", "#15833a", "#15833a"
|
| 178 |
-
else:
|
| 179 |
-
bg, fg, bd = "#f1f3f4", "#5f6368", "#9aa0a6"
|
| 180 |
-
return (
|
| 181 |
-
'<span style="display:inline-block;padding:2px 8px;border-radius:999px;'
|
| 182 |
-
f'font-size:0.72em;font-weight:600;color:{fg};background:{bg};border:1px solid {bd};" '
|
| 183 |
-
f'title="답변의 (근거{idx}) 표기와 매칭">근거{idx}</span>'
|
| 184 |
-
)
|
| 185 |
-
|
| 186 |
-
# 카드 스타일 + 상태 배지 (cited > llm-passed > none)
|
| 187 |
-
if is_cited:
|
| 188 |
-
card_style = (
|
| 189 |
-
"background:#f3fbf5;border:1px solid #b9e3c5;border-left:4px solid #15833a;"
|
| 190 |
-
"border-radius:10px;padding:12px 14px;margin:8px 12px;"
|
| 191 |
-
)
|
| 192 |
-
state_badge = (
|
| 193 |
-
'<span style="display:inline-block;padding:2px 8px;border-radius:999px;'
|
| 194 |
-
'font-size:0.72em;font-weight:600;color:#fff;background:#15833a;" '
|
| 195 |
-
'title="AI 답변에 인용됨">✓ 답변에 채택</span>'
|
| 196 |
-
)
|
| 197 |
-
if show_idx:
|
| 198 |
-
state_badge += _idx_chip("cited")
|
| 199 |
-
elif is_llm_passed:
|
| 200 |
card_style = (
|
| 201 |
"background:#fff;border:1px solid #e5e5e5;border-left:4px solid #9aa0a6;"
|
| 202 |
"border-radius:10px;padding:12px 14px;margin:8px 12px;"
|
|
@@ -204,10 +155,8 @@ def _render_references_html(
|
|
| 204 |
state_badge = (
|
| 205 |
'<span style="display:inline-block;padding:2px 8px;border-radius:999px;'
|
| 206 |
'font-size:0.72em;font-weight:600;color:#fff;background:#9aa0a6;" '
|
| 207 |
-
'title="LLM 입력으로 전달됨
|
| 208 |
)
|
| 209 |
-
if show_idx:
|
| 210 |
-
state_badge += _idx_chip("llm")
|
| 211 |
else:
|
| 212 |
card_style = (
|
| 213 |
"background:#fff;border:1px solid #e5e5e5;border-radius:10px;"
|
|
@@ -215,6 +164,15 @@ def _render_references_html(
|
|
| 215 |
)
|
| 216 |
state_badge = ""
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
parts.append(
|
| 219 |
f"""<div style="{card_style}">
|
| 220 |
<div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin-bottom:6px;">
|
|
@@ -344,10 +302,7 @@ async def _stream_answer(
|
|
| 344 |
elif kind == "done":
|
| 345 |
final_answer = evt["answer"]
|
| 346 |
chatbot[-1]["content"] = final_answer
|
| 347 |
-
# ★ 답변 완료 —
|
| 348 |
-
citations = [e.citation for e in retrieval_excerpts]
|
| 349 |
-
cited_list, _cited_idx = compute_cited_with_indices(final_answer, citations)
|
| 350 |
-
cited = set(cited_list)
|
| 351 |
geungeo = extract_geungeo_indices(final_answer)
|
| 352 |
llm_passed = {
|
| 353 |
e.citation for e in retrieval_excerpts[:DEFAULT_MAX_EXCERPTS] if e.citation
|
|
@@ -355,7 +310,6 @@ async def _stream_answer(
|
|
| 355 |
refs_html = _render_references_html(
|
| 356 |
retrieval_excerpts,
|
| 357 |
retrieval_elapsed_ms,
|
| 358 |
-
cited_citations=cited,
|
| 359 |
llm_passed_citations=llm_passed,
|
| 360 |
geungeo_indices=geungeo,
|
| 361 |
)
|
|
|
|
| 29 |
from kpaa.llm.manager import get_manager
|
| 30 |
from kpaa.llm.presets import list_presets
|
| 31 |
from kpaa.pipeline import generate
|
| 32 |
+
from kpaa.retrieval.citation_match import extract_geungeo_indices
|
|
|
|
|
|
|
|
|
|
| 33 |
from kpaa.retrieval.context_builder import DEFAULT_MAX_EXCERPTS
|
| 34 |
from kpaa.retrieval.excerpts import Excerpt
|
| 35 |
|
|
|
|
| 71 |
]
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def _render_references_html(
|
| 75 |
excerpts: list[Excerpt],
|
| 76 |
elapsed_ms: int,
|
|
|
|
| 77 |
llm_passed_citations: set[str] | None = None,
|
| 78 |
geungeo_indices: set[int] | None = None,
|
| 79 |
) -> str:
|
| 80 |
"""우측 패널 HTML 카드 묶음 — server.py 분할 화면과 동일 정책.
|
| 81 |
|
| 82 |
+
표시 단계 (2-tier):
|
| 83 |
+
- LLM 전달 (llm_passed_citations): 좌측 회색 표지 + 회색 "LLM 전달" 배지.
|
| 84 |
+
그 N건 *전부* 가 LLM 이 본 후보 (의미 매칭 추적은 안 함).
|
| 85 |
+
- 검색만: 표시 없음.
|
| 86 |
+
+ 답변 본문에 (근거N) 으로 *명시* 등장한 카드는 추가로 "근거N" outline chip.
|
| 87 |
+
(LLM 이 명시적으로 라벨링한 신호이고, 명시 안 된 청크도 답변에 영향 줬을 수
|
| 88 |
+
있으므로 별도 "채택" 강조 색상은 두지 않음.)
|
| 89 |
|
| 90 |
Args:
|
| 91 |
excerpts: 검색된 전체 (ranker 정렬 순서). 1-based 위치 = (근거N) N.
|
| 92 |
elapsed_ms: 검색 시간.
|
|
|
|
| 93 |
llm_passed_citations: LLM 입력으로 전달된 상위 N건 citation set.
|
| 94 |
+
geungeo_indices: 답변에 (근거N) 으로 등장한 N 들 (1-based).
|
|
|
|
|
|
|
| 95 |
"""
|
| 96 |
if not excerpts:
|
| 97 |
return (
|
|
|
|
| 99 |
"근거가 검색되지 않았습니다."
|
| 100 |
"</div>"
|
| 101 |
)
|
|
|
|
| 102 |
llm_set = llm_passed_citations or set()
|
| 103 |
geungeo_set = geungeo_indices or set()
|
| 104 |
|
|
|
|
| 105 |
llm_count = sum(1 for e in excerpts if (e.citation or "").strip() in llm_set)
|
| 106 |
|
| 107 |
+
# 카드별 원본 LLM 입력 순서(1-based) 보존 — 정렬 후에도 (근거N) 매핑 유지.
|
| 108 |
indexed: list[tuple[int, Excerpt]] = list(enumerate(excerpts, 1))
|
| 109 |
|
| 110 |
+
# 2-tier 정렬 (stable): LLM 후보 → 나머지.
|
| 111 |
def _tier(item: tuple[int, Excerpt]) -> int:
|
| 112 |
+
return 0 if (item[1].citation or "").strip() in llm_set else 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
sorted_items = sorted(indexed, key=_tier)
|
| 115 |
|
|
|
|
| 119 |
summary_parts.append(
|
| 120 |
f'<span style="color:#6c727b;">LLM 전달 {llm_count}건</span>'
|
| 121 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
summary = " · ".join(summary_parts)
|
| 123 |
parts: list[str] = [
|
| 124 |
f'<div style="padding:8px 12px;color:#888;font-size:0.82em;">{summary}</div>'
|
|
|
|
| 129 |
color = _BADGE_COLOR.get(e.source_type, "#666")
|
| 130 |
url = (e.metadata or {}).get("url", "").strip()
|
| 131 |
cit = (e.citation or "").strip()
|
|
|
|
| 132 |
is_llm_passed = cit in llm_set
|
| 133 |
show_idx = idx in geungeo_set
|
| 134 |
|
|
|
|
| 146 |
content = html.escape(e.content or "")
|
| 147 |
citation = html.escape(e.citation)
|
| 148 |
|
| 149 |
+
# 카드 스타일 + 상태 배지
|
| 150 |
+
if is_llm_passed:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
card_style = (
|
| 152 |
"background:#fff;border:1px solid #e5e5e5;border-left:4px solid #9aa0a6;"
|
| 153 |
"border-radius:10px;padding:12px 14px;margin:8px 12px;"
|
|
|
|
| 155 |
state_badge = (
|
| 156 |
'<span style="display:inline-block;padding:2px 8px;border-radius:999px;'
|
| 157 |
'font-size:0.72em;font-weight:600;color:#fff;background:#9aa0a6;" '
|
| 158 |
+
'title="LLM 입력으로 전달됨 — 모델이 이 카드를 컨텍스트로 봄">LLM 전달</span>'
|
| 159 |
)
|
|
|
|
|
|
|
| 160 |
else:
|
| 161 |
card_style = (
|
| 162 |
"background:#fff;border:1px solid #e5e5e5;border-radius:10px;"
|
|
|
|
| 164 |
)
|
| 165 |
state_badge = ""
|
| 166 |
|
| 167 |
+
# (근거N) chip — 답변 본문에 명시 등장한 카드에만. outline 회색 톤 (정보 표시).
|
| 168 |
+
if show_idx:
|
| 169 |
+
state_badge += (
|
| 170 |
+
'<span style="display:inline-block;padding:2px 8px;border-radius:999px;'
|
| 171 |
+
'font-size:0.72em;font-weight:600;color:#5f6368;background:#f1f3f4;'
|
| 172 |
+
'border:1px solid #9aa0a6;" '
|
| 173 |
+
f'title="답변 본문의 (근거{idx}) 표기와 매칭">근거{idx}</span>'
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
parts.append(
|
| 177 |
f"""<div style="{card_style}">
|
| 178 |
<div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin-bottom:6px;">
|
|
|
|
| 302 |
elif kind == "done":
|
| 303 |
final_answer = evt["answer"]
|
| 304 |
chatbot[-1]["content"] = final_answer
|
| 305 |
+
# ★ 답변 완료 — (근거N) chip 인덱스만 추출 후 재렌더.
|
|
|
|
|
|
|
|
|
|
| 306 |
geungeo = extract_geungeo_indices(final_answer)
|
| 307 |
llm_passed = {
|
| 308 |
e.citation for e in retrieval_excerpts[:DEFAULT_MAX_EXCERPTS] if e.citation
|
|
|
|
| 310 |
refs_html = _render_references_html(
|
| 311 |
retrieval_excerpts,
|
| 312 |
retrieval_elapsed_ms,
|
|
|
|
| 313 |
llm_passed_citations=llm_passed,
|
| 314 |
geungeo_indices=geungeo,
|
| 315 |
)
|