evalvault 1.75.0__py3-none-any.whl → 1.77.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -172,11 +172,11 @@ class LLMReportModule(BaseAnalysisModule):
172
172
  additional = context.get("additional_params", {}) or {}
173
173
  value = params.get("evidence_limit")
174
174
  if value is None:
175
- value = additional.get("evidence_limit", 6)
175
+ value = additional.get("evidence_limit", 12)
176
176
  try:
177
177
  return max(1, int(value))
178
178
  except (TypeError, ValueError):
179
- return 6
179
+ return 12
180
180
 
181
181
  def _build_context(self, inputs: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]:
182
182
  context = inputs.get("__context__", {})
@@ -393,34 +393,27 @@ class LLMReportModule(BaseAnalysisModule):
393
393
  threshold = self._resolve_threshold(run, metric)
394
394
  pass_rate = pass_rates.get(metric) if isinstance(pass_rates, dict) else None
395
395
  status = "unknown"
396
- if isinstance(mean, int | float):
396
+ if isinstance(mean, (int, float)):
397
397
  status = "pass" if float(mean) >= threshold else "risk"
398
- elif isinstance(pass_rate, int | float):
398
+ elif isinstance(pass_rate, (int, float)):
399
399
  status = "pass" if float(pass_rate) >= 0.7 else "risk"
400
400
 
401
+ mean_value = float(mean) if isinstance(mean, (int, float)) else None
402
+ pass_rate_value = float(pass_rate) if isinstance(pass_rate, (int, float)) else None
403
+
401
404
  scorecard.append(
402
405
  {
403
406
  "metric": metric,
404
- "mean": round(float(mean), 4) if isinstance(mean, int | float) else None,
405
- "std": round(float(stats.get("std")), 4)
406
- if isinstance(stats.get("std"), int | float)
407
- else None,
408
- "min": round(float(stats.get("min")), 4)
409
- if isinstance(stats.get("min"), int | float)
410
- else None,
411
- "max": round(float(stats.get("max")), 4)
412
- if isinstance(stats.get("max"), int | float)
413
- else None,
414
- "median": round(float(stats.get("median")), 4)
415
- if isinstance(stats.get("median"), int | float)
416
- else None,
407
+ "mean": self._round_if_number(mean_value),
408
+ "std": self._round_if_number(stats.get("std")),
409
+ "min": self._round_if_number(stats.get("min")),
410
+ "max": self._round_if_number(stats.get("max")),
411
+ "median": self._round_if_number(stats.get("median")),
417
412
  "count": stats.get("count") if isinstance(stats.get("count"), int) else None,
418
- "pass_rate": (
419
- round(float(pass_rate), 4) if isinstance(pass_rate, int | float) else None
420
- ),
413
+ "pass_rate": self._round_if_number(pass_rate_value),
421
414
  "threshold": threshold,
422
- "gap": round(threshold - float(mean), 4)
423
- if isinstance(mean, int | float)
415
+ "gap": self._round_if_number(threshold - mean_value)
416
+ if isinstance(mean_value, float)
424
417
  else None,
425
418
  "status": status,
426
419
  }
@@ -596,10 +589,83 @@ class LLMReportModule(BaseAnalysisModule):
596
589
  "comparison_scorecard": comparison_scorecard,
597
590
  "quality_summary": quality_summary,
598
591
  "artifact_manifest": artifact_manifest,
592
+ "artifact_highlights": self._build_artifact_highlights(context),
599
593
  "evidence_index": evidence_index,
594
+ "signal_group_summary": self._build_signal_group_summary(scorecard),
600
595
  "risk_metrics": risk_metrics,
601
596
  }
602
597
 
598
+ def _build_artifact_highlights(self, context: dict[str, Any]) -> list[str]:
599
+ highlights: list[str] = []
600
+
601
+ def add_items(items: list[Any], prefix: str | None = None, limit: int = 3) -> None:
602
+ count = 0
603
+ for item in items:
604
+ if count >= limit:
605
+ break
606
+ text = self._stringify_artifact_item(item)
607
+ if not text:
608
+ continue
609
+ highlights.append(f"{prefix}: {text}" if prefix else text)
610
+ count += 1
611
+
612
+ root_causes = context.get("root_causes") or []
613
+ if isinstance(root_causes, list):
614
+ add_items(root_causes, prefix="원인")
615
+
616
+ recommendations = context.get("recommendations") or []
617
+ if isinstance(recommendations, list):
618
+ add_items(recommendations, prefix="권장")
619
+
620
+ patterns = context.get("patterns") or []
621
+ if isinstance(patterns, list):
622
+ add_items(patterns, prefix="패턴")
623
+
624
+ trends = context.get("trends") or []
625
+ if isinstance(trends, list):
626
+ add_items(trends, prefix="추세")
627
+
628
+ diagnostics = context.get("diagnostics") or []
629
+ if isinstance(diagnostics, list):
630
+ add_items(diagnostics, prefix="진단", limit=2)
631
+
632
+ nlp_keywords = context.get("nlp_keywords") or []
633
+ if isinstance(nlp_keywords, list) and nlp_keywords:
634
+ keywords = ", ".join([str(item) for item in nlp_keywords[:6]])
635
+ highlights.append(f"키워드: {keywords}")
636
+
637
+ nlp_summary = context.get("nlp_summary") or {}
638
+ if isinstance(nlp_summary, dict) and nlp_summary:
639
+ summary_text = self._stringify_artifact_item(nlp_summary)
640
+ if summary_text:
641
+ highlights.append(f"NLP 요약: {summary_text}")
642
+
643
+ priority_summary = context.get("priority_summary") or {}
644
+ if isinstance(priority_summary, dict) and priority_summary:
645
+ summary_text = self._stringify_artifact_item(priority_summary)
646
+ if summary_text:
647
+ highlights.append(f"우선순위: {summary_text}")
648
+
649
+ quality_checks = context.get("quality_checks") or []
650
+ if isinstance(quality_checks, list) and quality_checks:
651
+ add_items(quality_checks, prefix="품질 체크", limit=2)
652
+
653
+ return highlights[:10]
654
+
655
+ @staticmethod
656
+ def _stringify_artifact_item(item: Any) -> str:
657
+ if item is None:
658
+ return ""
659
+ if isinstance(item, str):
660
+ return truncate_text(item, 200)
661
+ if isinstance(item, dict):
662
+ for key in ("description", "cause", "summary", "label", "pattern", "title"):
663
+ value = item.get(key)
664
+ if isinstance(value, str) and value.strip():
665
+ return truncate_text(value.strip(), 200)
666
+ return truncate_text(json.dumps(item, ensure_ascii=False), 200)
667
+ return truncate_text(str(item), 200)
668
+
603
669
  @staticmethod
