scvcoder commited on
Commit
18ad465
·
verified ·
1 Parent(s): 3630faa

ui: simplify references panel — drop 'adopted' tier, keep 'LLM 전달' + (근거N) chip

Browse files

Top-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

Files changed (2) hide show
  1. src/kpaa/server.py +32 -70
  2. 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
- # 우측 패널이 갱신. cited_citations답변 완료 시점에 final answer 와 매칭해
99
- # 채워짐 (검색 직후엔 리스트). llm_excerpt_citations retrieval 시점에
100
- # 상위 DEFAULT_MAX_EXCERPTS 건의 citation 사용자가 "어느 LLM 입력으로
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 _compute_cited(answer: str, excerpts: list[Excerpt]) -> list[str]:
123
- """[backward-compat wrapper] excerpts citations 추출 공통 헬퍼 호출."""
124
- return _compute_cited_helper(answer, [e.citation for e in excerpts])
125
 
126
-
127
- def _mark_cited(answer: str) -> None:
128
- """답변 완료 _last_refs cited_citations + 인용 N 업데이트 + ts 갱신.
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
- # 검색 완료 시점엔 답변 아직 없음 — done 시점_mark_cited 가 채움.
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
- # 답변 완료 — references 패널이 다음 polling 에채택 표시 그리도록 갱신.
532
  if not _is_meta_query(query):
533
- _mark_cited(evt.get("answer") or "")
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
- # 비스트리밍 분기도 답변 완료 후 cited 표�� 갱신.
690
  if text and not _is_meta_query(query):
691
- _mark_cited(text)
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" 인덱스 표시용 배지(outline 스타일). 채택 색과 매칭. */
887
- .badge.cited-idx { background: #e6f4ea; color: #15833a; border: 1px solid #15833a; }
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.cited-idx { background: #11261a; color: #4ade80; border-color: #4ade80; }
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 || []); // 답변 실제 등장한 N
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
- // 3-tier 정렬: 채택(0) → LLM 후보(1) → 검색만(2). stable.
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
- // 헤더 — "6건 · LLM 전달 3 · 답변에 채택 1건"
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
- // .cited .llm-passed 보다 우선 시각화 ( 다일 cited 부여 — 시각 redundancy 회피).
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) 으로 등장한 카드만 별도 "근거N" 배지 표시.
1016
  const showIdx = geungeoSet.has(e._idx);
1017
  let stateBadge = "";
1018
- if (isCited) {
1019
- stateBadge = `<span class="badge cited-flag" title="AI 답변에 인용됨"> 답변에 채택</span>`;
1020
- if (showIdx) {
1021
- stateBadge += `<span class="badge cited-idx" title="답변의 (근거${e._idx}) 표기와 매칭">근거${e._idx}</span>`;
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_citationsretrieval 시점에 상위
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
- 표시 단계 (3-tier):
94
- - 채택 (cited_citations): 좌측 border + 배경 + 배지 + [근거N]
95
- - LLM (llm_passed_citations): 좌측 회색 표지 + 회색 배지 + [근거N]
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
- None 이면 표시 X.
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) 보존 — 정렬 후에도 [근거N] 매핑 유지.
121
  indexed: list[tuple[int, Excerpt]] = list(enumerate(excerpts, 1))
122
 
123
- # 3-tier 정렬 (stable): 채택 → LLM 후보 → 나머지.
124
  def _tier(item: tuple[int, Excerpt]) -> int:
125
- c = (item[1].citation or "").strip()
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
- # 짝 배지 — 답변 본문에 (근거N) 표기로 등장한 카드 인덱chip 추가.
173
- # 채택/LLM 전달 색상에 맞춰 outline 톤으로.
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 입력으로 전달됨 (답변 명시 인용은 됨)">LLM 전달</span>'
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
- # ★ 답변 완료 — 공통 매칭 헬퍼로 cited + (근거N) indices 추출 후 재렌더.
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
  )