604
670
  def _coerce_str(value: Any) -> str:
605
671
  if value is None:
@@ -737,6 +803,172 @@ class LLMReportModule(BaseAnalysisModule):
737
803
  lines.append(f"{metric}: {hypothesis}{suffix}")
738
804
  return lines
739
805
 
806
+ def _build_result_meaning(
807
+ self,
808
+ scorecard: list[dict[str, Any]],
809
+ signal_group_summary: dict[str, dict[str, Any]] | None,
810
+ evidence: list[dict[str, Any]],
811
+ ) -> list[str]:
812
+ signal_group_summary = signal_group_summary or {}
813
+ evidence_index = self._index_evidence_by_metric(evidence)
814
+ risk_metrics = self._build_risk_metrics(scorecard, limit=4)
815
+ lines: list[str] = []
816
+
817
+ group_meaning = {
818
+ "groundedness": "근거성 저하로 사실 오류/환각 위험 증가",
819
+ "intent_alignment": "질문 의도와 답변 정렬이 약해 사용자 만족도 하락",
820
+ "retrieval_effectiveness": "검색/랭킹 품질 저하로 정답 근거 누락",
821
+ "summary_fidelity": "요약 충실도 저하로 핵심 정보 손실",
822
+ "embedding_quality": "임베딩 품질 저하로 유사도 기반 성능 하락",
823
+ "efficiency": "응답 지연/비용 증가 가능성",
824
+ "unknown": "지표 해석을 위해 추가 분석 필요",
825
+ }
826
+
827
+ for group, bucket in signal_group_summary.items():
828
+ risk_count = bucket.get("risk_count", 0)
829
+ total = bucket.get("total", 0)
830
+ if not risk_count:
831
+ continue
832
+ meaning = group_meaning.get(group, group_meaning["unknown"])
833
+ lines.append(f"{group}: {risk_count}/{total} 지표 위험 → {meaning}")
834
+
835
+ if not lines:
836
+ for row in risk_metrics:
837
+ metric = row.get("metric")
838
+ if not metric:
839
+ continue
840
+ refs = self._format_evidence_refs(evidence_index.get(metric, []))
841
+ suffix = f" {refs}" if refs else " (추가 데이터 필요)"
842
+ lines.append(f"{metric} 저하로 사용자 신뢰/정확성 이슈 가능{suffix}")
843
+
844
+ return lines
845
+
846
+ def _build_dataset_deltas(
847
+ self,
848
+ report_type: str,
849
+ run_summary: dict[str, Any] | None,
850
+ comparison_runs: list[dict[str, Any]] | None,
851
+ change_summary: dict[str, Any] | None,
852
+ scorecard: list[dict[str, Any]],
853
+ evidence: list[dict[str, Any]],
854
+ ) -> list[str]:
855
+ report_type = report_type or "analysis"
856
+ change_summary = change_summary or {}
857
+ evidence_index = self._index_evidence_by_metric(evidence)
858
+ lines: list[str] = []
859
+
860
+ if report_type == "comparison":
861
+ dataset_changes = change_summary.get("dataset_changes", [])
862
+ if isinstance(dataset_changes, list) and dataset_changes:
863
+ for item in dataset_changes[:3]:
864
+ lines.append(f"데이터셋 변경: {self._format_dataset_change(item)}")
865
+ else:
866
+ run_a = comparison_runs[0] if comparison_runs else {}
867
+ run_b = comparison_runs[1] if comparison_runs and len(comparison_runs) > 1 else {}
868
+ if run_a.get("dataset_name") and run_b.get("dataset_name"):
869
+ if run_a.get("dataset_name") != run_b.get("dataset_name"):
870
+ lines.append(
871
+ f"데이터셋 이름 변경: {run_a.get('dataset_name')} → {run_b.get('dataset_name')}"
872
+ )
873
+ elif run_a.get("dataset_version") != run_b.get("dataset_version"):
874
+ lines.append(
875
+ f"데이터셋 버전 변경: {run_a.get('dataset_version')} → {run_b.get('dataset_version')}"
876
+ )
877
+ if not lines:
878
+ lines.append("데이터셋 변경 근거가 부족합니다. (추가 데이터 필요)")
879
+ return lines
880
+
881
+ if run_summary:
882
+ lines.append(
883
+ f"데이터셋: {run_summary.get('dataset_name', '-')} v{run_summary.get('dataset_version', '-')}"
884
+ )
885
+ risk_rows = self._build_risk_metrics(scorecard, limit=4)
886
+ for row in risk_rows:
887
+ metric = row.get("metric")
888
+ if not metric:
889
+ continue
890
+ mean = self._format_float(row.get("mean"))
891
+ threshold = self._format_float(row.get("threshold"))
892
+ refs = self._format_evidence_refs(evidence_index.get(metric, []))
893
+ suffix = f" {refs}" if refs else " (추가 데이터 필요)"
894
+ lines.append(f"{metric}: 평균 {mean} < 기준 {threshold} (데이터셋 기준 미달){suffix}")
895
+ if len(lines) <= 1:
896
+ lines.append("데이터셋 기준 차이를 설명할 근거가 부족합니다. (추가 데이터 필요)")
897
+ return lines
898
+
899
+ def _build_improvement_plan(
900
+ self,
901
+ scorecard: list[dict[str, Any]],
902
+ evidence: list[dict[str, Any]],
903
+ recommendations: list[str],
904
+ ) -> list[str]:
905
+ if recommendations:
906
+ return recommendations[:6]
907
+
908
+ action_map = {
909
+ "context_precision": "랭커/리랭커 도입 및 상위 문서 필터링 강화",
910
+ "context_recall": "검색 범위 확장 또는 하드 네거티브 추가",
911
+ "mrr": "상위 K 재정렬 및 쿼리 재작성 적용",
912
+ "ndcg": "랭킹 품질 지표 최적화(리랭킹/하이브리드 검색)",
913
+ "hit_rate": "검색 후보군 확대 또는 인덱싱 개선",
914
+ "answer_relevancy": "답변 포맷/질문 의도 정렬 프롬프트 강화",
915
+ "faithfulness": "근거 인용/검증 단계 추가",
916
+ "factual_correctness": "정답 검증 규칙 강화 및 근거 필터링",
917
+ "semantic_similarity": "정답 기준 문장 재정의 및 평가셋 보강",
918
+ }
919
+
920
+ evidence_index = self._index_evidence_by_metric(evidence)
921
+ risk_rows = self._build_risk_metrics(scorecard, limit=4)
922
+ lines: list[str] = []
923
+ for row in risk_rows:
924
+ metric = row.get("metric")
925
+ if not metric:
926
+ continue
927
+ action = action_map.get(metric, "실험을 통해 개선 방향을 재검증")
928
+ refs = self._format_evidence_refs(evidence_index.get(metric, []))
929
+ suffix = f" {refs}" if refs else " (추가 데이터 필요)"
930
+ lines.append(f"{metric}: {action} → 영향 지표 {metric}, 검증: analyze-compare{suffix}")
931
+ if not lines:
932
+ lines.append("개선 방향 도출을 위한 근거가 부족합니다. (추가 데이터 필요)")
933
+ return lines
934
+
935
+ @staticmethod
936
+ def _is_generic_entry(text: str) -> bool:
937
+ if not text:
938
+ return True
939
+ if re.search(r"\[(A|B|E)\d+\]", text):
940
+ return False
941
+ lowered = text.replace(" ", "").strip()
942
+ generic_phrases = (
943
+ "추가데이터필요",
944
+ "개선필요",
945
+ "점검필요",
946
+ "확인필요",
947
+ "재검증",
948
+ "보완필요",
949
+ "데이터부족",
950
+ "근거부족",
951
+ "점검하세요",
952
+ "재점검",
953
+ "확인하세요",
954
+ "추가하세요",
955
+ "강화하세요",
956
+ "필요합니다",
957
+ )
958
+ return not lowered or any(phrase in lowered for phrase in generic_phrases)
959
+
960
+ def _select_section_or_fallback(
961
+ self,
962
+ items: list[str],
963
+ fallback: list[str],
964
+ *,
965
+ min_items: int = 2,
966
+ ) -> list[str]:
967
+ cleaned = [item.strip() for item in items if item and item.strip()]
968
+ if len(cleaned) >= min_items and not all(self._is_generic_entry(item) for item in cleaned):
969
+ return cleaned
970
+ return fallback
971
+
740
972
  def _build_comparison_stat_notes(
741
973
  self,
742
974
  comparison_scorecard: list[dict[str, Any]],
@@ -762,21 +994,62 @@ class LLMReportModule(BaseAnalysisModule):
762
994
  dataset_changes = change_summary.get("dataset_changes", [])
763
995
  config_changes = change_summary.get("config_changes", [])
764
996
  prompt_changes = change_summary.get("prompt_changes", {})
765
- evidence_ids = [item.get("evidence_id") for item in evidence if item.get("evidence_id")]
997
+ evidence_ids: list[str] = []
998
+ for item in evidence:
999
+ evidence_id = item.get("evidence_id")
1000
+ if evidence_id is None:
1001
+ continue
1002
+ evidence_ids.append(str(evidence_id))
766
1003
  refs = self._format_evidence_refs(evidence_ids, max_items=2)
767
1004
  refs_text = f" {refs}" if refs else ""
768
1005
  lines: list[str] = []
769
1006
  if isinstance(dataset_changes, list) and dataset_changes:
770
- lines.append(f"데이터셋 변경이 성능 차이에 영향 가능{refs_text}")
1007
+ for item in dataset_changes[:3]:
1008
+ lines.append(f"데이터셋 변경: {self._format_dataset_change(item)}{refs_text}")
771
1009
  if isinstance(config_changes, list) and config_changes:
772
1010
  lines.append(f"설정 변경이 지표 변동에 영향 가능{refs_text}")
773
1011
  if isinstance(prompt_changes, dict) and prompt_changes.get("status"):
774
1012
  status = prompt_changes.get("status")
775
1013
  lines.append(f"프롬프트 변경 상태: {status}{refs_text}")
1014
+ lines.extend(self._summarize_comparison_evidence(evidence))
776
1015
  if not lines:
777
1016
  lines.append("원인 분석 근거가 부족합니다. (추가 데이터 필요)")
778
1017
  return lines
779
1018
 
1019
+ @staticmethod
1020
+ def _format_dataset_change(item: Any) -> str:
1021
+ if isinstance(item, dict):
1022
+ field = item.get("field") or "field"
1023
+ before = item.get("before")
1024
+ after = item.get("after")
1025
+ if before is not None or after is not None:
1026
+ return f"{field}: {before} → {after}"
1027
+ return str(item)
1028
+
1029
+ def _summarize_comparison_evidence(self, evidence: list[dict[str, Any]]) -> list[str]:
1030
+ summary: dict[str, dict[str, int]] = {"A": {}, "B": {}}
1031
+ refs: dict[str, list[str]] = {"A": [], "B": []}
1032
+ for item in evidence:
1033
+ run_label = item.get("run_label") or "A"
1034
+ if run_label not in summary:
1035
+ continue
1036
+ for metric in item.get("failed_metrics") or []:
1037
+ summary[run_label][metric] = summary[run_label].get(metric, 0) + 1
1038
+ evidence_id = item.get("evidence_id")
1039
+ if evidence_id:
1040
+ refs[run_label].append(str(evidence_id))
1041
+
1042
+ lines: list[str] = []
1043
+ for label in ("A", "B"):
1044
+ if not summary[label]:
1045
+ continue
1046
+ top = sorted(summary[label].items(), key=lambda x: x[1], reverse=True)[:3]
1047
+ metrics_text = ", ".join([f"{metric}({count}건)" for metric, count in top])
1048
+ refs_text = self._format_evidence_refs(refs[label], max_items=2)
1049
+ suffix = f" {refs_text}" if refs_text else ""
1050
+ lines.append(f"실행 {label} 실패 지표 상위: {metrics_text}{suffix}")
1051
+ return lines
1052
+
780
1053
  def _merge_recommendations(self, context: dict[str, Any]) -> list[str]:
781
1054
  recommendations: list[str] = []
782
1055
  for item in context.get("recommendations") or []:
@@ -794,8 +1067,12 @@ class LLMReportModule(BaseAnalysisModule):
794
1067
  "summary_bullets": ["<요약 bullet 1>", "<요약 bullet 2>", "<요약 bullet 3>"],
795
1068
  "change_summary": ["<변경 사항 1>", "<변경 사항 2>"],
796
1069
  "statistical_notes": ["<통계적 신뢰도 1>"],
1070
+ "reasons": ["<변화 원인/근거 1>"],
1071
+ "meaning": ["<결과 의미 1>"],
1072
+ "dataset_deltas": ["<데이터셋 차이 1>"],
797
1073
  "root_causes": ["<원인 분석 1>"],
798
1074
  "recommendations": ["<개선 제안 1>"],
1075
+ "improvement_plan": ["<개선 방향 1>", "<검증 방법 1>", "<리스크 1>"],
799
1076
  "next_steps": ["<다음 단계 1>"],
800
1077
  "appendix": ["<부록 항목 1>"],
801
1078
  }
@@ -803,7 +1080,11 @@ class LLMReportModule(BaseAnalysisModule):
803
1080
  template = {
804
1081
  "summary_sentence": "<한 문장 결론>",
805
1082
  "summary_bullets": ["<요약 bullet 1>", "<요약 bullet 2>", "<요약 bullet 3>"],
1083
+ "reasons": ["<원인/근거 1>"],
1084
+ "meaning": ["<결과 의미 1>"],
1085
+ "dataset_deltas": ["<데이터셋 차이 1>"],
806
1086
  "recommendations": ["<개선 제안 1>"],
1087
+ "improvement_plan": ["<개선 방향 1>", "<검증 방법 1>", "<리스크 1>"],
807
1088
  "next_steps": ["<다음 단계 1>"],
808
1089
  "appendix": ["<부록 항목 1>"],
809
1090
  }
@@ -812,8 +1093,12 @@ class LLMReportModule(BaseAnalysisModule):
812
1093
  "summary_sentence": "<한 문장 결론>",
813
1094
  "summary_bullets": ["<요약 bullet 1>", "<요약 bullet 2>", "<요약 bullet 3>"],
814
1095
  "insights": ["<증거 기반 인사이트 1>"],
1096
+ "reasons": ["<원인/근거 1>"],
1097
+ "meaning": ["<결과 의미 1>"],
1098
+ "dataset_deltas": ["<데이터셋 차이 1>"],
815
1099
  "root_causes": ["<원인 가설 1>"],
816
1100
  "recommendations": ["<개선 제안 1>"],
1101
+ "improvement_plan": ["<개선 방향 1>", "<검증 방법 1>", "<리스크 1>"],
817
1102
  "next_steps": ["<다음 단계 1>"],
818
1103
  "appendix": ["<부록 항목 1>"],
819
1104
  }
@@ -852,8 +1137,12 @@ class LLMReportModule(BaseAnalysisModule):
852
1137
  "summary_sentence": self._coerce_str(payload.get("summary_sentence")),
853
1138
  "summary_bullets": self._coerce_list(payload.get("summary_bullets")),
854
1139
  "insights": self._coerce_list(payload.get("insights")),
1140
+ "reasons": self._coerce_list(payload.get("reasons")),
1141
+ "meaning": self._coerce_list(payload.get("meaning")),
1142
+ "dataset_deltas": self._coerce_list(payload.get("dataset_deltas")),
855
1143
  "root_causes": self._coerce_list(payload.get("root_causes")),
856
1144
  "recommendations": self._coerce_list(payload.get("recommendations")),
1145
+ "improvement_plan": self._coerce_list(payload.get("improvement_plan")),
857
1146
  "next_steps": self._coerce_list(payload.get("next_steps")),
858
1147
  "change_summary": self._coerce_list(payload.get("change_summary")),
859
1148
  "statistical_notes": self._coerce_list(payload.get("statistical_notes")),
@@ -865,18 +1154,35 @@ class LLMReportModule(BaseAnalysisModule):
865
1154
  "summary_sentence",
866
1155
  "summary_bullets",
867
1156
  "insights",
1157
+ "reasons",
1158
+ "meaning",
1159
+ "dataset_deltas",
868
1160
  "root_causes",
869
1161
  "recommendations",
1162
+ "improvement_plan",
1163
+ "next_steps",
1164
+ ],
1165
+ "summary": [
1166
+ "summary_sentence",
1167
+ "summary_bullets",
1168
+ "reasons",
1169
+ "meaning",
1170
+ "dataset_deltas",
1171
+ "recommendations",
1172
+ "improvement_plan",
870
1173
  "next_steps",
871
1174
  ],
872
- "summary": ["summary_sentence", "summary_bullets", "recommendations", "next_steps"],
873
1175
  "comparison": [
874
1176
  "summary_sentence",
875
1177
  "summary_bullets",
876
1178
  "change_summary",
877
1179
  "statistical_notes",
1180
+ "reasons",
1181
+ "meaning",
1182
+ "dataset_deltas",
878
1183
  "root_causes",
879
1184
  "recommendations",
1185
+ "improvement_plan",
880
1186
  "next_steps",
881
1187
  ],
882
1188
  }
@@ -917,6 +1223,8 @@ class LLMReportModule(BaseAnalysisModule):
917
1223
  comparison_scorecard = assets.get("comparison_scorecard", [])
918
1224
  quality_summary = assets.get("quality_summary", {})
919
1225
  artifact_manifest = assets.get("artifact_manifest", [])
1226
+ artifact_highlights = assets.get("artifact_highlights", [])
1227
+ artifact_hint_lines = [f"아티팩트 근거: {item}" for item in artifact_highlights[:4]]
920
1228
 
921
1229
  lines = [title, "", "## 요약"]
922
1230
  summary_sentence = payload.get("summary_sentence")
@@ -950,6 +1258,47 @@ class LLMReportModule(BaseAnalysisModule):
950
1258
  lines.append(f"- {note}")
951
1259
  lines.append("")
952
1260
 
1261
+ lines.append("## 원인/근거")
1262
+ reasons = self._select_section_or_fallback(
1263
+ payload.get("reasons") or [],
1264
+ self._build_comparison_root_causes(context.get("change_summary"), evidence)
1265
+ + artifact_hint_lines,
1266
+ )
1267
+ for item in reasons:
1268
+ lines.append(f"- {item}")
1269
+ lines.append("")
1270
+
1271
+ lines.append("## 결과 의미")
1272
+ meanings = self._select_section_or_fallback(
1273
+ payload.get("meaning") or [],
1274
+ self._build_result_meaning(
1275
+ scorecard,
1276
+ assets.get("signal_group_summary") or {},
1277
+ evidence,
1278
+ )
1279
+ + artifact_hint_lines,
1280
+ )
1281
+ for item in meanings:
1282
+ lines.append(f"- {item}")
1283
+ lines.append("")
1284
+
1285
+ lines.append("## 데이터셋 차이")
1286
+ dataset_deltas = self._select_section_or_fallback(
1287
+ payload.get("dataset_deltas") or [],
1288
+ self._build_dataset_deltas(
1289
+ report_type,
1290
+ self._build_run_summary(context.get("run")),
1291
+ [self._build_run_summary(run) for run in (context.get("runs") or [])[:2]],
1292
+ context.get("change_summary"),
1293
+ scorecard,
1294
+ evidence,
1295
+ )
1296
+ + artifact_hint_lines,
1297
+ )
1298
+ for item in dataset_deltas:
1299
+ lines.append(f"- {item}")
1300
+ lines.append("")
1301
+
953
1302
  lines.append("## 원인 분석")
954
1303
  root_causes = payload.get("root_causes") or self._build_comparison_root_causes(
955
1304
  context.get("change_summary"),
@@ -963,6 +1312,46 @@ class LLMReportModule(BaseAnalysisModule):
963
1312
  lines.extend(self._render_scorecard_table(scorecard))
964
1313
  lines.append("")
965
1314
 
1315
+ lines.append("## 원인/근거")
1316
+ reasons = self._select_section_or_fallback(
1317
+ payload.get("reasons") or [],
1318
+ self._build_fallback_insights(scorecard, evidence) + artifact_hint_lines,
1319
+ )
1320
+ for item in reasons:
1321
+ lines.append(f"- {item}")
1322
+ lines.append("")
1323
+
1324
+ lines.append("## 결과 의미")
1325
+ meanings = self._select_section_or_fallback(
1326
+ payload.get("meaning") or [],
1327
+ self._build_result_meaning(
1328
+ scorecard,
1329
+ assets.get("signal_group_summary") or {},
1330
+ evidence,
1331
+ )
1332
+ + artifact_hint_lines,
1333
+ )
1334
+ for item in meanings:
1335
+ lines.append(f"- {item}")
1336
+ lines.append("")
1337
+
1338
+ lines.append("## 데이터셋 차이")
1339
+ dataset_deltas = self._select_section_or_fallback(
1340
+ payload.get("dataset_deltas") or [],
1341
+ self._build_dataset_deltas(
1342
+ report_type,
1343
+ self._build_run_summary(context.get("run")),
1344
+ [self._build_run_summary(run) for run in (context.get("runs") or [])[:2]],
1345
+ context.get("change_summary"),
1346
+ scorecard,
1347
+ evidence,
1348
+ )
1349
+ + artifact_hint_lines,
1350
+ )
1351
+ for item in dataset_deltas:
1352
+ lines.append(f"- {item}")
1353
+ lines.append("")
1354
+
966
1355
  if report_type != "summary":
967
1356
  lines.append("## 데이터 품질/신뢰도")
968
1357
  lines.extend(
@@ -1008,6 +1397,16 @@ class LLMReportModule(BaseAnalysisModule):
1008
1397
  else:
1009
1398
  lines.append("- 추가 데이터 및 LLM 분석을 통해 상세 원인을 도출하세요.")
1010
1399
 
1400
+ lines.append("")
1401
+ lines.append("## 개선 방향")
1402
+ improvement_plan = self._select_section_or_fallback(
1403
+ payload.get("improvement_plan") or [],
1404
+ self._build_improvement_plan(scorecard, evidence, recommendations)
1405
+ + artifact_hint_lines,
1406
+ )
1407
+ for item in improvement_plan:
1408
+ lines.append(f"- {item}")
1409
+
1011
1410
  lines.append("")
1012
1411
  lines.append("## 다음 단계")
1013
1412
  next_steps = payload.get("next_steps") or [
@@ -1033,12 +1432,16 @@ class LLMReportModule(BaseAnalysisModule):
1033
1432
  "변경 사항 요약": ["변경 사항 요약", "변경사항 요약", "변경 요약"],
1034
1433
  "지표 비교 스코어카드": ["지표 비교 스코어카드", "비교 스코어카드", "지표 비교표"],
1035
1434
  "통계적 신뢰도": ["통계적 신뢰도", "통계 신뢰도", "통계적 검증"],
1435
+ "원인/근거": ["원인/근거", "원인 및 근거", "근거"],
1436
+ "결과 의미": ["결과 의미", "의미", "영향"],
1437
+ "데이터셋 차이": ["데이터셋 차이", "데이터셋 변경", "데이터셋 기준"],
1036
1438
  "원인 분석": ["원인 분석", "원인", "원인 가설"],
1037
1439
  "지표 스코어카드": ["지표 스코어카드", "스코어카드", "지표 요약"],
1038
1440
  "데이터 품질/신뢰도": ["데이터 품질/신뢰도", "데이터 품질", "신뢰도"],
1039
1441
  "증거 기반 인사이트": ["증거 기반 인사이트", "증거 인사이트", "증거 기반", "증거"],
1040
1442
  "원인 가설": ["원인 가설", "원인 분석", "원인"],
1041
1443
  "개선 제안": ["개선 제안", "개선안", "개선 사항"],
1444
+ "개선 방향": ["개선 방향", "개선 계획", "개선 로드맵"],
1042
1445
  "다음 단계": ["다음 단계", "후속 단계", "다음 액션"],
1043
1446
  "부록(산출물)": ["부록(산출물)", "부록", "산출물"],
1044
1447
  }
@@ -1048,25 +1451,37 @@ class LLMReportModule(BaseAnalysisModule):
1048
1451
  "변경 사항 요약",
1049
1452
  "지표 비교 스코어카드",
1050
1453
  "통계적 신뢰도",
1454
+ "원인/근거",
1455
+ "결과 의미",
1456
+ "데이터셋 차이",
1051
1457
  "원인 분석",
1052
1458
  "개선 제안",
1459
+ "개선 방향",
1053
1460
  "다음 단계",
1054
1461
  "부록(산출물)",
1055
1462
  ],
1056
1463
  "summary": [
1057
1464
  "요약",
1058
1465
  "지표 스코어카드",
1466
+ "원인/근거",
1467
+ "결과 의미",
1468
+ "데이터셋 차이",
1059
1469
  "개선 제안",
1470
+ "개선 방향",
1060
1471
  "다음 단계",
1061
1472
  "부록(산출물)",
1062
1473
  ],
1063
1474
  "analysis": [
1064
1475
  "요약",
1065
1476
  "지표 스코어카드",
1477
+ "원인/근거",
1478
+ "결과 의미",
1479
+ "데이터셋 차이",
1066
1480
  "데이터 품질/신뢰도",
1067
1481
  "증거 기반 인사이트",
1068
1482
  "원인 가설",
1069
1483
  "개선 제안",
1484
+ "개선 방향",
1070
1485
  "다음 단계",
1071
1486
  "부록(산출물)",
1072
1487
  ],
@@ -1230,6 +1645,12 @@ class LLMReportModule(BaseAnalysisModule):
1230
1645
  except (TypeError, ValueError):
1231
1646
  return "-"
1232
1647
 
1648
+ @staticmethod
1649
+ def _round_if_number(value: Any, precision: int = 4) -> float | None:
1650
+ if isinstance(value, (int, float)):
1651
+ return round(float(value), precision)
1652
+ return None
1653
+
1233
1654
  def _render_scorecard_table(self, scorecard: list[dict[str, Any]]) -> list[str]:
1234
1655
  if not scorecard:
1235
1656
  return ["- 스코어카드 데이터가 없습니다."]
@@ -1313,6 +1734,7 @@ class LLMReportModule(BaseAnalysisModule):
1313
1734
  priority_highlights = self._build_priority_highlights(context.get("priority_summary"))
1314
1735
  prompt_change_summary = self._summarize_prompt_changes(context.get("change_summary"))
1315
1736
  artifact_manifest = self._build_artifact_manifest(context.get("artifact_nodes") or [])
1737
+ artifact_highlights = self._build_artifact_highlights(context)
1316
1738
  signal_group_summary = self._build_signal_group_summary(scorecard)
1317
1739
  risk_metrics = self._build_risk_metrics(scorecard)
1318
1740
  significant_changes = self._build_significant_changes(comparison_scorecard)
@@ -1363,6 +1785,7 @@ class LLMReportModule(BaseAnalysisModule):
1363
1785
  "signal_group_summary": signal_group_summary,
1364
1786
  "risk_metrics": risk_metrics,
1365
1787
  "artifact_manifest": artifact_manifest,
1788
+ "artifact_highlights": artifact_highlights,
1366
1789
  }
1367
1790
 
1368
1791
  summary_json = json.dumps(summary_payload, ensure_ascii=False, indent=2)
@@ -1384,6 +1807,9 @@ class LLMReportModule(BaseAnalysisModule):
1384
1807
  "6) 개선안마다 기대되는 영향 지표, 검증 방법(실험/재평가), 리스크를 함께 서술\n"
1385
1808
  "7) 신뢰도/타당성 제약(표본 수, 커버리지, 유의성, 데이터 변경)을 명시\n"
1386
1809
  "8) appendix는 선택이며 비어 있으면 산출물 목록을 자동 추가\n"
1810
+ "9) 금지: 모호한 조언(예: '개선 필요', '점검하세요')만 반복\n"
1811
+ "10) 각 항목은 반드시 '지표명 + 근거 + 영향/개선 방향'을 포함\n"
1812
+ "11) artifact_highlights에서 최소 2개 이상을 인용해 근거를 강화\n"
1387
1813
  )
1388
1814
  if report_type == "comparison":
1389
1815
  requirements = (
@@ -1391,35 +1817,42 @@ class LLMReportModule(BaseAnalysisModule):
1391
1817
  "1) 출력 언어: 한국어\n"
1392
1818
  "2) 핵심 주장/원인/개선안에는 evidence_id를 [A1]/[B1] 형식으로 인용\n"
1393
1819
  "3) JSON 필수 키: summary_sentence, summary_bullets, change_summary, "
1394
- "statistical_notes, root_causes, recommendations, next_steps\n"
1820
+ "statistical_notes, reasons, meaning, dataset_deltas, root_causes, "
1821
+ "recommendations, improvement_plan, next_steps\n"
1395
1822
  "4) summary_bullets는 3개 권장(지표/변경 사항/사용자 영향)\n"
1396
1823
  "5) change_summary에는 데이터셋/설정/프롬프트 변경을 명시\n"
1397
1824
  "6) statistical_notes에는 유의한 변화 및 한계(표본/유의성) 포함\n"
1398
- "7) 근거가 부족하면 '추가 데이터 필요'라고 명시\n"
1825
+ "7) meaning은 결과가 사용자/업무에 주는 의미를 서술\n"
1826
+ "8) dataset_deltas는 데이터셋 기준 차이를 명확히 기재\n"
1827
+ "9) improvement_plan은 개선 방향+기대 지표+검증 방법+리스크 포함\n"
1828
+ "10) 근거가 부족하면 '추가 데이터 필요'라고 명시\n"
1399
1829
  )
1400
1830
  elif report_type == "summary":
1401
1831
  requirements = (
1402
1832
  "요구사항:\n"
1403
1833
  "1) 출력 언어: 한국어\n"
1404
1834
  "2) 핵심 주장/개선안에는 evidence_id를 [E1] 형식으로 인용\n"
1405
- "3) JSON 필수 키: summary_sentence, summary_bullets, "
1406
- "recommendations, next_steps\n"
1835
+ "3) JSON 필수 키: summary_sentence, summary_bullets, reasons, meaning, "
1836
+ "dataset_deltas, recommendations, improvement_plan, next_steps\n"
1407
1837
  "4) summary_bullets는 3개 권장\n"
1408
1838
  "5) risk_metrics를 활용해 상위 위험 지표 3개를 명확히 언급\n"
1409
- "6) 근거가 부족하면 '추가 데이터 필요'라고 명시\n"
1839
+ "6) meaning과 dataset_deltas는 간단히라도 포함\n"
1840
+ "7) improvement_plan은 개선 방향+검증 방법을 포함\n"
1841
+ "8) 근거가 부족하면 '추가 데이터 필요'라고 명시\n"
1410
1842
  )
1411
1843
  else:
1412
1844
  requirements = (
1413
1845
  "요구사항:\n"
1414
1846
  "1) 출력 언어: 한국어\n"
1415
1847
  "2) 핵심 주장/원인/개선안에는 evidence_id를 [E1] 형식으로 인용\n"
1416
- "3) JSON 필수 키: summary_sentence, summary_bullets, insights, "
1417
- "root_causes, recommendations, next_steps\n"
1848
+ "3) JSON 필수 키: summary_sentence, summary_bullets, insights, reasons, meaning, "
1849
+ "dataset_deltas, root_causes, recommendations, improvement_plan, next_steps\n"
1418
1850
  "4) insights/root_causes에는 evidence_id를 포함\n"
1419
1851
  "5) summary_bullets는 3개 권장(지표/원인/사용자 영향)\n"
1420
1852
  "6) signal_group_summary로 축별 약점/강점을 분해\n"
1421
1853
  "7) 사용자 영향은 신뢰/이해/인지부하 관점으로 1~2문장\n"
1422
- "8) 근거가 부족하면 '추가 데이터 필요'라고 명시\n"
1854
+ "8) meaning/dataset_deltas/improvement_plan은 반드시 포함\n"
1855
+ "9) 근거가 부족하면 '추가 데이터 필요'라고 명시\n"
1423
1856
  )
1424
1857
 
1425
1858
  return (
@@ -1568,6 +2001,28 @@ class LLMReportModule(BaseAnalysisModule):
1568
2001
  report_lines.append(f"- {note}")
1569
2002
  report_lines.append("")
1570
2003
 
2004
+ report_lines.append("## 원인/근거")
2005
+ for item in self._build_comparison_root_causes(change_summary, evidence):
2006
+ report_lines.append(f"- {item}")
2007
+ report_lines.append("")
2008
+
2009
+ report_lines.append("## 결과 의미")
2010
+ for item in self._build_result_meaning(scorecard, {}, evidence):
2011
+ report_lines.append(f"- {item}")
2012
+ report_lines.append("")
2013
+
2014
+ report_lines.append("## 데이터셋 차이")
2015
+ for item in self._build_dataset_deltas(
2016
+ report_type,
2017
+ run_summary,
2018
+ comparison_runs,
2019
+ change_summary,
2020
+ scorecard,
2021
+ evidence,
2022
+ ):
2023
+ report_lines.append(f"- {item}")
2024
+ report_lines.append("")
2025
+
1571
2026
  report_lines.append("## 원인 분석")
1572
2027
  for cause in self._build_comparison_root_causes(change_summary, evidence):
1573
2028
  report_lines.append(f"- {cause}")
@@ -1599,6 +2054,28 @@ class LLMReportModule(BaseAnalysisModule):
1599
2054
  report_lines.append("- 증거 기반 인사이트가 없습니다. (추가 데이터 필요)")
1600
2055
  report_lines.append("")
1601
2056
 
2057
+ report_lines.append("## 원인/근거")
2058
+ for item in self._build_fallback_insights(scorecard, evidence):
2059
+ report_lines.append(f"- {item}")
2060
+ report_lines.append("")
2061
+
2062
+ report_lines.append("## 결과 의미")
2063
+ for item in self._build_result_meaning(scorecard, {}, evidence):
2064
+ report_lines.append(f"- {item}")
2065
+ report_lines.append("")
2066
+
2067
+ report_lines.append("## 데이터셋 차이")
2068
+ for item in self._build_dataset_deltas(
2069
+ report_type,
2070
+ run_summary,
2071
+ comparison_runs,
2072
+ context.get("change_summary"),
2073
+ scorecard,
2074
+ evidence,
2075
+ ):
2076
+ report_lines.append(f"- {item}")
2077
+ report_lines.append("")
2078
+
1602
2079
  report_lines.append("## 원인 가설")
1603
2080
  root_causes = self._build_root_cause_hypotheses(scorecard, evidence)
1604
2081
  if root_causes:
@@ -1617,6 +2094,11 @@ class LLMReportModule(BaseAnalysisModule):
1617
2094
  else:
1618
2095
  report_lines.append("- 추가 데이터 및 LLM 분석을 통해 상세 원인을 도출하세요.")
1619
2096
 
2097
+ report_lines.append("")
2098
+ report_lines.append("## 개선 방향")
2099
+ for item in self._build_improvement_plan(scorecard, evidence, recommendations):
2100
+ report_lines.append(f"- {item}")
2101
+
1620
2102
  report_lines.append("")
1621
2103
  report_lines.append("## 다음 단계")
1622
2104
  report_lines.append("- 우선순위 케이스를 대상으로 실험/재평가를 진행하세요.")