diff --git a/README.md b/README.md
index a436f92..bf8416b 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
'
+ f'{label[:8]}' if pct > 10 else
+ f'
'
+ )
+
+ dist_label = "标签分布" if lang == "zh" else "Label Dist."
+ return (
+ f'
'
+ f'
'
+ f'{dist_label}
'
+ f'
'
+ f'{"".join(segments)}
'
+ )
diff --git a/socialscikit/core/icr.py b/socialscikit/core/icr.py
new file mode 100644
index 0000000..cfa99cd
--- /dev/null
+++ b/socialscikit/core/icr.py
@@ -0,0 +1,630 @@
+"""Inter-Coder Reliability (ICR) — Cohen's Kappa, Krippendorff's Alpha, Jaccard agreement.
+
+Compute agreement metrics between human-human or human-LLM coders.
+Supports both single-label (QuantiKit) and multi-label (QualiKit) coding.
+
+References:
+- Cohen, J. (1960). A coefficient of agreement for nominal scales.
+ Educational and Psychological Measurement, 20(1), 37–46.
+- Krippendorff, K. (2011). Computing Krippendorff's Alpha-Reliability.
+ https://repository.upenn.edu/asc_papers/43
+- Landis, J. R., & Koch, G. G. (1977). The measurement of observer
+ agreement for categorical data. Biometrics, 33(1), 159–174.
+"""
+
+from __future__ import annotations
+
+import math
+from collections import Counter
+from dataclasses import dataclass, field
+from itertools import combinations
+
+
+# ---------------------------------------------------------------------------
+# Data classes
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class ICRResult:
+ """Result for a single metric computation."""
+
+ metric_name: str # "cohens_kappa" | "krippendorffs_alpha" | "jaccard_agreement"
+ value: float
+ interpretation: str # e.g. "Moderate agreement"
+ n_coders: int = 2
+ n_items: int = 0
+ n_categories: int = 0
+
+
+@dataclass
+class PerCategoryAgreement:
+ """Agreement breakdown for a single category/theme."""
+
+ category: str
+ observed_agreement: float
+ expected_agreement: float
+ specific_agreement: float # category-specific agreement
+
+
+@dataclass
+class ICRReport:
+ """Full inter-coder reliability report."""
+
+ results: list[ICRResult] = field(default_factory=list)
+ per_category: list[PerCategoryAgreement] = field(default_factory=list)
+ pairwise_matrix: list[list[float]] | None = None
+ coder_labels: list[str] = field(default_factory=list)
+ summary_text: str = ""
+
+
+# ---------------------------------------------------------------------------
+# ICR Calculator
+# ---------------------------------------------------------------------------
+
+
+class ICRCalculator:
+ """Compute inter-coder reliability metrics.
+
+ Supports two input modes:
+ 1. Two label lists (simple 2-coder case, single-label)
+ 2. Multi-label theme sets (QualiKit qualitative coding)
+
+ Also supports Krippendorff's Alpha with a reliability matrix for 2+ coders.
+
+ Usage::
+
+ calc = ICRCalculator()
+
+ # Single-label (QuantiKit)
+ report = calc.compute_all(
+ coder1_labels=["pos", "neg", "pos"],
+ coder2_labels=["pos", "pos", "pos"],
+ )
+
+ # Multi-label (QualiKit)
+ report = calc.compute_all_multilabel(
+ coder1_themes=[{"economy", "policy"}, {"health"}],
+ coder2_themes=[{"economy"}, {"health", "education"}],
+ )
+ """
+
+ # ------------------------------------------------------------------
+ # Cohen's Kappa (2 coders, single-label)
+ # ------------------------------------------------------------------
+
+ def compute_cohens_kappa(
+ self,
+ coder1_labels: list[str],
+ coder2_labels: list[str],
+ labels: list[str] | None = None,
+ ) -> ICRResult:
+ """Cohen's Kappa for two coders with single-label classification.
+
+ Parameters
+ ----------
+ coder1_labels, coder2_labels : list[str]
+ Labels assigned by each coder (same length).
+ labels : list[str] or None
+ Explicit label set. If None, derived from the union.
+
+ Returns
+ -------
+ ICRResult
+ """
+ if len(coder1_labels) != len(coder2_labels):
+ raise ValueError(
+ f"Length mismatch: {len(coder1_labels)} vs {len(coder2_labels)}."
+ )
+
+ n = len(coder1_labels)
+ if n == 0:
+ return ICRResult(
+ metric_name="cohens_kappa", value=0.0,
+ interpretation="No data", n_items=0,
+ )
+
+ c1 = [str(x).strip() for x in coder1_labels]
+ c2 = [str(x).strip() for x in coder2_labels]
+
+ if labels is None:
+ labels = sorted(set(c1) | set(c2))
+ label_idx = {l: i for i, l in enumerate(labels)}
+ k = len(labels)
+
+ # Build confusion matrix
+ cm = [[0] * k for _ in range(k)]
+ for a, b in zip(c1, c2):
+ ai = label_idx.get(a)
+ bi = label_idx.get(b)
+ if ai is not None and bi is not None:
+ cm[ai][bi] += 1
+
+ # Observed agreement
+ p_o = sum(cm[i][i] for i in range(k)) / n
+
+ # Expected agreement by chance
+ p_e = 0.0
+ for i in range(k):
+ row_sum = sum(cm[i][j] for j in range(k))
+ col_sum = sum(cm[j][i] for j in range(k))
+ p_e += (row_sum / n) * (col_sum / n)
+
+ if p_e >= 1.0:
+ kappa = 0.0
+ else:
+ kappa = (p_o - p_e) / (1.0 - p_e)
+
+ return ICRResult(
+ metric_name="cohens_kappa",
+ value=round(kappa, 4),
+ interpretation=self.interpret_kappa(kappa),
+ n_coders=2,
+ n_items=n,
+ n_categories=k,
+ )
+
+ # ------------------------------------------------------------------
+ # Krippendorff's Alpha (2+ coders, handles missing data)
+ # ------------------------------------------------------------------
+
+ def compute_krippendorffs_alpha(
+ self,
+ reliability_matrix: list[list[str | None]],
+ data_type: str = "nominal",
+ ) -> ICRResult:
+ """Krippendorff's Alpha for 2+ coders.
+
+ Parameters
+ ----------
+ reliability_matrix : list[list[str | None]]
+ Shape: (n_items, n_coders). Cell = label or None for missing.
+ data_type : str
+ "nominal" (default). Others reserved for future.
+
+ Returns
+ -------
+ ICRResult
+ """
+ if not reliability_matrix:
+ return ICRResult(
+ metric_name="krippendorffs_alpha", value=0.0,
+ interpretation="No data", n_items=0,
+ )
+
+ n_items = len(reliability_matrix)
+ n_coders = len(reliability_matrix[0]) if reliability_matrix else 0
+
+ if data_type != "nominal":
+ raise ValueError(
+ f"Currently only 'nominal' data type is supported, got '{data_type}'."
+ )
+
+ # Collect all categories
+ all_values: set[str] = set()
+ for row in reliability_matrix:
+ for v in row:
+ if v is not None:
+ all_values.add(str(v))
+
+ if len(all_values) <= 1:
+ # All same value or empty — perfect agreement (by convention)
+ return ICRResult(
+ metric_name="krippendorffs_alpha", value=1.0,
+ interpretation=self.interpret_alpha(1.0),
+ n_coders=n_coders, n_items=n_items,
+ n_categories=len(all_values),
+ )
+
+ # For each item, count the number of non-missing values
+ # and the value frequencies
+ item_counts: list[dict[str, int]] = []
+ item_mu: list[int] = [] # number of coders per item (non-missing)
+
+ for row in reliability_matrix:
+ counts: dict[str, int] = Counter()
+ mu = 0
+ for v in row:
+ if v is not None:
+ counts[str(v)] += 1
+ mu += 1
+ item_counts.append(counts)
+ item_mu.append(mu)
+
+ # Only keep items where at least 2 coders provided values
+ valid_items = [i for i in range(n_items) if item_mu[i] >= 2]
+
+ if not valid_items:
+ return ICRResult(
+ metric_name="krippendorffs_alpha", value=0.0,
+ interpretation="Insufficient data (need ≥2 coders per item)",
+ n_coders=n_coders, n_items=0,
+ )
+
+ # Observed disagreement
+ # D_o = (1 / sum_of_pairable_values) * sum_over_items(
+ # 1/(m_u - 1) * sum_c_k( n_uc * n_uk * delta(c,k) )
+ # )
+ total_pairable = sum(item_mu[i] * (item_mu[i] - 1) for i in valid_items)
+
+ if total_pairable == 0:
+ return ICRResult(
+ metric_name="krippendorffs_alpha", value=0.0,
+ interpretation="Insufficient data",
+ n_coders=n_coders, n_items=0,
+ )
+
+ d_observed = 0.0
+ for i in valid_items:
+ mu = item_mu[i]
+ counts = item_counts[i]
+ # Sum over all pairs of categories (c, k) where c != k
+ for c, n_c in counts.items():
+ for k, n_k in counts.items():
+ if c != k:
+ # For nominal: delta(c, k) = 1 when c != k
+ d_observed += n_c * n_k
+
+ d_observed /= total_pairable
+
+ # Expected disagreement
+ # Marginal frequencies across all items
+ n_total_values = sum(item_mu[i] for i in valid_items)
+ marginal: Counter = Counter()
+ for i in valid_items:
+ for v, cnt in item_counts[i].items():
+ marginal[v] += cnt
+
+ d_expected = 0.0
+ for c in marginal:
+ for k in marginal:
+ if c != k:
+ d_expected += marginal[c] * marginal[k]
+
+ d_expected /= (n_total_values * (n_total_values - 1))
+
+ if d_expected == 0:
+ alpha = 1.0
+ else:
+ alpha = 1.0 - d_observed / d_expected
+
+ return ICRResult(
+ metric_name="krippendorffs_alpha",
+ value=round(alpha, 4),
+ interpretation=self.interpret_alpha(alpha),
+ n_coders=n_coders,
+ n_items=len(valid_items),
+ n_categories=len(all_values),
+ )
+
+ # ------------------------------------------------------------------
+ # Multi-label Jaccard agreement (QualiKit)
+ # ------------------------------------------------------------------
+
+ def compute_multilabel_agreement(
+ self,
+ coder1_themes: list[set[str]],
+ coder2_themes: list[set[str]],
+ ) -> ICRResult:
+ """Jaccard-based agreement for multi-label coding.
+
+ For each segment, agreement = |intersection| / |union|.
+ If both coders assign no themes, agreement = 1.0 (both agree: no themes).
+ Returns the average across all segments.
+
+ Parameters
+ ----------
+ coder1_themes, coder2_themes : list[set[str]]
+ Theme sets per segment (same length).
+
+ Returns
+ -------
+ ICRResult
+ """
+ if len(coder1_themes) != len(coder2_themes):
+ raise ValueError(
+ f"Length mismatch: {len(coder1_themes)} vs {len(coder2_themes)}."
+ )
+
+ n = len(coder1_themes)
+ if n == 0:
+ return ICRResult(
+ metric_name="jaccard_agreement", value=0.0,
+ interpretation="No data", n_items=0,
+ )
+
+ total_jaccard = 0.0
+ for s1, s2 in zip(coder1_themes, coder2_themes):
+ if not s1 and not s2:
+ total_jaccard += 1.0
+ elif not s1 or not s2:
+ total_jaccard += 0.0
+ else:
+ intersection = len(s1 & s2)
+ union = len(s1 | s2)
+ total_jaccard += intersection / union if union > 0 else 0.0
+
+ avg_jaccard = total_jaccard / n
+ all_themes = set()
+ for s in coder1_themes + coder2_themes:
+ all_themes |= s
+
+ return ICRResult(
+ metric_name="jaccard_agreement",
+ value=round(avg_jaccard, 4),
+ interpretation=self._interpret_jaccard(avg_jaccard),
+ n_coders=2,
+ n_items=n,
+ n_categories=len(all_themes),
+ )
+
+ # ------------------------------------------------------------------
+ # Per-category agreement (single-label)
+ # ------------------------------------------------------------------
+
+ def _compute_per_category(
+ self,
+ coder1_labels: list[str],
+ coder2_labels: list[str],
+ labels: list[str],
+ ) -> list[PerCategoryAgreement]:
+ """Compute category-specific agreement for each label."""
+ n = len(coder1_labels)
+ if n == 0:
+ return []
+
+ result = []
+ for cat in labels:
+ # Binary: does coder assign this category or not?
+ c1_binary = [1 if l == cat else 0 for l in coder1_labels]
+ c2_binary = [1 if l == cat else 0 for l in coder2_labels]
+
+ # Observed agreement for this category
+ agree = sum(1 for a, b in zip(c1_binary, c2_binary) if a == b)
+ p_o = agree / n
+
+ # Expected agreement
+ p1 = sum(c1_binary) / n
+ p2 = sum(c2_binary) / n
+ p_e = p1 * p2 + (1 - p1) * (1 - p2)
+
+ # Specific agreement: proportion of cases where both say "yes"
+ both_yes = sum(1 for a, b in zip(c1_binary, c2_binary) if a == 1 and b == 1)
+ either_yes = sum(1 for a, b in zip(c1_binary, c2_binary) if a == 1 or b == 1)
+ specific = both_yes / either_yes if either_yes > 0 else 0.0
+
+ result.append(PerCategoryAgreement(
+ category=cat,
+ observed_agreement=round(p_o, 4),
+ expected_agreement=round(p_e, 4),
+ specific_agreement=round(specific, 4),
+ ))
+
+ return result
+
+ # ------------------------------------------------------------------
+ # Comprehensive reports
+ # ------------------------------------------------------------------
+
+ def compute_all(
+ self,
+ coder1_labels: list[str],
+ coder2_labels: list[str],
+ labels: list[str] | None = None,
+ ) -> ICRReport:
+ """Compute Cohen's Kappa, Krippendorff's Alpha, and per-category.
+
+ Parameters
+ ----------
+ coder1_labels, coder2_labels : list[str]
+ labels : list[str] or None
+
+ Returns
+ -------
+ ICRReport
+ """
+ if labels is None:
+ labels = sorted(
+ set(str(x).strip() for x in coder1_labels)
+ | set(str(x).strip() for x in coder2_labels)
+ )
+
+ kappa_result = self.compute_cohens_kappa(coder1_labels, coder2_labels, labels)
+
+ # Build reliability matrix for Krippendorff's Alpha
+ c1 = [str(x).strip() for x in coder1_labels]
+ c2 = [str(x).strip() for x in coder2_labels]
+ reliability_matrix = [[a, b] for a, b in zip(c1, c2)]
+ alpha_result = self.compute_krippendorffs_alpha(reliability_matrix, "nominal")
+
+ per_category = self._compute_per_category(c1, c2, labels)
+
+ report = ICRReport(
+ results=[kappa_result, alpha_result],
+ per_category=per_category,
+ coder_labels=["Coder 1", "Coder 2"],
+ )
+ report.summary_text = self.format_report(report)
+ return report
+
+ def compute_all_multilabel(
+ self,
+ coder1_themes: list[set[str]],
+ coder2_themes: list[set[str]],
+ all_themes: list[str] | None = None,
+ ) -> ICRReport:
+ """Full report for multi-label coding comparison.
+
+ Computes Jaccard agreement and per-theme binary Kappa.
+
+ Parameters
+ ----------
+ coder1_themes, coder2_themes : list[set[str]]
+ all_themes : list[str] or None
+
+ Returns
+ -------
+ ICRReport
+ """
+ jaccard_result = self.compute_multilabel_agreement(coder1_themes, coder2_themes)
+
+ if all_themes is None:
+ all_t: set[str] = set()
+ for s in coder1_themes + coder2_themes:
+ all_t |= s
+ all_themes = sorted(all_t)
+
+ # Per-theme binary Kappa
+ per_category = []
+ per_theme_kappas = []
+ for theme in all_themes:
+ c1_binary = ["yes" if theme in s else "no" for s in coder1_themes]
+ c2_binary = ["yes" if theme in s else "no" for s in coder2_themes]
+ kappa_r = self.compute_cohens_kappa(c1_binary, c2_binary, ["yes", "no"])
+ per_theme_kappas.append(kappa_r)
+
+ # Specific agreement for this theme
+ n = len(coder1_themes)
+ both_yes = sum(
+ 1 for s1, s2 in zip(coder1_themes, coder2_themes)
+ if theme in s1 and theme in s2
+ )
+ either_yes = sum(
+ 1 for s1, s2 in zip(coder1_themes, coder2_themes)
+ if theme in s1 or theme in s2
+ )
+ specific = both_yes / either_yes if either_yes > 0 else 0.0
+
+ per_category.append(PerCategoryAgreement(
+ category=theme,
+ observed_agreement=round(
+ sum(1 for a, b in zip(c1_binary, c2_binary) if a == b) / n, 4
+ ) if n > 0 else 0.0,
+ expected_agreement=0.0, # not as meaningful for multi-label
+ specific_agreement=round(specific, 4),
+ ))
+
+ # Average per-theme Kappa as a summary metric
+ valid_kappas = [r.value for r in per_theme_kappas if r.n_items > 0]
+ avg_kappa = sum(valid_kappas) / len(valid_kappas) if valid_kappas else 0.0
+ avg_kappa_result = ICRResult(
+ metric_name="avg_per_theme_kappa",
+ value=round(avg_kappa, 4),
+ interpretation=self.interpret_kappa(avg_kappa),
+ n_coders=2,
+ n_items=len(coder1_themes),
+ n_categories=len(all_themes),
+ )
+
+ report = ICRReport(
+ results=[jaccard_result, avg_kappa_result],
+ per_category=per_category,
+ coder_labels=["Coder 1", "Coder 2"],
+ )
+ report.summary_text = self.format_report(report, multilabel=True)
+ return report
+
+ # ------------------------------------------------------------------
+ # Interpretation scales
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def interpret_kappa(value: float) -> str:
+ """Landis & Koch (1977) interpretation scale for Kappa."""
+ if value < 0:
+ return "Poor agreement"
+ elif value < 0.21:
+ return "Slight agreement"
+ elif value < 0.41:
+ return "Fair agreement"
+ elif value < 0.61:
+ return "Moderate agreement"
+ elif value < 0.81:
+ return "Substantial agreement"
+ else:
+ return "Almost perfect agreement"
+
+ @staticmethod
+ def interpret_alpha(value: float) -> str:
+ """Krippendorff interpretation scale for Alpha."""
+ if value < 0.667:
+ return "Unreliable — discard or recode"
+ elif value < 0.8:
+ return "Tentatively reliable"
+ else:
+ return "Reliable"
+
+ @staticmethod
+ def _interpret_jaccard(value: float) -> str:
+ """Interpret average Jaccard agreement."""
+ if value < 0.2:
+ return "Poor agreement"
+ elif value < 0.4:
+ return "Fair agreement"
+ elif value < 0.6:
+ return "Moderate agreement"
+ elif value < 0.8:
+ return "Substantial agreement"
+ else:
+ return "Almost perfect agreement"
+
+ # ------------------------------------------------------------------
+ # Formatting
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def format_report(
+ report: ICRReport,
+ lang: str = "zh",
+ multilabel: bool = False,
+ ) -> str:
+ """Format an ICRReport as a human-readable string."""
+ lines: list[str] = []
+
+ if lang == "zh":
+ lines.append("═══ 编码者间信度报告 ═══")
+ else:
+ lines.append("═══ Inter-Coder Reliability Report ═══")
+ lines.append("")
+
+ for r in report.results:
+ label_map = {
+ "cohens_kappa": "Cohen's Kappa",
+ "krippendorffs_alpha": "Krippendorff's Alpha",
+ "jaccard_agreement": "Jaccard Agreement (avg)" if lang == "en"
+ else "Jaccard 一致性(均值)",
+ "avg_per_theme_kappa": "Avg Per-Theme Kappa" if lang == "en"
+ else "各主题 Kappa 均值",
+ }
+ name = label_map.get(r.metric_name, r.metric_name)
+ lines.append(f"{name}: {r.value:.4f}")
+ if lang == "zh":
+ lines.append(f" 解释: {r.interpretation}")
+ else:
+ lines.append(f" Interpretation: {r.interpretation}")
+ if r.n_items > 0:
+ items_label = "项目数" if lang == "zh" else "Items"
+ cats_label = "类别数" if lang == "zh" else "Categories"
+ lines.append(f" {items_label}: {r.n_items} | {cats_label}: {r.n_categories}")
+ lines.append("")
+
+ if report.per_category:
+ if multilabel:
+ header_label = "各主题一致性" if lang == "zh" else "Per-Theme Agreement"
+ else:
+ header_label = "各类别一致性" if lang == "zh" else "Per-Category Agreement"
+ lines.append(f"── {header_label} ──")
+
+ cat_label = "类别" if lang == "zh" else "Category"
+ obs_label = "观测一致" if lang == "zh" else "Observed"
+ spec_label = "特定一致" if lang == "zh" else "Specific"
+ lines.append(f"{cat_label:<24} {obs_label:>10} {spec_label:>10}")
+ lines.append("─" * 46)
+ for pc in report.per_category:
+ lines.append(
+ f"{pc.category:<24} {pc.observed_agreement:>10.4f} "
+ f"{pc.specific_agreement:>10.4f}"
+ )
+ lines.append("")
+
+ return "\n".join(lines)
diff --git a/socialscikit/core/methods_writer.py b/socialscikit/core/methods_writer.py
new file mode 100644
index 0000000..820cf72
--- /dev/null
+++ b/socialscikit/core/methods_writer.py
@@ -0,0 +1,448 @@
+"""Methods Section Auto-generation — template-based methods paragraph for papers.
+
+Generates a Methods paragraph draft from pipeline metadata. Template-based
+(no LLM calls) so every number and method name is deterministic and verifiable.
+
+Outputs both English and Chinese versions.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+
+# ---------------------------------------------------------------------------
+# Pipeline metadata
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class QuantiKitPipelineMetadata:
+ """Metadata collected from a QuantiKit analysis session."""
+
+ dataset_name: str = ""
+ n_samples: int = 0
+ n_classes: int = 0
+ class_labels: list[str] = field(default_factory=list)
+ classification_method: str = "" # "zero-shot" | "few-shot" | "fine-tune-local" | "fine-tune-api"
+ model_name: str = ""
+ model_backend: str = "" # "openai" | "anthropic" | "ollama"
+ n_annotations: int = 0
+ prompt_optimization_used: bool = False
+ n_prompt_variants: int = 0
+ # Evaluation metrics
+ accuracy: float = 0.0
+ macro_f1: float = 0.0
+ weighted_f1: float = 0.0
+ cohens_kappa: float = 0.0
+ # ICR metrics (from ICR module, if run)
+ icr_kappa: float = 0.0
+ icr_alpha: float = 0.0
+
+
+@dataclass
+class QualiKitPipelineMetadata:
+ """Metadata collected from a QualiKit analysis session."""
+
+ dataset_name: str = ""
+ n_segments: int = 0
+ deidentification_performed: bool = False
+ n_pii_detected: int = 0
+ n_themes: int = 0
+ theme_names: list[str] = field(default_factory=list)
+ coding_model_name: str = ""
+ coding_model_backend: str = ""
+ # Consensus
+ consensus_coding_used: bool = False
+ n_consensus_models: int = 0
+ consensus_model_names: list[str] = field(default_factory=list)
+ consensus_agreement: float = 0.0
+ # Confidence tiers
+ n_high_confidence: int = 0
+ n_medium_confidence: int = 0
+ n_low_confidence: int = 0
+ # Review
+ n_accepted: int = 0
+ n_rejected: int = 0
+ n_edited: int = 0
+ # ICR
+ icr_jaccard: float = 0.0
+ icr_per_theme_kappa: float = 0.0
+
+
+@dataclass
+class MethodsSection:
+ """Generated methods section output."""
+
+ text_en: str = ""
+ text_zh: str = ""
+ metadata_used: dict = field(default_factory=dict)
+
+
+# ---------------------------------------------------------------------------
+# Methods Writer
+# ---------------------------------------------------------------------------
+
+
+class MethodsWriter:
+ """Generate a Methods section paragraph for academic papers.
+
+ Uses template-based generation with slot-filling from pipeline metadata.
+ No LLM calls — purely deterministic to ensure accuracy.
+
+ Usage::
+
+ writer = MethodsWriter()
+ meta = QuantiKitPipelineMetadata(
+ n_samples=5000, n_classes=3, classification_method="few-shot",
+ model_name="gpt-4o", accuracy=0.87, macro_f1=0.85,
+ )
+ section = writer.generate_quantikit_methods(meta)
+ print(section.text_en)
+ """
+
+ # ------------------------------------------------------------------
+ # Public API
+ # ------------------------------------------------------------------
+
+ def generate_quantikit_methods(
+ self, metadata: QuantiKitPipelineMetadata,
+ ) -> MethodsSection:
+ """Generate methods section for a QuantiKit text classification analysis."""
+ return MethodsSection(
+ text_en=self._build_quantikit_en(metadata),
+ text_zh=self._build_quantikit_zh(metadata),
+ metadata_used=self._meta_to_dict(metadata),
+ )
+
+ def generate_qualikit_methods(
+ self, metadata: QualiKitPipelineMetadata,
+ ) -> MethodsSection:
+ """Generate methods section for a QualiKit qualitative coding analysis."""
+ return MethodsSection(
+ text_en=self._build_qualikit_en(metadata),
+ text_zh=self._build_qualikit_zh(metadata),
+ metadata_used=self._meta_to_dict(metadata),
+ )
+
+ # ------------------------------------------------------------------
+ # QuantiKit — English
+ # ------------------------------------------------------------------
+
+ def _build_quantikit_en(self, m: QuantiKitPipelineMetadata) -> str:
+ parts: list[str] = []
+
+ # Opening
+ n_str = f"{m.n_samples:,}" if m.n_samples else "N"
+ classes_str = f"{m.n_classes}" if m.n_classes else "multiple"
+ labels_str = (
+ f" ({', '.join(m.class_labels)})" if m.class_labels else ""
+ )
+ parts.append(
+ f"We classified {n_str} text samples into {classes_str} "
+ f"categories{labels_str} using SocialSciKit (Sun et al., 2026)."
+ )
+
+ # Classification method
+ method_desc = self._method_desc_en(m.classification_method, m.model_name, m.model_backend)
+ if method_desc:
+ parts.append(method_desc)
+
+ # Annotations
+ if m.n_annotations > 0:
+ parts.append(
+ f"A total of {m.n_annotations} samples were manually annotated "
+ f"to serve as the training/evaluation set."
+ )
+
+ # Prompt optimization
+ if m.prompt_optimization_used:
+ opt_str = (
+ f" across {m.n_prompt_variants} prompt variants"
+ if m.n_prompt_variants > 1 else ""
+ )
+ parts.append(
+ f"Automated Prompt Engineering (APE) was applied to optimize "
+ f"classification prompts{opt_str}."
+ )
+
+ # Evaluation
+ eval_parts = []
+ if m.accuracy > 0:
+ eval_parts.append(f"accuracy of {m.accuracy:.2%}")
+ if m.macro_f1 > 0:
+ eval_parts.append(f"macro-F1 of {m.macro_f1:.4f}")
+ if m.weighted_f1 > 0:
+ eval_parts.append(f"weighted-F1 of {m.weighted_f1:.4f}")
+ if m.cohens_kappa > 0:
+ eval_parts.append(f"Cohen's Kappa of {m.cohens_kappa:.4f}")
+ if eval_parts:
+ parts.append(
+ f"The model achieved {', '.join(eval_parts)} on the held-out "
+ f"evaluation set."
+ )
+
+ # ICR
+ icr_parts = []
+ if m.icr_kappa > 0:
+ icr_parts.append(f"Cohen's Kappa = {m.icr_kappa:.4f}")
+ if m.icr_alpha > 0:
+ icr_parts.append(f"Krippendorff's Alpha = {m.icr_alpha:.4f}")
+ if icr_parts:
+ parts.append(
+ f"Inter-coder reliability was assessed ({', '.join(icr_parts)})."
+ )
+
+ return " ".join(parts)
+
+ # ------------------------------------------------------------------
+ # QuantiKit — Chinese
+ # ------------------------------------------------------------------
+
+ def _build_quantikit_zh(self, m: QuantiKitPipelineMetadata) -> str:
+ parts: list[str] = []
+
+ n_str = f"{m.n_samples:,}" if m.n_samples else "N"
+ classes_str = f"{m.n_classes}" if m.n_classes else "多个"
+ labels_str = (
+ f"({', '.join(m.class_labels)})" if m.class_labels else ""
+ )
+ parts.append(
+ f"本研究使用 SocialSciKit(Sun et al., 2026)对 {n_str} 条文本进行"
+ f" {classes_str} 分类{labels_str}。"
+ )
+
+ method_desc = self._method_desc_zh(m.classification_method, m.model_name, m.model_backend)
+ if method_desc:
+ parts.append(method_desc)
+
+ if m.n_annotations > 0:
+ parts.append(f"共人工标注 {m.n_annotations} 条样本作为训练/评估数据。")
+
+ if m.prompt_optimization_used:
+ opt_str = f",共测试 {m.n_prompt_variants} 种 Prompt 变体" if m.n_prompt_variants > 1 else ""
+ parts.append(f"使用自动提示工程(APE)优化分类 Prompt{opt_str}。")
+
+ eval_parts = []
+ if m.accuracy > 0:
+ eval_parts.append(f"准确率 {m.accuracy:.2%}")
+ if m.macro_f1 > 0:
+ eval_parts.append(f"宏平均 F1 = {m.macro_f1:.4f}")
+ if m.weighted_f1 > 0:
+ eval_parts.append(f"加权 F1 = {m.weighted_f1:.4f}")
+ if m.cohens_kappa > 0:
+ eval_parts.append(f"Cohen's Kappa = {m.cohens_kappa:.4f}")
+ if eval_parts:
+ parts.append(f"模型在评估集上的表现为:{', '.join(eval_parts)}。")
+
+ icr_parts = []
+ if m.icr_kappa > 0:
+ icr_parts.append(f"Cohen's Kappa = {m.icr_kappa:.4f}")
+ if m.icr_alpha > 0:
+ icr_parts.append(f"Krippendorff's Alpha = {m.icr_alpha:.4f}")
+ if icr_parts:
+ parts.append(f"编码者间信度检验:{', '.join(icr_parts)}。")
+
+ return "".join(parts)
+
+ # ------------------------------------------------------------------
+ # QualiKit — English
+ # ------------------------------------------------------------------
+
+ def _build_qualikit_en(self, m: QualiKitPipelineMetadata) -> str:
+ parts: list[str] = []
+
+ n_str = f"{m.n_segments:,}" if m.n_segments else "N"
+ parts.append(
+ f"Qualitative coding was performed on {n_str} text segments "
+ f"using SocialSciKit (Sun et al., 2026)."
+ )
+
+ # De-identification
+ if m.deidentification_performed:
+ pii_str = (
+ f", detecting and masking {m.n_pii_detected} personally "
+ f"identifiable items" if m.n_pii_detected > 0 else ""
+ )
+ parts.append(
+ f"Prior to coding, all texts were de-identified{pii_str}."
+ )
+
+ # Research framework
+ if m.n_themes > 0:
+ themes_str = (
+ f" ({', '.join(m.theme_names[:5])}{'...' if len(m.theme_names) > 5 else ''})"
+ if m.theme_names else ""
+ )
+ parts.append(
+ f"A coding framework with {m.n_themes} themes was defined{themes_str}."
+ )
+
+ # Coding method
+ if m.consensus_coding_used and m.n_consensus_models >= 2:
+ models_str = ", ".join(m.consensus_model_names) if m.consensus_model_names else f"{m.n_consensus_models} models"
+ parts.append(
+ f"Multi-LLM consensus coding was employed: {models_str} independently "
+ f"coded each segment, and themes were retained only when a majority "
+ f"of coders agreed (overall agreement: {m.consensus_agreement:.2%})."
+ )
+ elif m.coding_model_name:
+ backend_str = f" ({m.coding_model_backend})" if m.coding_model_backend else ""
+ parts.append(
+ f"LLM-assisted coding was performed using {m.coding_model_name}"
+ f"{backend_str}."
+ )
+
+ # Confidence tiers
+ total_conf = m.n_high_confidence + m.n_medium_confidence + m.n_low_confidence
+ if total_conf > 0:
+ parts.append(
+ f"Coding confidence was categorized into three tiers: "
+ f"high ({m.n_high_confidence}), medium ({m.n_medium_confidence}), "
+ f"and low ({m.n_low_confidence})."
+ )
+
+ # Human review
+ total_reviewed = m.n_accepted + m.n_rejected + m.n_edited
+ if total_reviewed > 0:
+ parts.append(
+ f"Human review was conducted on all coded segments: "
+ f"{m.n_accepted} accepted, {m.n_rejected} rejected, "
+ f"and {m.n_edited} manually edited."
+ )
+
+ # ICR
+ icr_parts = []
+ if m.icr_jaccard > 0:
+ icr_parts.append(f"Jaccard agreement = {m.icr_jaccard:.4f}")
+ if m.icr_per_theme_kappa > 0:
+ icr_parts.append(f"average per-theme Kappa = {m.icr_per_theme_kappa:.4f}")
+ if icr_parts:
+ parts.append(
+ f"Inter-coder reliability between human review and LLM coding "
+ f"was assessed ({', '.join(icr_parts)})."
+ )
+
+ return " ".join(parts)
+
+ # ------------------------------------------------------------------
+ # QualiKit — Chinese
+ # ------------------------------------------------------------------
+
+ def _build_qualikit_zh(self, m: QualiKitPipelineMetadata) -> str:
+ parts: list[str] = []
+
+ n_str = f"{m.n_segments:,}" if m.n_segments else "N"
+ parts.append(
+ f"本研究使用 SocialSciKit(Sun et al., 2026)对 {n_str} 条文本段落"
+ f"进行质性编码。"
+ )
+
+ if m.deidentification_performed:
+ pii_str = f",共检测并脱敏 {m.n_pii_detected} 项个人信息" if m.n_pii_detected > 0 else ""
+ parts.append(f"编码前对所有文本进行了脱敏处理{pii_str}。")
+
+ if m.n_themes > 0:
+ themes_str = (
+ f"({', '.join(m.theme_names[:5])}{'...' if len(m.theme_names) > 5 else ''})"
+ if m.theme_names else ""
+ )
+ parts.append(f"定义了包含 {m.n_themes} 个主题的编码框架{themes_str}。")
+
+ if m.consensus_coding_used and m.n_consensus_models >= 2:
+ models_str = "、".join(m.consensus_model_names) if m.consensus_model_names else f"{m.n_consensus_models} 个模型"
+ parts.append(
+ f"采用多模型共识编码策略:{models_str}分别独立编码,"
+ f"仅保留多数模型一致的主题标签(总体一致性:{m.consensus_agreement:.2%})。"
+ )
+ elif m.coding_model_name:
+ backend_str = f"({m.coding_model_backend})" if m.coding_model_backend else ""
+ parts.append(f"使用 {m.coding_model_name}{backend_str} 进行 LLM 辅助编码。")
+
+ total_conf = m.n_high_confidence + m.n_medium_confidence + m.n_low_confidence
+ if total_conf > 0:
+ parts.append(
+ f"编码置信度分为三档:高({m.n_high_confidence} 条)、"
+ f"中({m.n_medium_confidence} 条)、低({m.n_low_confidence} 条)。"
+ )
+
+ total_reviewed = m.n_accepted + m.n_rejected + m.n_edited
+ if total_reviewed > 0:
+ parts.append(
+ f"对全部编码进行人工审核:接受 {m.n_accepted} 条、"
+ f"拒绝 {m.n_rejected} 条、手动编辑 {m.n_edited} 条。"
+ )
+
+ icr_parts = []
+ if m.icr_jaccard > 0:
+ icr_parts.append(f"Jaccard 一致性 = {m.icr_jaccard:.4f}")
+ if m.icr_per_theme_kappa > 0:
+ icr_parts.append(f"各主题平均 Kappa = {m.icr_per_theme_kappa:.4f}")
+ if icr_parts:
+ parts.append(f"人工审核与 LLM 编码的编码者间信度:{', '.join(icr_parts)}。")
+
+ return "".join(parts)
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _method_desc_en(method: str, model: str, backend: str) -> str:
+ """Build the classification method sentence (English)."""
+ model_str = model or "the language model"
+ backend_str = f" ({backend})" if backend else ""
+
+ method_map = {
+ "zero-shot": (
+ f"A zero-shot prompting approach was used with "
+ f"{model_str}{backend_str}, where no labeled examples were "
+ f"provided in the prompt."
+ ),
+ "few-shot": (
+ f"A few-shot prompting approach was used with "
+ f"{model_str}{backend_str}, where labeled examples were "
+ f"included in the prompt for in-context learning."
+ ),
+ "fine-tune-local": (
+ f"The model {model_str} was fine-tuned locally on the "
+ f"annotated training data."
+ ),
+ "fine-tune-api": (
+ f"The model {model_str}{backend_str} was fine-tuned via "
+ f"the provider's API on the annotated training data."
+ ),
+ }
+ return method_map.get(method, "")
+
+ @staticmethod
+ def _method_desc_zh(method: str, model: str, backend: str) -> str:
+ """Build the classification method sentence (Chinese)."""
+ model_str = model or "语言模型"
+ backend_str = f"({backend})" if backend else ""
+
+ method_map = {
+ "zero-shot": (
+ f"采用零样本提示(zero-shot prompting)方法,"
+ f"使用 {model_str}{backend_str},Prompt 中不包含标注示例。"
+ ),
+ "few-shot": (
+ f"采用少样本提示(few-shot prompting)方法,"
+ f"使用 {model_str}{backend_str},Prompt 中包含标注示例进行上下文学习。"
+ ),
+ "fine-tune-local": (
+ f"在标注训练数据上对 {model_str} 进行本地微调。"
+ ),
+ "fine-tune-api": (
+ f"通过 API 在标注训练数据上对 {model_str}{backend_str} 进行微调。"
+ ),
+ }
+ return method_map.get(method, "")
+
+ @staticmethod
+ def _meta_to_dict(metadata: object) -> dict:
+ """Convert a metadata dataclass to a plain dict."""
+ if hasattr(metadata, "__dataclass_fields__"):
+ from dataclasses import asdict
+ return asdict(metadata)
+ return {}
diff --git a/socialscikit/core/project_io.py b/socialscikit/core/project_io.py
new file mode 100644
index 0000000..a7797a3
--- /dev/null
+++ b/socialscikit/core/project_io.py
@@ -0,0 +1,394 @@
+"""Project save & restore — serialize / deserialize all session state to JSON.
+
+Allows users to save their progress and resume later without losing any
+annotation, coding, or review state. All complex types are converted to
+plain dicts with a ``__type__`` discriminator for safe, human-readable
+round-tripping.
+
+Usage::
+
+ from socialscikit.core.project_io import save_project, load_project
+
+ json_str = save_project({"qt_df": df, "ql_segments": segments, ...})
+ states = load_project(json_str)
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import time
+from typing import Any
+
+import pandas as pd
+
+from socialscikit.quantikit.annotator import (
+ Annotation,
+ AnnotationSession,
+ AnnotationStatus,
+)
+from socialscikit.qualikit.extraction_reviewer import (
+ ExtractionReviewSession,
+ ReviewAction,
+ ReviewedExtraction,
+)
+from socialscikit.qualikit.segment_extractor import (
+ ExtractionResult,
+ ResearchQuestion,
+)
+from socialscikit.qualikit.segmenter import TextPosition, TextSegment
+
+logger = logging.getLogger(__name__)
+
+PROJECT_VERSION = "1.0"
+
+
+# ======================================================================
+# Serializers (Python object → JSON-safe dict)
+# ======================================================================
+
+
+def _ser_dataframe(df: pd.DataFrame) -> dict:
+ return {
+ "__type__": "DataFrame",
+ "records": json.loads(df.to_json(orient="records", force_ascii=False)),
+ "columns": list(df.columns),
+ }
+
+
+def _ser_text_position(pos: TextPosition) -> dict:
+ return {
+ "__type__": "TextPosition",
+ "line_start": pos.line_start,
+ "line_end": pos.line_end,
+ "char_start": pos.char_start,
+ "char_end": pos.char_end,
+ "paragraph_index": pos.paragraph_index,
+ }
+
+
+def _ser_text_segment(seg: TextSegment) -> dict:
+ return {
+ "__type__": "TextSegment",
+ "segment_id": seg.segment_id,
+ "text": seg.text,
+ "position": _ser_text_position(seg.position),
+ "core_sentence": seg.core_sentence,
+ "core_char_start": seg.core_char_start,
+ "core_char_end": seg.core_char_end,
+ }
+
+
+def _ser_research_question(rq: ResearchQuestion) -> dict:
+ return {
+ "__type__": "ResearchQuestion",
+ "rq_id": rq.rq_id,
+ "description": rq.description,
+ "sub_themes": list(rq.sub_themes),
+ }
+
+
+def _ser_extraction_result(r: ExtractionResult) -> dict:
+ return {
+ "__type__": "ExtractionResult",
+ "segment_id": r.segment_id,
+ "text": r.text,
+ "rq_label": r.rq_label,
+ "sub_theme": r.sub_theme,
+ "confidence": r.confidence,
+ "reasoning": r.reasoning,
+ "evidence_span": getattr(r, "evidence_span", ""),
+ "position": _ser_text_position(r.position) if r.position else None,
+ }
+
+
+def _ser_reviewed_extraction(item: ReviewedExtraction) -> dict:
+ return {
+ "__type__": "ReviewedExtraction",
+ "result": _ser_extraction_result(item.result),
+ "action": item.action.value,
+ "edited_rq_label": item.edited_rq_label,
+ "edited_sub_theme": item.edited_sub_theme,
+ }
+
+
+def _ser_extraction_review_session(sess: ExtractionReviewSession) -> dict:
+ return {
+ "__type__": "ExtractionReviewSession",
+ "items": [_ser_reviewed_extraction(i) for i in sess.items],
+ "original_text": sess.original_text,
+ "segments": [_ser_text_segment(s) for s in sess.segments],
+ "research_questions": [_ser_research_question(rq) for rq in sess.research_questions],
+ }
+
+
+def _ser_annotation(a: Annotation) -> dict:
+ return {
+ "__type__": "Annotation",
+ "idx": a.idx,
+ "text": a.text,
+ "label": a.label,
+ "status": a.status.value,
+ "timestamp": a.timestamp,
+ "annotator_note": a.annotator_note,
+ }
+
+
+def _ser_annotation_session(sess: AnnotationSession) -> dict:
+ elapsed = time.monotonic() - sess._start_time
+ return {
+ "__type__": "AnnotationSession",
+ "labels": list(sess.labels),
+ "items": [_ser_annotation(a) for a in sess._items],
+ "cursor": sess._cursor,
+ "history": list(sess._history),
+ "elapsed_seconds": round(elapsed, 1),
+ }
+
+
+# ======================================================================
+# Deserializers (JSON-safe dict → Python object)
+# ======================================================================
+
+
+def _de_dataframe(d: dict) -> pd.DataFrame:
+ df = pd.DataFrame(d["records"])
+ # Restore column order
+ if d.get("columns"):
+ cols = [c for c in d["columns"] if c in df.columns]
+ df = df[cols]
+ return df
+
+
+def _de_text_position(d: dict) -> TextPosition:
+ return TextPosition(
+ line_start=d["line_start"],
+ line_end=d["line_end"],
+ char_start=d["char_start"],
+ char_end=d["char_end"],
+ paragraph_index=d.get("paragraph_index", 0),
+ )
+
+
+def _de_text_segment(d: dict) -> TextSegment:
+ return TextSegment(
+ segment_id=d["segment_id"],
+ text=d["text"],
+ position=_de_text_position(d["position"]),
+ core_sentence=d.get("core_sentence"),
+ core_char_start=d.get("core_char_start"),
+ core_char_end=d.get("core_char_end"),
+ )
+
+
+def _de_research_question(d: dict) -> ResearchQuestion:
+ return ResearchQuestion(
+ rq_id=d["rq_id"],
+ description=d["description"],
+ sub_themes=d.get("sub_themes", []),
+ )
+
+
+def _de_extraction_result(d: dict) -> ExtractionResult:
+ pos_data = d.get("position")
+ return ExtractionResult(
+ segment_id=d["segment_id"],
+ text=d["text"],
+ rq_label=d["rq_label"],
+ sub_theme=d["sub_theme"],
+ confidence=d["confidence"],
+ reasoning=d.get("reasoning", ""),
+ evidence_span=d.get("evidence_span", ""),
+ position=_de_text_position(pos_data) if pos_data else None,
+ )
+
+
+def _de_reviewed_extraction(d: dict) -> ReviewedExtraction:
+ return ReviewedExtraction(
+ result=_de_extraction_result(d["result"]),
+ action=ReviewAction(d["action"]),
+ edited_rq_label=d.get("edited_rq_label"),
+ edited_sub_theme=d.get("edited_sub_theme"),
+ )
+
+
+def _de_extraction_review_session(d: dict) -> ExtractionReviewSession:
+ return ExtractionReviewSession(
+ items=[_de_reviewed_extraction(i) for i in d.get("items", [])],
+ original_text=d.get("original_text", ""),
+ segments=[_de_text_segment(s) for s in d.get("segments", [])],
+ research_questions=[_de_research_question(rq) for rq in d.get("research_questions", [])],
+ )
+
+
+def _de_annotation(d: dict) -> Annotation:
+ return Annotation(
+ idx=d["idx"],
+ text=d["text"],
+ label=d.get("label"),
+ status=AnnotationStatus(d.get("status", "pending")),
+ timestamp=d.get("timestamp"),
+ annotator_note=d.get("annotator_note", ""),
+ )
+
+
+def _de_annotation_session(d: dict) -> AnnotationSession:
+ items = [_de_annotation(a) for a in d.get("items", [])]
+ labels = d.get("labels", [])
+ sess = AnnotationSession(items=items, labels=labels, shuffle=False)
+ sess._cursor = d.get("cursor", 0)
+ sess._history = d.get("history", [])
+ elapsed = d.get("elapsed_seconds", 0.0)
+ sess._start_time = time.monotonic() - elapsed
+ return sess
+
+
+# ======================================================================
+# Top-level dispatch
+# ======================================================================
+
+_TYPE_SERIALIZERS = {
+ "DataFrame": _ser_dataframe,
+ "TextPosition": _ser_text_position,
+ "TextSegment": _ser_text_segment,
+ "ResearchQuestion": _ser_research_question,
+ "ExtractionResult": _ser_extraction_result,
+ "ReviewedExtraction": _ser_reviewed_extraction,
+ "ExtractionReviewSession": _ser_extraction_review_session,
+ "Annotation": _ser_annotation,
+ "AnnotationSession": _ser_annotation_session,
+}
+
+_TYPE_DESERIALIZERS = {
+ "DataFrame": _de_dataframe,
+ "TextPosition": _de_text_position,
+ "TextSegment": _de_text_segment,
+ "ResearchQuestion": _de_research_question,
+ "ExtractionResult": _de_extraction_result,
+ "ReviewedExtraction": _de_reviewed_extraction,
+ "ExtractionReviewSession": _de_extraction_review_session,
+ "Annotation": _de_annotation,
+ "AnnotationSession": _de_annotation_session,
+}
+
+_TYPE_MAP = {
+ pd.DataFrame: "DataFrame",
+ TextPosition: "TextPosition",
+ TextSegment: "TextSegment",
+ ResearchQuestion: "ResearchQuestion",
+ ExtractionResult: "ExtractionResult",
+ ReviewedExtraction: "ReviewedExtraction",
+ ExtractionReviewSession: "ExtractionReviewSession",
+ Annotation: "Annotation",
+ AnnotationSession: "AnnotationSession",
+}
+
+
+def _serialize_value(val: Any) -> Any:
+ """Recursively serialize a value to JSON-safe form."""
+ if val is None:
+ return None
+ # Check known types
+ type_name = _TYPE_MAP.get(type(val))
+ if type_name:
+ return _TYPE_SERIALIZERS[type_name](val)
+ # Lists
+ if isinstance(val, list):
+ return [_serialize_value(v) for v in val]
+ # Dicts
+ if isinstance(val, dict):
+ return {str(k): _serialize_value(v) for k, v in val.items()}
+ # Primitives
+ if isinstance(val, (str, int, float, bool)):
+ return val
+ # Fallback: try str
+ logger.warning("Cannot serialize type %s, converting to str", type(val).__name__)
+ return str(val)
+
+
+def _deserialize_value(val: Any) -> Any:
+ """Recursively deserialize a JSON-safe value back to Python objects."""
+ if val is None:
+ return None
+ if isinstance(val, dict):
+ type_tag = val.get("__type__")
+ if type_tag and type_tag in _TYPE_DESERIALIZERS:
+ return _TYPE_DESERIALIZERS[type_tag](val)
+ # Regular dict
+ return {k: _deserialize_value(v) for k, v in val.items()}
+ if isinstance(val, list):
+ return [_deserialize_value(v) for v in val]
+ return val
+
+
+# ======================================================================
+# Public API
+# ======================================================================
+
+
+def save_project(states: dict[str, Any]) -> str:
+ """Serialize all session states to a JSON string.
+
+ Parameters
+ ----------
+ states : dict
+ Mapping of state names to their Python values. Keys include
+ ``qt_df``, ``qt_result_df``, ``qt_ann_session``, ``ql_raw_text``,
+ ``ql_segments``, ``ql_rqs``, ``ql_ext_session``, ``ql_lang``.
+
+ Returns
+ -------
+ str
+ JSON string that can be written to a file.
+ """
+ payload: dict[str, Any] = {
+ "__project_version__": PROJECT_VERSION,
+ "__toolkit__": "SocialSciKit",
+ }
+ for key, val in states.items():
+ try:
+ payload[key] = _serialize_value(val)
+ except Exception as e:
+ logger.warning("Failed to serialize '%s': %s", key, e)
+ payload[key] = None
+ return json.dumps(payload, ensure_ascii=False, indent=2)
+
+
+def load_project(json_str: str) -> dict[str, Any]:
+ """Deserialize a JSON project file back into session states.
+
+ Parameters
+ ----------
+ json_str : str
+ The JSON string from a previously saved project file.
+
+ Returns
+ -------
+ dict
+ Mapping of state names to reconstructed Python objects.
+
+ Raises
+ ------
+ ValueError
+ If the JSON cannot be parsed or is not a valid project file.
+ """
+ try:
+ raw = json.loads(json_str)
+ except json.JSONDecodeError as e:
+ raise ValueError(f"Invalid project file: {e}") from e
+
+ if not isinstance(raw, dict):
+ raise ValueError("Project file must be a JSON object.")
+
+ version = raw.pop("__project_version__", "unknown")
+ raw.pop("__toolkit__", None)
+ logger.info("Loading project file (version %s)", version)
+
+ states: dict[str, Any] = {}
+ for key, val in raw.items():
+ try:
+ states[key] = _deserialize_value(val)
+ except Exception as e:
+ logger.warning("Failed to deserialize '%s': %s", key, e)
+ states[key] = None
+ return states
diff --git a/socialscikit/qualikit/__init__.py b/socialscikit/qualikit/__init__.py
index 57687e6..871f230 100644
--- a/socialscikit/qualikit/__init__.py
+++ b/socialscikit/qualikit/__init__.py
@@ -13,9 +13,15 @@
ReviewAction,
ReviewedExtraction,
)
+from socialscikit.qualikit.consensus import (
+ ConsensusCoder,
+ ConsensusReport,
+ SegmentConsensus,
+)
__all__ = [
"Segmenter", "TextPosition", "TextSegment",
"ExtractionReport", "ExtractionResult", "ResearchQuestion", "SegmentExtractor",
"ExtractionReviewer", "ExtractionReviewSession", "ReviewAction", "ReviewedExtraction",
+ "ConsensusCoder", "ConsensusReport", "SegmentConsensus",
]
diff --git a/socialscikit/qualikit/coder.py b/socialscikit/qualikit/coder.py
index fa366ea..88749d7 100644
--- a/socialscikit/qualikit/coder.py
+++ b/socialscikit/qualikit/coder.py
@@ -44,6 +44,7 @@ class CodingResult:
themes: list[str] = field(default_factory=list)
confidences: dict[str, float] = field(default_factory=dict)
trigger_words: dict[str, list[str]] = field(default_factory=dict)
+ evidence_spans: dict[str, str] = field(default_factory=dict)
reasoning: str = ""
@property
@@ -103,12 +104,13 @@ def low_confidence_count(self) -> int:
Assign relevant themes to this text. For each theme:
1. Confidence (0.0-1.0): how certain you are this theme applies
2. Trigger words: specific words/phrases from the text that support this coding
-3. Brief reasoning
+3. Evidence span: copy the exact phrase or sentence from the text that most directly supports this coding (verbatim, do not paraphrase)
+4. Brief reasoning
If NO themes apply, return an empty "themes" array.
Return ONLY valid JSON (no markdown fencing):
-{{"themes": [{{"name": "theme_name", "confidence": 0.85, "trigger_words": ["word1", "word2"], "reasoning": "brief reason"}}]}}"""
+{{"themes": [{{"name": "theme_name", "confidence": 0.85, "trigger_words": ["word1", "word2"], "evidence_span": "exact quote from text", "reasoning": "brief reason"}}]}}"""
# ---------------------------------------------------------------------------
@@ -253,6 +255,7 @@ def _parse_response(
assigned_themes = []
confidences = {}
trigger_words = {}
+ evidence_spans = {}
reasoning_parts = []
raw_themes = parsed.get("themes", [])
@@ -271,9 +274,13 @@ def _parse_response(
triggers = item.get("trigger_words", [])
reason = item.get("reasoning", "")
+ evidence = str(item.get("evidence_span", "")).strip()
+
assigned_themes.append(matched)
confidences[matched] = round(conf, 3)
trigger_words[matched] = triggers if isinstance(triggers, list) else []
+ if evidence:
+ evidence_spans[matched] = evidence
if reason:
reasoning_parts.append(f"{matched}: {reason}")
@@ -283,6 +290,7 @@ def _parse_response(
themes=assigned_themes,
confidences=confidences,
trigger_words=trigger_words,
+ evidence_spans=evidence_spans,
reasoning="; ".join(reasoning_parts),
)
diff --git a/socialscikit/qualikit/consensus.py b/socialscikit/qualikit/consensus.py
new file mode 100644
index 0000000..9533682
--- /dev/null
+++ b/socialscikit/qualikit/consensus.py
@@ -0,0 +1,369 @@
+"""Multi-LLM Consensus Coding — run multiple LLMs, majority vote, agreement report.
+
+Run 2–3 different LLMs independently on the same text segments. For each
+segment the final theme set is determined by majority vote: a theme is
+included only if ≥ ceil(n_coders / 2) coders assigned it.
+
+The merged results are standard ``CodingResult`` objects so they flow
+through the existing ``ConfidenceRanker → CodingReviewer → Exporter``
+pipeline unchanged.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import math
+from collections import Counter
+from dataclasses import dataclass, field
+
+from socialscikit.core.llm_client import LLMClient
+from socialscikit.qualikit.coder import Coder, CodingReport, CodingResult
+from socialscikit.qualikit.theme_definer import Theme
+
+
+# ---------------------------------------------------------------------------
+# Data classes
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class SegmentConsensus:
+ """Consensus result for a single text segment."""
+
+ text_id: int
+ text: str
+ consensus_themes: list[str] = field(default_factory=list)
+ consensus_confidences: dict[str, float] = field(default_factory=dict)
+ agreement_rate: float = 0.0
+ individual_results: list[CodingResult] = field(default_factory=list)
+ vote_counts: dict[str, int] = field(default_factory=dict)
+
+ def to_coding_result(self) -> CodingResult:
+ """Convert to a standard CodingResult for downstream compatibility."""
+ # Merge trigger words from all coders who assigned consensus themes
+ merged_triggers: dict[str, list[str]] = {}
+ merged_reasoning_parts: list[str] = []
+
+ for theme in self.consensus_themes:
+ triggers_set: set[str] = set()
+ for r in self.individual_results:
+ if theme in r.themes:
+ triggers_set.update(r.trigger_words.get(theme, []))
+ merged_triggers[theme] = list(triggers_set)
+
+ # Merge reasoning
+ for r in self.individual_results:
+ if r.reasoning:
+ merged_reasoning_parts.append(r.reasoning)
+
+ return CodingResult(
+ text_id=self.text_id,
+ text=self.text,
+ themes=self.consensus_themes,
+ confidences=self.consensus_confidences,
+ trigger_words=merged_triggers,
+ reasoning=" | ".join(merged_reasoning_parts) if merged_reasoning_parts else "",
+ )
+
+
+@dataclass
+class ConsensusReport:
+ """Full report from a consensus coding run."""
+
+ segments: list[SegmentConsensus] = field(default_factory=list)
+ n_coders: int = 0
+ coder_models: list[str] = field(default_factory=list)
+ n_total: int = 0
+ n_coded: int = 0
+ n_failed: int = 0
+ overall_agreement: float = 0.0
+ theme_distribution: dict[str, int] = field(default_factory=dict)
+ per_coder_cost: list[float] = field(default_factory=list)
+ total_cost: float = 0.0
+
+ def to_coding_report(self) -> CodingReport:
+ """Convert to a standard CodingReport for downstream compatibility."""
+ results = [seg.to_coding_result() for seg in self.segments]
+ return CodingReport(
+ results=results,
+ n_total=self.n_total,
+ n_coded=self.n_coded,
+ n_failed=self.n_failed,
+ theme_distribution=dict(self.theme_distribution),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Consensus Coder
+# ---------------------------------------------------------------------------
+
+
+class ConsensusCoder:
+ """Multi-LLM consensus coding engine.
+
+ Instantiates a ``Coder`` per ``LLMClient``, runs all coders on the same
+ segments, then merges results via majority vote.
+
+ Usage::
+
+ clients = [
+ LLMClient(backend="openai", model="gpt-4o"),
+ LLMClient(backend="anthropic", model="claude-sonnet-4-20250514"),
+ ]
+ consensus = ConsensusCoder(clients)
+ report = consensus.code(texts, themes)
+ """
+
+ def __init__(
+ self,
+ llm_clients: list[LLMClient],
+ majority_threshold: int | None = None,
+ ):
+ """
+ Parameters
+ ----------
+ llm_clients : list[LLMClient]
+ At least 2 LLM clients for consensus coding.
+ majority_threshold : int or None
+ Minimum number of coders that must agree for a theme to be included.
+ Defaults to ``ceil(len(llm_clients) / 2)``.
+ """
+ if len(llm_clients) < 2:
+ raise ValueError("Consensus coding requires at least 2 LLM clients.")
+
+ self.llm_clients = llm_clients
+ self.coders = [Coder(client) for client in llm_clients]
+ self.n_coders = len(llm_clients)
+ self.majority_threshold = majority_threshold or math.ceil(self.n_coders / 2)
+
+ def code(
+ self,
+ texts: list[str],
+ themes: list[Theme],
+ ) -> ConsensusReport:
+ """Synchronous consensus coding — runs coders sequentially.
+
+ Parameters
+ ----------
+ texts : list[str]
+ Text segments to code.
+ themes : list[Theme]
+ Theme definitions.
+
+ Returns
+ -------
+ ConsensusReport
+ """
+ all_reports: list[CodingReport] = []
+ for coder in self.coders:
+ report = coder.code(texts, themes)
+ all_reports.append(report)
+
+ return self._merge_results(all_reports, texts)
+
+ async def code_async(
+ self,
+ texts: list[str],
+ themes: list[Theme],
+ batch_size: int = 50,
+ ) -> ConsensusReport:
+ """Async consensus coding — runs coders in parallel.
+
+ Parameters
+ ----------
+ texts : list[str]
+ themes : list[Theme]
+ batch_size : int
+ Batch size for each coder's async coding.
+
+ Returns
+ -------
+ ConsensusReport
+ """
+ tasks = [
+ coder.code_async(texts, themes, batch_size)
+ for coder in self.coders
+ ]
+ all_reports = await asyncio.gather(*tasks, return_exceptions=True)
+
+ # Filter out exceptions
+ valid_reports: list[CodingReport] = []
+ for r in all_reports:
+ if isinstance(r, CodingReport):
+ valid_reports.append(r)
+
+ if len(valid_reports) < 2:
+ raise RuntimeError(
+ f"Only {len(valid_reports)}/{self.n_coders} coders succeeded. "
+ "Need at least 2 for consensus."
+ )
+
+ return self._merge_results(valid_reports, texts)
+
+ # ------------------------------------------------------------------
+ # Internal: merge results
+ # ------------------------------------------------------------------
+
+ def _merge_results(
+ self,
+ all_reports: list[CodingReport],
+ texts: list[str],
+ ) -> ConsensusReport:
+ """Apply majority vote to merge results from multiple coders."""
+ n_coders = len(all_reports)
+ threshold = self.majority_threshold
+ n_total = len(texts)
+
+ segments: list[SegmentConsensus] = []
+ theme_dist: dict[str, int] = Counter()
+ n_failed = 0
+ agreement_sum = 0.0
+
+ for idx in range(n_total):
+ # Gather individual results for this segment
+ individual: list[CodingResult] = []
+ for report in all_reports:
+ if idx < len(report.results):
+ individual.append(report.results[idx])
+ else:
+ individual.append(CodingResult(text_id=idx, text=texts[idx]))
+
+ # Majority vote
+ consensus_themes, avg_confs, votes = self._majority_vote(
+ individual, threshold,
+ )
+
+ # Agreement rate: for each theme mentioned by any coder,
+ # agreement = votes / n_coders. Average across all mentioned themes.
+ if votes:
+ all_mentioned_themes = set(votes.keys())
+ rates = [votes[t] / n_coders for t in all_mentioned_themes]
+ seg_agreement = sum(rates) / len(rates)
+ else:
+ # All coders agree: no themes
+ seg_agreement = 1.0
+
+ agreement_sum += seg_agreement
+
+ for t in consensus_themes:
+ theme_dist[t] = theme_dist.get(t, 0) + 1
+
+ if not any(r.themes for r in individual):
+ n_failed += 1
+
+ segments.append(SegmentConsensus(
+ text_id=idx,
+ text=texts[idx],
+ consensus_themes=consensus_themes,
+ consensus_confidences=avg_confs,
+ agreement_rate=round(seg_agreement, 4),
+ individual_results=individual,
+ vote_counts=votes,
+ ))
+
+ overall_agreement = agreement_sum / n_total if n_total > 0 else 0.0
+
+ # Cost tracking
+ coder_models = []
+ per_coder_cost = []
+ for i, client in enumerate(self.llm_clients[:n_coders]):
+ coder_models.append(f"{client.backend}:{client.model}")
+ # Sum cost from call log
+ cost = sum(log.cost_usd for log in client.call_log) if client.call_log else 0.0
+ per_coder_cost.append(cost)
+
+ return ConsensusReport(
+ segments=segments,
+ n_coders=n_coders,
+ coder_models=coder_models,
+ n_total=n_total,
+ n_coded=n_total - n_failed,
+ n_failed=n_failed,
+ overall_agreement=round(overall_agreement, 4),
+ theme_distribution=dict(theme_dist),
+ per_coder_cost=per_coder_cost,
+ total_cost=sum(per_coder_cost),
+ )
+
+ @staticmethod
+ def _majority_vote(
+ individual_results: list[CodingResult],
+ threshold: int,
+ ) -> tuple[list[str], dict[str, float], dict[str, int]]:
+ """Compute majority vote for a single segment.
+
+ Returns
+ -------
+ consensus_themes : list[str]
+ Themes meeting the majority threshold.
+ avg_confidences : dict[str, float]
+ Average confidence among coders who assigned each consensus theme.
+ vote_counts : dict[str, int]
+ Theme -> number of coders who assigned it (for ALL mentioned themes).
+ """
+ # Count votes for each theme
+ vote_counts: dict[str, int] = Counter()
+ confidence_sums: dict[str, float] = {}
+ confidence_counts: dict[str, int] = {}
+
+ for result in individual_results:
+ for theme in result.themes:
+ vote_counts[theme] += 1
+ confidence_sums[theme] = confidence_sums.get(theme, 0.0) + result.confidences.get(theme, 0.5)
+ confidence_counts[theme] = confidence_counts.get(theme, 0) + 1
+
+ # Filter by threshold
+ consensus_themes = [
+ t for t, count in vote_counts.items() if count >= threshold
+ ]
+ consensus_themes.sort()
+
+ # Average confidence for consensus themes
+ avg_confidences = {}
+ for t in consensus_themes:
+ avg_confidences[t] = round(
+ confidence_sums[t] / confidence_counts[t], 3
+ )
+
+ return consensus_themes, avg_confidences, dict(vote_counts)
+
+ # ------------------------------------------------------------------
+ # Formatting
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def format_report(report: ConsensusReport, lang: str = "zh") -> str:
+ """Format a ConsensusReport as a human-readable string."""
+ lines: list[str] = []
+
+ if lang == "zh":
+ lines.append("═══ 多模型共识编码报告 ═══")
+ lines.append("")
+ lines.append(f"编码模型数量:{report.n_coders}")
+ lines.append(f"模型列表:{', '.join(report.coder_models)}")
+ lines.append(f"总文本数:{report.n_total}")
+ lines.append(f"成功编码:{report.n_coded}")
+ lines.append(f"总体一致性:{report.overall_agreement:.2%}")
+ lines.append(f"总费用:${report.total_cost:.4f}")
+ else:
+ lines.append("═══ Multi-LLM Consensus Coding Report ═══")
+ lines.append("")
+ lines.append(f"Number of coders: {report.n_coders}")
+ lines.append(f"Models: {', '.join(report.coder_models)}")
+ lines.append(f"Total segments: {report.n_total}")
+ lines.append(f"Successfully coded: {report.n_coded}")
+ lines.append(f"Overall agreement: {report.overall_agreement:.2%}")
+ lines.append(f"Total cost: ${report.total_cost:.4f}")
+
+ lines.append("")
+
+ if report.theme_distribution:
+ dist_label = "主题分布" if lang == "zh" else "Theme Distribution"
+ lines.append(f"── {dist_label} ──")
+ for theme, count in sorted(
+ report.theme_distribution.items(), key=lambda x: -x[1],
+ ):
+ lines.append(f" {theme}: {count}")
+ lines.append("")
+
+ return "\n".join(lines)
diff --git a/socialscikit/qualikit/extraction_reviewer.py b/socialscikit/qualikit/extraction_reviewer.py
index 70a1ba9..cb6e785 100644
--- a/socialscikit/qualikit/extraction_reviewer.py
+++ b/socialscikit/qualikit/extraction_reviewer.py
@@ -216,5 +216,6 @@ def export_to_dataframe(self, session: ExtractionReviewSession) -> pd.DataFrame:
"paragraph_index": pos.paragraph_index if pos else 0,
"review_status": item.action.value,
"reasoning": item.result.reasoning,
+ "evidence_span": getattr(item.result, "evidence_span", ""),
})
return pd.DataFrame(rows) if rows else pd.DataFrame()
diff --git a/socialscikit/qualikit/segment_extractor.py b/socialscikit/qualikit/segment_extractor.py
index 22985b0..4e4b38d 100644
--- a/socialscikit/qualikit/segment_extractor.py
+++ b/socialscikit/qualikit/segment_extractor.py
@@ -46,6 +46,7 @@ class ExtractionResult:
sub_theme: str # sub-theme label
confidence: float # 0.0 – 1.0
reasoning: str = ""
+ evidence_span: str = "" # verbatim quote from text supporting the coding
position: TextPosition | None = None
@@ -87,9 +88,10 @@ class ExtractionReport:
2. 只在文本明确涉及时才标记,避免过度匹配
3. 不相关的段落不要包含在结果中
4. 如果研究问题指定了子主题列表,sub_theme 必须从中选择;如果标注了「自动生成」,请自行生成 2-8 字的子主题标签
+5. evidence_span: 从原文中逐字复制最能支持该编码判断的关键短语或句子(原文原样,不要改写)
仅返回 JSON,不要 markdown 代码块:
-{{"matches": [{{"segment_id": 1, "rq_id": "RQ1", "sub_theme": "子主题名", "confidence": 0.85, "reasoning": "简要依据"}}]}}"""
+{{"matches": [{{"segment_id": 1, "rq_id": "RQ1", "sub_theme": "子主题名", "confidence": 0.85, "reasoning": "简要依据", "evidence_span": "原文中的关键句"}}]}}"""
# ---------------------------------------------------------------------------
@@ -274,6 +276,7 @@ def _parse_response(
sub_theme=sub_theme or "未分类",
confidence=round(confidence, 3),
reasoning=str(item.get("reasoning", "")).strip(),
+ evidence_span=str(item.get("evidence_span", "")).strip(),
))
return results
diff --git a/socialscikit/ui/i18n.py b/socialscikit/ui/i18n.py
index d05d390..5d8a71f 100644
--- a/socialscikit/ui/i18n.py
+++ b/socialscikit/ui/i18n.py
@@ -23,8 +23,8 @@ def t(key: str, lang: str = "en") -> str:
"zh": "# SocialSciKit",
},
"landing.subtitle": {
- "en": "Zero-code text analysis toolkit for social science researchers",
- "zh": "面向社会科学研究者的零代码文本分析工具",
+ "en": "Zero-code text analysis & research methods toolkit for social science researchers",
+ "zh": "面向社会科学研究者的零代码文本分析与研究方法工具包",
},
"landing.quantikit_card": {
"en": (
@@ -70,18 +70,36 @@ def t(key: str, lang: str = "en") -> str:
"- 摘录表 + 共现矩阵 + 分析备忘录\n"
),
},
+ "landing.toolbox_card": {
+ "en": (
+ "### Toolbox — Research Methods Tools\n\n"
+ "**Standalone tools** that work independently or together with QuantiKit / QualiKit:\n\n"
+ "- **ICR Calculator** — Inter-coder reliability (Cohen's Kappa, Krippendorff's Alpha, multi-label Jaccard); supports 2+ coders with auto metric selection\n"
+ "- **Consensus Coding** — Multi-LLM majority-vote coding with 2\u20135 configurable LLM backends\n"
+ "- **Methods Generator** — Auto-generate methods section paragraphs (EN/ZH) from pipeline logs or manual input\n"
+ ),
+ "zh": (
+ "### 工具箱 — 研究方法工具\n\n"
+ "**独立工具**,可单独使用或与 QuantiKit / QualiKit 搭配:\n\n"
+ "- **ICR 计算器** — 编码者间信度(Cohen's Kappa、Krippendorff's Alpha、多标签 Jaccard);支持 2+ 编码者自动选择指标\n"
+ "- **共识编码** — 多 LLM 多数投票编码,支持 2\u20135 个可配置的 LLM 后端\n"
+ "- **方法论生成器** — 从流水线日志或手动输入自动生成方法部分段落(中英双语)\n"
+ ),
+ },
"landing.quickstart": {
"en": (
"### Quick Start\n\n"
- "1. Click the **QuantiKit** or **QualiKit** tab above to enter the corresponding module\n"
+ "1. Click the **QuantiKit**, **QualiKit**, or **Toolbox** tab above to enter the corresponding module\n"
"2. Follow the numbered steps in order \u2014 results at each step can be reviewed and edited before proceeding\n"
- "3. When LLM features are needed, provide an API Key (OpenAI / Anthropic) or use Ollama for local inference"
+ "3. When LLM features are needed, provide an API Key (OpenAI / Anthropic) or use Ollama for local inference\n"
+ "4. Use the **Toolbox** for standalone tools: ICR calculation, multi-LLM consensus coding, or auto-generating methods sections"
),
"zh": (
"### 快速开始\n\n"
- "1. 点击上方 **QuantiKit** 或 **QualiKit** 标签页进入对应模块\n"
+ "1. 点击上方 **QuantiKit**、**QualiKit** 或 **工具箱** 标签页进入对应模块\n"
"2. 按步骤编号依次操作 \u2014 每步结果可审核编辑,确认后再进入下一步\n"
- "3. 需要 LLM 功能时提供 API Key(OpenAI / Anthropic),或使用 Ollama 本地推理"
+ "3. 需要 LLM 功能时提供 API Key(OpenAI / Anthropic),或使用 Ollama 本地推理\n"
+ "4. 使用**工具箱**可独立进行:ICR 信度计算、多 LLM 共识编码、自动生成方法论段落"
),
},
"landing.examples": {
@@ -90,14 +108,20 @@ def t(key: str, lang: str = "en") -> str:
"`examples/sentiment_example.csv` (sentiment classification \u00b7 QuantiKit) \u00b7 "
"`examples/policy_example.csv` (policy instrument classification \u00b7 QuantiKit) \u00b7 "
"`examples/interview_example.txt` (single interview \u00b7 QualiKit) \u00b7 "
- "`examples/interview_focus_group.txt` (focus group \u00b7 QualiKit)"
+ "`examples/interview_focus_group.txt` (focus group \u00b7 QualiKit) \u00b7 "
+ "`examples/icr_example.csv` (inter-coder reliability \u00b7 Toolbox) \u00b7 "
+ "`examples/consensus_example.csv` (consensus coding \u00b7 Toolbox) \u00b7 "
+ "`examples/methods_log_quantikit.json` / `methods_log_qualikit.json` (methods generator \u00b7 Toolbox)"
),
"zh": (
"**示例数据:**\n"
"`examples/sentiment_example.csv`(情感分类 \u00b7 QuantiKit)\u00b7 "
"`examples/policy_example.csv`(政策工具分类 \u00b7 QuantiKit)\u00b7 "
"`examples/interview_example.txt`(单人访谈 \u00b7 QualiKit)\u00b7 "
- "`examples/interview_focus_group.txt`(焦点小组 \u00b7 QualiKit)"
+ "`examples/interview_focus_group.txt`(焦点小组 \u00b7 QualiKit)\u00b7 "
+ "`examples/icr_example.csv`(编码者间信度 \u00b7 工具箱)\u00b7 "
+ "`examples/consensus_example.csv`(共识编码 \u00b7 工具箱)\u00b7 "
+ "`examples/methods_log_quantikit.json` / `methods_log_qualikit.json`(方法论生成 \u00b7 工具箱)"
),
},
"landing.references": {
@@ -536,6 +560,22 @@ def t(key: str, lang: str = "en") -> str:
"en": "Add",
"zh": "添加",
},
+ "ql.s5.charts_title": {
+ "en": "Visualization Dashboard",
+ "zh": "可视化仪表盘",
+ },
+ "ql.s5.review_progress": {
+ "en": "Review Progress",
+ "zh": "审查进度",
+ },
+ "ql.s5.confidence": {
+ "en": "Confidence Distribution",
+ "zh": "置信度分布",
+ },
+ "ql.s5.themes": {
+ "en": "Theme Distribution",
+ "zh": "主题分布",
+ },
# ======================================================================
# QualiKit Step 6 - Export
@@ -1360,6 +1400,18 @@ def t(key: str, lang: str = "en") -> str:
"en": "Evaluation report",
"zh": "评估报告",
},
+ "qt.s5.confusion_matrix": {
+ "en": "Confusion Matrix",
+ "zh": "混淆矩阵",
+ },
+ "qt.s5.per_class": {
+ "en": "Per-Class Metrics",
+ "zh": "各类别指标",
+ },
+ "qt.s5.text_report": {
+ "en": "Full Text Report",
+ "zh": "完整文字报告",
+ },
# ======================================================================
# QuantiKit Step 6 - Export
@@ -1431,4 +1483,279 @@ def t(key: str, lang: str = "en") -> str:
"en": "API Key",
"zh": "API Key",
},
+
+ # ======================================================================
+ # Inter-Coder Reliability (ICR)
+ # ======================================================================
+
+ "icr.title": {
+ "en": "Inter-Coder Reliability",
+ "zh": "编码者间信度",
+ },
+ "icr.description": {
+ "en": "Compute agreement metrics between two sets of labels or coders.",
+ "zh": "计算两组标签或编码者之间的一致性指标。",
+ },
+ "icr.upload_second_labels": {
+ "en": "Upload second coder's labels (CSV)",
+ "zh": "上传第二编码者标签(CSV)",
+ },
+ "icr.second_label_col": {
+ "en": "Second coder label column",
+ "zh": "第二编码者标签列名",
+ },
+ "icr.compute_btn": {
+ "en": "Compute ICR",
+ "zh": "计算编码者间信度",
+ },
+ "icr.report": {
+ "en": "ICR Report",
+ "zh": "信度报告",
+ },
+ "icr.human_vs_llm": {
+ "en": "Human vs LLM Agreement",
+ "zh": "人工 vs LLM 一致性",
+ },
+ "icr.human_vs_llm_desc": {
+ "en": "Compare human-reviewed themes against original LLM coding.",
+ "zh": "对比人工审核主题与原始 LLM 编码结果。",
+ },
+ "icr.compute_human_llm_btn": {
+ "en": "Compute Human vs LLM ICR",
+ "zh": "计算人工 vs LLM 信度",
+ },
+ "msg.run_eval_first": {
+ "en": "Please run evaluation first.",
+ "zh": "请先运行评估。",
+ },
+ "msg.no_review_data": {
+ "en": "No review data available. Please complete coding and review first.",
+ "zh": "暂无审核数据,请先完成编码和审核。",
+ },
+
+ # ======================================================================
+ # Multi-LLM Consensus Coding
+ # ======================================================================
+
+ "consensus.title": {
+ "en": "Consensus Coding (Multi-LLM)",
+ "zh": "共识编码(多模型)",
+ },
+ "consensus.description": {
+ "en": (
+ "Run 2–3 LLMs independently on the same segments. "
+ "Themes are retained only when a majority of models agree."
+ ),
+ "zh": (
+ "使用 2–3 个 LLM 分别独立编码同一批文本,"
+ "仅保留多数模型一致同意的主题。"
+ ),
+ },
+ "consensus.backend_n": {
+ "en": "LLM {} Backend",
+ "zh": "LLM {} 后端",
+ },
+ "consensus.model_n": {
+ "en": "Model {}",
+ "zh": "模型 {}",
+ },
+ "consensus.api_key_n": {
+ "en": "API Key {}",
+ "zh": "API Key {}",
+ },
+ "consensus.run_btn": {
+ "en": "Run Consensus Coding",
+ "zh": "运行共识编码",
+ },
+ "consensus.summary": {
+ "en": "Consensus Summary",
+ "zh": "共识摘要",
+ },
+ "consensus.results": {
+ "en": "Consensus Results",
+ "zh": "共识结果",
+ },
+ "consensus.agreement": {
+ "en": "Agreement Report",
+ "zh": "一致性报告",
+ },
+ "msg.at_least_two_llms": {
+ "en": "Please configure at least 2 LLMs for consensus coding.",
+ "zh": "共识编码需要至少配置 2 个 LLM。",
+ },
+ "msg.consensus_done": {
+ "en": "Consensus coding complete. {} segments coded with {} models.",
+ "zh": "共识编码完成。{} 条文本使用 {} 个模型编码。",
+ },
+ "msg.lock_themes_first": {
+ "en": "Please define and lock themes first.",
+ "zh": "请先定义并锁定主题框架。",
+ },
+
+ # ======================================================================
+ # Methods Section Auto-generation
+ # ======================================================================
+
+ "methods.title": {
+ "en": "Methods Section Generator",
+ "zh": "方法论段落生成",
+ },
+ "methods.description": {
+ "en": "Generate a Methods paragraph draft for your paper based on the analysis pipeline.",
+ "zh": "根据分析流程自动生成论文方法论段落草稿。",
+ },
+ "methods.generate_btn": {
+ "en": "Generate Methods Section",
+ "zh": "生成方法论段落",
+ },
+ "methods.text_en": {
+ "en": "Methods (English)",
+ "zh": "方法论(英文)",
+ },
+ "methods.text_zh": {
+ "en": "Methods (Chinese)",
+ "zh": "方法论(中文)",
+ },
+ "methods.copy_hint": {
+ "en": "Auto-generated draft. Copy, edit, and cite appropriately before publication.",
+ "zh": "自动生成草稿,请复制后编辑,并在发表前适当引用。",
+ },
+ "methods.no_data": {
+ "en": "Please complete the analysis pipeline before generating.",
+ "zh": "请先完成分析流程再生成。",
+ },
+
+ # ======================================================================
+ # Toolbox
+ # ======================================================================
+
+ "toolbox.title": {
+ "en": "Toolbox",
+ "zh": "工具箱",
+ },
+ "toolbox.description": {
+ "en": "Standalone research tools — ICR Calculator, Multi-LLM Consensus Coding, and Methods Section Generator. These tools work independently from QuantiKit and QualiKit.",
+ "zh": "独立研究工具 — 编码者间信度计算、多模型共识编码、方法论段落生成。这些工具独立于 QuantiKit 和 QualiKit 使用。",
+ },
+ "toolbox.icr_tab": {
+ "en": "ICR Calculator",
+ "zh": "编码者间信度",
+ },
+ "toolbox.consensus_tab": {
+ "en": "Consensus Coding",
+ "zh": "共识编码",
+ },
+ "toolbox.methods_tab": {
+ "en": "Methods Generator",
+ "zh": "方法论生成",
+ },
+ "toolbox.import_log": {
+ "en": "Import Pipeline Log",
+ "zh": "导入流水线日志",
+ },
+ "toolbox.export_log": {
+ "en": "Export Pipeline Log",
+ "zh": "导出流水线日志",
+ },
+ "toolbox.manual_input": {
+ "en": "Manual Input (without log file)",
+ "zh": "手动输入(无日志文件时使用)",
+ },
+ "toolbox.pipeline_type": {
+ "en": "Pipeline Type",
+ "zh": "流水线类型",
+ },
+ "toolbox.icr_upload": {
+ "en": "Upload Labels CSV (each column = one coder)",
+ "zh": "上传标签 CSV(每列代表一个编码者)",
+ },
+ "toolbox.icr_file_info": {
+ "en": "File info",
+ "zh": "文件信息",
+ },
+ "toolbox.icr_select_cols": {
+ "en": "Select coder columns",
+ "zh": "选择编码者列",
+ },
+ "toolbox.icr_select_cols_info": {
+ "en": "Pick 2+ columns. 2 coders → Cohen's Kappa; 3+ coders → Krippendorff's Alpha.",
+ "zh": "选择 2 列以上。2 人 → Cohen's Kappa;3 人以上 → Krippendorff's Alpha。",
+ },
+ "toolbox.icr_mode": {
+ "en": "Label Mode",
+ "zh": "标签模式",
+ },
+ "toolbox.icr_mode_info": {
+ "en": "single-label: one label per cell. multi-label: comma-separated values per cell.",
+ "zh": "单标签:每格一个标签。多标签:每格用逗号分隔多个标签。",
+ },
+ "toolbox.add_llm": {
+ "en": "+ Add LLM",
+ "zh": "+ 添加模型",
+ },
+ "toolbox.remove_llm": {
+ "en": "- Remove LLM",
+ "zh": "- 移除模型",
+ },
+ "toolbox.data_file": {
+ "en": "Data File (CSV)",
+ "zh": "数据文件(CSV)",
+ },
+ "toolbox.text_col": {
+ "en": "Text Column",
+ "zh": "文本列名",
+ },
+ "toolbox.themes_input": {
+ "en": "Themes (one per line, format: name: description)",
+ "zh": "主题(每行一个,格式:名称: 描述)",
+ },
+ "toolbox.download_example": {
+ "en": "Download Example",
+ "zh": "下载示例",
+ },
+ "toolbox.example_file": {
+ "en": "Example File",
+ "zh": "示例文件",
+ },
+ "toolbox.example_qt_log": {
+ "en": "QuantiKit Log Example",
+ "zh": "QuantiKit 日志示例",
+ },
+ "toolbox.example_ql_log": {
+ "en": "QualiKit Log Example",
+ "zh": "QualiKit 日志示例",
+ },
+
+ # ======================================================================
+ # Project Save / Load
+ # ======================================================================
+
+ "project.save_title": {
+ "en": "Save Project",
+ "zh": "保存项目",
+ },
+ "project.save_btn": {
+ "en": "Save Project File",
+ "zh": "保存项目文件",
+ },
+ "project.load_title": {
+ "en": "Load Saved Project",
+ "zh": "加载已保存的项目",
+ },
+ "project.load_desc": {
+ "en": "Upload a previously saved `.json` project file to restore your progress.",
+ "zh": "上传之前保存的 `.json` 项目文件以恢复进度。",
+ },
+ "project.load_btn": {
+ "en": "Load Project",
+ "zh": "加载项目",
+ },
+ "project.file": {
+ "en": "Project File",
+ "zh": "项目文件",
+ },
+ "project.status": {
+ "en": "Status",
+ "zh": "状态",
+ },
}
diff --git a/socialscikit/ui/main_app.py b/socialscikit/ui/main_app.py
index e871d36..8891e33 100644
--- a/socialscikit/ui/main_app.py
+++ b/socialscikit/ui/main_app.py
@@ -14,10 +14,12 @@
import pandas as pd
from socialscikit.core.data_loader import get_template_path
+from socialscikit.core.project_io import save_project, load_project
from socialscikit.quantikit.feature_extractor import TASK_TYPES
import socialscikit.ui.quantikit_app as qn
import socialscikit.ui.qualikit_app as ql
+import socialscikit.ui.toolbox_app as tb
from socialscikit.ui.i18n import t, LANGUAGES
# ---------------------------------------------------------------------------
@@ -116,11 +118,15 @@
/* Initial fix for file-drop text (browser locale → Chinese) */
fixDropText();
- new MutationObserver(fixDropText).observe(document.body, {
- childList: true, subtree: true
- });
+ /* Body-wide observer: re-apply drop-text AND tab labels on any DOM change.
+ Needed because Gradio re-renders tabs individually when selected, which
+ reverts our translations. Cheap text-only updates — no layout work. */
+ new MutationObserver(() => {
+ fixDropText();
+ switchTabs();
+ }).observe(document.body, { childList: true, subtree: true });
/* Fallback for stubborn Gradio re-renders */
- setInterval(fixDropText, 3000);
+ setInterval(() => { fixDropText(); switchTabs(); }, 2000);
}
"""
@@ -408,16 +414,35 @@
.hero-title h1 { font-size: 2rem !important; color: #222 !important; margin-bottom: 0.25rem !important; }
.hero-sub { text-align: center; color: #888 !important; font-size: 1rem !important; margin-bottom: 2rem; }
-/* ---- Module cards — just a subtle bg, no border ---- */
+/* ---- Module cards — subtle bg with colored left border ---- */
.module-card {
border: none !important;
border-radius: 8px;
padding: 1.5rem;
background: #f7f7f8;
+ border-left: 4px solid #4A90D9;
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
+}
+.module-card:nth-child(2) { border-left-color: #5BA88D; }
+.module-card:nth-child(3) { border-left-color: #E8734A; }
+.module-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.06) !important;
}
.module-card h3 { margin-top: 0 !important; color: #222 !important; }
.module-card strong { color: #222 !important; }
.module-card p, .module-card li { color: #444 !important; }
+
+/* ---- Plot containers — clean borders ---- */
+.plot-container {
+ border: 1px solid #f0f0f0 !important;
+ border-radius: 8px !important;
+ overflow: hidden;
+}
+/* Ensure plots render on white background */
+.plot-container img, .plot-container canvas {
+ background: #ffffff !important;
+}
"""
# ---------------------------------------------------------------------------
@@ -534,12 +559,15 @@ def _build_landing(lang: str = "en") -> str:
'
\n\n'
f'{t("landing.subtitle", lang)}\n\n'
'
\n\n---\n\n'
- '
\n'
+ '
\n'
'
\n\n'
f'{t("landing.quantikit_card", lang)}\n\n'
'
\n'
'
\n\n'
f'{t("landing.qualikit_card", lang)}\n\n'
+ '
\n'
+ '
\n\n'
+ f'{t("landing.toolbox_card", lang)}\n\n'
'
\n
\n\n'
f'{t("landing.quickstart", lang)}\n\n'
f'{t("landing.examples", lang)}\n\n'
@@ -647,6 +675,23 @@ def create_app() -> gr.Blocks:
with gr.Tab("Home"):
landing_md = gr.Markdown(value=_build_landing("en"))
+ with gr.Accordion(t("project.load_title", "en"), open=False):
+ gr.Markdown(t("project.load_desc", "en"))
+ with gr.Row():
+ proj_load_file = gr.File(
+ label=t("project.file", "en"),
+ file_types=[".json"],
+ )
+ with gr.Column(scale=0, min_width=160):
+ proj_load_btn = gr.Button(
+ t("project.load_btn", "en"),
+ variant="primary", size="sm",
+ )
+ proj_load_msg = gr.Textbox(
+ label=t("project.status", "en"),
+ interactive=False, lines=1,
+ )
+
# ==================================================================
# QuantiKit
# ==================================================================
@@ -715,7 +760,9 @@ def create_app() -> gr.Blocks:
qa_labels = gr.Textbox(label=t("qt.s3.labels", "en"), value="positive, negative, neutral", placeholder=t("qt.s3.labels_placeholder", "en"))
qa_shuf = gr.Checkbox(label=t("qt.s3.shuffle", "en"), value=False)
qa_create = gr.Button(t("qt.s3.create_session", "en"), variant="primary")
- qa_stats = gr.Textbox(label=t("qt.s3.progress", "en"), interactive=False)
+ with gr.Row():
+ qa_stats = gr.Textbox(label=t("qt.s3.progress", "en"), interactive=False, scale=3)
+ qa_ann_plot = gr.Plot(label="", scale=1, min_width=180)
qa_idx = gr.Textbox(label=t("qt.s3.current_pos", "en"), interactive=False)
qa_text = gr.Textbox(label=t("qt.s3.text_to_annotate", "en"), lines=4, interactive=False)
with gr.Row():
@@ -741,12 +788,18 @@ def create_app() -> gr.Blocks:
qa_mmsg = gr.Textbox(label="", interactive=False, show_label=False)
_so = [qt_ann_session, qa_stats, qa_text, qa_idx, qa_msg]
+ _ann_chart_then = dict(fn=qn._make_annotation_chart, inputs=[qt_ann_session], outputs=[qa_ann_plot])
qa_create.click(fn=qn._create_annotation_session,
- inputs=[qt_df, qt_tcol, qt_lcol, qa_labels, qa_shuf], outputs=_so)
- qa_sub.click(fn=qn._annotate_item, inputs=[qt_ann_session, qa_input], outputs=_so)
- qa_skip.click(fn=qn._skip_item, inputs=[qt_ann_session], outputs=_so)
- qa_flag.click(fn=qn._flag_item, inputs=[qt_ann_session, qa_fnote], outputs=_so)
- qa_undo.click(fn=qn._undo_annotation, inputs=[qt_ann_session], outputs=_so)
+ inputs=[qt_df, qt_tcol, qt_lcol, qa_labels, qa_shuf], outputs=_so
+ ).then(**_ann_chart_then)
+ qa_sub.click(fn=qn._annotate_item, inputs=[qt_ann_session, qa_input], outputs=_so
+ ).then(**_ann_chart_then)
+ qa_skip.click(fn=qn._skip_item, inputs=[qt_ann_session], outputs=_so
+ ).then(**_ann_chart_then)
+ qa_flag.click(fn=qn._flag_item, inputs=[qt_ann_session, qa_fnote], outputs=_so
+ ).then(**_ann_chart_then)
+ qa_undo.click(fn=qn._undo_annotation, inputs=[qt_ann_session], outputs=_so
+ ).then(**_ann_chart_then)
qa_exp.click(fn=qn._export_annotations, inputs=[qt_ann_session, qa_all], outputs=[qa_tbl, qa_msg])
qa_dl.click(fn=qn._download_annotations_csv, inputs=[qt_ann_session, qa_all], outputs=[qa_dlf])
qa_merge.click(fn=qn._update_main_df_from_annotations,
@@ -908,8 +961,15 @@ def _ft_wrap(df, tc, lc, m, bs, ep, lr):
with gr.Tab("Step 5 · Evaluation"):
qt_s5_md = gr.Markdown(t("qt.s5.title", "en"))
qt_ebtn = gr.Button(t("qt.s5.run_btn", "en"), variant="primary")
- qt_eout = gr.Textbox(label=t("qt.s5.report", "en"), lines=18, interactive=False)
- qt_ebtn.click(fn=qn._evaluate_results, inputs=[qt_result_df, qt_df, qt_lcol], outputs=[qt_eout])
+ qt_metrics_html = gr.HTML()
+ with gr.Row():
+ qt_cm_plot = gr.Plot(label=t("qt.s5.confusion_matrix", "en"))
+ qt_pc_plot = gr.Plot(label=t("qt.s5.per_class", "en"))
+ qt_s5_text_acc = gr.Accordion(t("qt.s5.text_report", "en"), open=False)
+ with qt_s5_text_acc:
+ qt_eout = gr.Textbox(label=t("qt.s5.report", "en"), lines=18, interactive=False)
+ qt_ebtn.click(fn=qn._evaluate_results, inputs=[qt_result_df, qt_df, qt_lcol],
+ outputs=[qt_eout, qt_metrics_html, qt_cm_plot, qt_pc_plot])
# -- 6. Export ------------------------------------------------
with gr.Tab("Step 6 · Export"):
@@ -918,6 +978,18 @@ def _ft_wrap(df, tc, lc, m, bs, ep, lr):
qt_xfile = gr.File(label=t("qt.s6.file", "en"))
qt_xbtn.click(fn=qn._export_results, inputs=[qt_result_df], outputs=[qt_xfile])
+ qt_log_btn = gr.Button(t("toolbox.export_log", "en"), variant="secondary")
+ qt_log_file = gr.File(label="Pipeline Log")
+ qt_log_btn.click(fn=qn._export_pipeline_log,
+ inputs=[qt_result_df, qt_df, qt_lcol],
+ outputs=[qt_log_file])
+
+ with gr.Accordion(t("project.save_title", "en"), open=False):
+ proj_qt_save_btn = gr.Button(
+ t("project.save_btn", "en"), variant="secondary",
+ )
+ proj_qt_save_file = gr.File(label=t("project.file", "en"))
+
# ==================================================================
# QualiKit
# ==================================================================
@@ -1143,6 +1215,13 @@ def _ft_wrap(df, tc, lc, m, bs, ep, lr):
ql_rev_stats = gr.Textbox(label=t("ql.s5.stats", "en"), interactive=False)
ql_rev_tbl = gr.Dataframe(label=t("ql.s5.table", "en"), interactive=False)
+ with gr.Accordion(t("ql.s5.charts_title", "en"), open=True):
+ ql_rev_cards = gr.HTML()
+ with gr.Row():
+ ql_review_plot = gr.Plot(label=t("ql.s5.review_progress", "en"))
+ ql_conf_plot = gr.Plot(label=t("ql.s5.confidence", "en"))
+ ql_theme_plot = gr.Plot(label=t("ql.s5.themes", "en"))
+
ql_s5_detail_md = gr.Markdown("---\n" + t("ql.s5.detail_title", "en"))
with gr.Row():
ql_rev_idx = gr.Number(label=t("ql.s5.index", "en"), precision=0, value=0, minimum=0)
@@ -1186,6 +1265,20 @@ def _ft_wrap(df, tc, lc, m, bs, ep, lr):
outputs=[ql_xl_file, ql_xl_msg],
)
+ ql_log_btn = gr.Button(t("toolbox.export_log", "en"), variant="secondary")
+ ql_log_file = gr.File(label="Pipeline Log")
+ ql_log_btn.click(
+ fn=ql._export_pipeline_log,
+ inputs=[ql_ext_session, ql_rqs, ql_ext_session],
+ outputs=[ql_log_file],
+ )
+
+ with gr.Accordion(t("project.save_title", "en"), open=False):
+ proj_ql_save_btn = gr.Button(
+ t("project.save_btn", "en"), variant="secondary",
+ )
+ proj_ql_save_file = gr.File(label=t("project.file", "en"))
+
# ==========================================================
# Cross-tab event wiring
# (placed here so all components are defined)
@@ -1199,12 +1292,18 @@ def _ft_wrap(df, tc, lc, m, bs, ep, lr):
)
# Step 4 → extraction outputs to Step 5 review table
+ _ql_chart_out = [ql_rev_cards, ql_review_plot, ql_conf_plot, ql_theme_plot]
+
ql_ext_btn.click(
fn=ql._run_extraction_v2,
inputs=[ql_raw_text, ql_segments, ql_rqs,
ql_ext_be, ql_ext_mod, ql_ext_key],
outputs=[ql_ext_session, ql_ext_msg, ql_ext_tbl,
ql_rev_tbl, ql_rev_stats],
+ ).then(
+ fn=ql._make_ql_charts,
+ inputs=[ql_ext_session],
+ outputs=_ql_chart_out,
)
# Step 5 — review events
@@ -1222,6 +1321,10 @@ def _ft_wrap(df, tc, lc, m, bs, ep, lr):
fn=ql._ext_show_context,
inputs=[ql_ext_session, ql_rev_idx, ql_raw_text],
outputs=[ql_rev_ctx],
+ ).then(
+ fn=ql._make_ql_charts,
+ inputs=[ql_ext_session],
+ outputs=_ql_chart_out,
)
ql_rev_rej.click(
fn=ql._ext_reject, inputs=[ql_ext_session, ql_rev_idx],
@@ -1230,6 +1333,10 @@ def _ft_wrap(df, tc, lc, m, bs, ep, lr):
fn=ql._ext_show_context,
inputs=[ql_ext_session, ql_rev_idx, ql_raw_text],
outputs=[ql_rev_ctx],
+ ).then(
+ fn=ql._make_ql_charts,
+ inputs=[ql_ext_session],
+ outputs=_ql_chart_out,
)
ql_rev_edit_btn.click(
fn=ql._ext_edit,
@@ -1239,11 +1346,19 @@ def _ft_wrap(df, tc, lc, m, bs, ep, lr):
fn=ql._ext_show_context,
inputs=[ql_ext_session, ql_rev_idx, ql_raw_text],
outputs=[ql_rev_ctx],
+ ).then(
+ fn=ql._make_ql_charts,
+ inputs=[ql_ext_session],
+ outputs=_ql_chart_out,
)
ql_rev_bulk.click(
fn=ql._ext_accept_all_high,
inputs=[ql_ext_session, ql_rev_thr],
outputs=_rev_out,
+ ).then(
+ fn=ql._make_ql_charts,
+ inputs=[ql_ext_session],
+ outputs=_ql_chart_out,
)
# Manual add — segment preview on ID change
@@ -1264,6 +1379,296 @@ def _ft_wrap(df, tc, lc, m, bs, ep, lr):
outputs=_rev_out,
)
+ # ==================================================================
+ # Project Save / Load wiring
+ # ==================================================================
+
+ _project_state_inputs = [
+ qt_df, qt_result_df, qt_ann_session,
+ ql_raw_text, ql_segments, ql_rqs, ql_ext_session,
+ ql_lang,
+ ]
+ _project_state_keys = [
+ "qt_df", "qt_result_df", "qt_ann_session",
+ "ql_raw_text", "ql_segments", "ql_rqs", "ql_ext_session",
+ "ql_lang",
+ ]
+
+ def _save_project_fn(*state_values):
+ states = dict(zip(_project_state_keys, state_values))
+ json_str = save_project(states)
+ path = os.path.join(tempfile.gettempdir(), "socialscikit_project.json")
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(json_str)
+ return path
+
+ # Load returns all state objects + a status message
+ _load_outputs = _project_state_inputs + [proj_load_msg]
+
+ def _load_project_fn(file):
+ n_states = len(_project_state_keys)
+ if file is None:
+ return [gr.update()] * n_states + ["Please upload a project file."]
+ try:
+ with open(file, "r", encoding="utf-8") as f:
+ json_str = f.read()
+ states = load_project(json_str)
+ results = []
+ for key in _project_state_keys:
+ results.append(states.get(key))
+ results.append("Project loaded successfully!")
+ return results
+ except Exception as e:
+ return [gr.update()] * n_states + [f"Load failed: {e}"]
+
+ proj_qt_save_btn.click(
+ fn=_save_project_fn,
+ inputs=_project_state_inputs,
+ outputs=[proj_qt_save_file],
+ )
+ proj_ql_save_btn.click(
+ fn=_save_project_fn,
+ inputs=_project_state_inputs,
+ outputs=[proj_ql_save_file],
+ )
+ proj_load_btn.click(
+ fn=_load_project_fn,
+ inputs=[proj_load_file],
+ outputs=_load_outputs,
+ )
+
+ # ==================================================================
+ # Toolbox
+ # ==================================================================
+ with gr.Tab(t("toolbox.title", "en")) as toolbox_tab:
+
+ tb_intro_md = gr.Markdown(t("toolbox.description", "en"))
+
+ # ---------- ICR Calculator ----------
+ with gr.Tab(t("toolbox.icr_tab", "en")) as tb_icr_tab:
+ tb_icr_md = gr.Markdown(t("icr.description", "en"))
+ with gr.Row():
+ tb_icr_file = gr.File(label=t("toolbox.icr_upload", "en"), file_types=[".csv"])
+ with gr.Column(scale=0, min_width=160):
+ tb_icr_ex_btn = gr.Button(t("toolbox.download_example", "en"), variant="secondary", size="sm")
+ tb_icr_ex_file = gr.File(label=t("toolbox.example_file", "en"), visible=False)
+ tb_icr_ex_btn.click(fn=tb._download_icr_example, outputs=[tb_icr_ex_file])
+ tb_icr_info = gr.Textbox(label=t("toolbox.icr_file_info", "en"), interactive=False, lines=1)
+ tb_icr_cols = gr.CheckboxGroup(
+ choices=[], label=t("toolbox.icr_select_cols", "en"),
+ info=t("toolbox.icr_select_cols_info", "en"),
+ )
+ tb_icr_mode = gr.Radio(
+ choices=["single-label", "multi-label"],
+ value="single-label",
+ label=t("toolbox.icr_mode", "en"),
+ info=t("toolbox.icr_mode_info", "en"),
+ )
+ tb_icr_btn = gr.Button(t("icr.compute_btn", "en"), variant="primary")
+ with gr.Row():
+ tb_icr_out = gr.Textbox(label=t("icr.report", "en"), lines=18, interactive=False, scale=2)
+ tb_icr_plot = gr.Plot(label="Agreement Chart", scale=1)
+
+ tb_icr_file.change(
+ fn=tb._icr_on_upload,
+ inputs=[tb_icr_file],
+ outputs=[tb_icr_cols, tb_icr_info],
+ )
+ tb_icr_btn.click(
+ fn=tb._compute_icr,
+ inputs=[tb_icr_file, tb_icr_cols, tb_icr_mode],
+ outputs=[tb_icr_out],
+ ).then(
+ fn=tb._make_icr_chart,
+ inputs=[tb_icr_file, tb_icr_cols, tb_icr_mode],
+ outputs=[tb_icr_plot],
+ )
+
+ # ---------- Consensus Coding ----------
+ with gr.Tab(t("toolbox.consensus_tab", "en")) as tb_con_tab:
+ tb_con_md = gr.Markdown(t("consensus.description", "en"))
+ with gr.Row():
+ tb_con_file = gr.File(label=t("toolbox.data_file", "en"), file_types=[".csv"])
+ tb_con_tcol = gr.Textbox(label=t("toolbox.text_col", "en"), value="text")
+ with gr.Column(scale=0, min_width=160):
+ tb_con_ex_btn = gr.Button(t("toolbox.download_example", "en"), variant="secondary", size="sm")
+ tb_con_ex_file = gr.File(label=t("toolbox.example_file", "en"), visible=False)
+ tb_con_ex_btn.click(fn=tb._download_consensus_example, outputs=[tb_con_ex_file])
+ tb_con_themes = gr.Textbox(
+ label=t("toolbox.themes_input", "en"),
+ placeholder="theme1: description\ntheme2: description\n...",
+ lines=4,
+ )
+
+ # Dynamic LLM slots (up to 5, first 2 visible)
+ tb_con_llm_rows = [] # list of (gr.Row, backend, model, key)
+ tb_con_backends = []
+ tb_con_models = []
+ tb_con_keys = []
+ _defaults = [
+ ("openai", "gpt-4o-mini"),
+ ("anthropic", "claude-sonnet-4-20250514"),
+ ("openai", "gpt-4o"),
+ ("ollama", ""),
+ ("anthropic", ""),
+ ]
+ for idx in range(tb.MAX_LLM_SLOTS):
+ visible = idx < 2
+ be_default, mod_default = _defaults[idx]
+ with gr.Row(visible=visible) as llm_row:
+ be = gr.Dropdown(
+ choices=["openai", "anthropic", "ollama"],
+ value=be_default,
+ label=f"Backend {idx + 1}",
+ )
+ md = gr.Textbox(label=f"Model {idx + 1}", value=mod_default if visible else "")
+ ky = gr.Textbox(label=f"API Key {idx + 1}", type="password")
+ tb_con_llm_rows.append(llm_row)
+ tb_con_backends.append(be)
+ tb_con_models.append(md)
+ tb_con_keys.append(ky)
+
+ tb_con_n_llms = gr.State(2) # tracks how many slots are visible
+
+ with gr.Row():
+ tb_con_add = gr.Button(t("toolbox.add_llm", "en"), variant="secondary", size="sm")
+ tb_con_rm = gr.Button(t("toolbox.remove_llm", "en"), variant="secondary", size="sm")
+
+ def _add_llm_slot(n):
+ n = min(n + 1, tb.MAX_LLM_SLOTS)
+ updates = [gr.update(visible=(i < n)) for i in range(tb.MAX_LLM_SLOTS)]
+ return [n] + updates
+
+ def _remove_llm_slot(n):
+ n = max(n - 1, 2)
+ updates = [gr.update(visible=(i < n)) for i in range(tb.MAX_LLM_SLOTS)]
+ return [n] + updates
+
+ tb_con_add.click(
+ fn=_add_llm_slot,
+ inputs=[tb_con_n_llms],
+ outputs=[tb_con_n_llms] + tb_con_llm_rows,
+ )
+ tb_con_rm.click(
+ fn=_remove_llm_slot,
+ inputs=[tb_con_n_llms],
+ outputs=[tb_con_n_llms] + tb_con_llm_rows,
+ )
+
+ tb_con_btn = gr.Button(t("consensus.run_btn", "en"), variant="primary")
+ tb_con_summary = gr.Textbox(label=t("consensus.summary", "en"), lines=10, interactive=False)
+ tb_con_results = gr.Dataframe(label=t("consensus.results", "en"), interactive=False)
+ tb_con_agreement = gr.Textbox(label=t("consensus.agreement", "en"), lines=4, interactive=False)
+
+ # Collect all backend/model/key inputs in order
+ _con_inputs = [tb_con_file, tb_con_tcol, tb_con_themes]
+ for i in range(tb.MAX_LLM_SLOTS):
+ _con_inputs.extend([tb_con_backends[i], tb_con_models[i], tb_con_keys[i]])
+
+ tb_con_btn.click(
+ fn=tb._run_standalone_consensus,
+ inputs=_con_inputs,
+ outputs=[tb_con_summary, tb_con_results, tb_con_agreement],
+ )
+
+ # ---------- Methods Generator ----------
+ with gr.Tab(t("toolbox.methods_tab", "en")) as tb_meth_tab:
+ tb_meth_md = gr.Markdown(t("methods.description", "en"))
+
+ # Primary: import log
+ gr.Markdown(f"### {t('toolbox.import_log', 'en')}")
+ with gr.Row():
+ tb_meth_log = gr.File(
+ label=t("toolbox.import_log", "en"),
+ file_types=[".json"],
+ )
+ with gr.Column(scale=0, min_width=200):
+ tb_meth_ex_qt_btn = gr.Button(
+ t("toolbox.example_qt_log", "en"),
+ variant="secondary", size="sm",
+ )
+ tb_meth_ex_qt_file = gr.File(
+ label=t("toolbox.example_file", "en"), visible=False,
+ )
+ tb_meth_ex_qt_btn.click(
+ fn=tb._download_methods_example_qt,
+ outputs=[tb_meth_ex_qt_file],
+ )
+ tb_meth_ex_ql_btn = gr.Button(
+ t("toolbox.example_ql_log", "en"),
+ variant="secondary", size="sm",
+ )
+ tb_meth_ex_ql_file = gr.File(
+ label=t("toolbox.example_file", "en"), visible=False,
+ )
+ tb_meth_ex_ql_btn.click(
+ fn=tb._download_methods_example_ql,
+ outputs=[tb_meth_ex_ql_file],
+ )
+ tb_meth_log_btn = gr.Button(t("methods.generate_btn", "en"), variant="primary")
+
+ tb_meth_en = gr.Textbox(label=t("methods.text_en", "en"), lines=8, interactive=True)
+ tb_meth_zh = gr.Textbox(label=t("methods.text_zh", "en"), lines=8, interactive=True)
+ gr.Markdown(t("methods.copy_hint", "en"))
+
+ tb_meth_log_btn.click(
+ fn=tb._generate_methods_from_log,
+ inputs=[tb_meth_log],
+ outputs=[tb_meth_en, tb_meth_zh],
+ )
+
+ # Fallback: manual form
+ with gr.Accordion(t("toolbox.manual_input", "en"), open=False):
+ tb_meth_type = gr.Radio(
+ choices=["QuantiKit", "QualiKit"],
+ value="QuantiKit",
+ label=t("toolbox.pipeline_type", "en"),
+ )
+ # QuantiKit fields
+ with gr.Group(visible=True) as tb_qt_group:
+ with gr.Row():
+ tb_qt_ns = gr.Number(label="N samples", value=0, precision=0)
+ tb_qt_nc = gr.Number(label="N classes", value=0, precision=0)
+ tb_qt_labels = gr.Textbox(label="Class labels (comma-separated)", value="")
+ tb_qt_model = gr.Textbox(label="Model name", value="")
+ with gr.Row():
+ tb_qt_acc = gr.Number(label="Accuracy", value=0, precision=3)
+ tb_qt_f1 = gr.Number(label="Macro F1", value=0, precision=3)
+ tb_qt_kappa = gr.Number(label="Cohen's Kappa", value=0, precision=3)
+
+ # QualiKit fields
+ with gr.Group(visible=False) as tb_ql_group:
+ with gr.Row():
+ tb_ql_nseg = gr.Number(label="N segments", value=0, precision=0)
+ tb_ql_nth = gr.Number(label="N themes", value=0, precision=0)
+ tb_ql_themes = gr.Textbox(label="Theme names (comma-separated)", value="")
+ tb_ql_model = gr.Textbox(label="Model name", value="")
+ tb_ql_consensus = gr.Checkbox(label="Consensus coding used", value=False)
+ tb_ql_ncon = gr.Number(label="N consensus models", value=0, precision=0)
+
+ def _toggle_pipeline_fields(choice):
+ return (
+ gr.update(visible=choice == "QuantiKit"),
+ gr.update(visible=choice == "QualiKit"),
+ )
+
+ tb_meth_type.change(
+ fn=_toggle_pipeline_fields,
+ inputs=[tb_meth_type],
+ outputs=[tb_qt_group, tb_ql_group],
+ )
+
+ tb_meth_form_btn = gr.Button(t("methods.generate_btn", "en"), variant="secondary")
+ tb_meth_form_btn.click(
+ fn=tb._generate_methods_from_form,
+ inputs=[tb_meth_type,
+ tb_qt_ns, tb_qt_nc, tb_qt_labels, tb_qt_model,
+ tb_qt_acc, tb_qt_f1, tb_qt_kappa,
+ tb_ql_nseg, tb_ql_nth, tb_ql_themes, tb_ql_model,
+ tb_ql_consensus, tb_ql_ncon],
+ outputs=[tb_meth_en, tb_meth_zh],
+ )
+
# ==================================================================
# Language switching
# ==================================================================
@@ -1276,6 +1681,12 @@ def _switch_language(choice):
lang,
# Landing page
_build_landing(lang),
+ # Project Load UI (on Home tab)
+ gr.update(value=t("project.load_btn", lang)), # proj_load_btn
+ gr.update(label=t("project.status", lang)), # proj_load_msg
+ # Project Save buttons
+ gr.update(value=t("project.save_btn", lang)), # proj_qt_save_btn
+ gr.update(value=t("project.save_btn", lang)), # proj_ql_save_btn
# -- QualiKit Step 1 --
t("ql.s1.title", lang), # ql_s1_md
gr.update(label=t("ql.s1.upload", lang)), # ql_file
@@ -1344,6 +1755,9 @@ def _switch_language(choice):
t("ql.s5.title", lang), # ql_s5_md
gr.update(label=t("ql.s5.stats", lang)), # ql_rev_stats
gr.update(label=t("ql.s5.table", lang)), # ql_rev_tbl
+ gr.update(label=t("ql.s5.review_progress", lang)), # ql_review_plot
+ gr.update(label=t("ql.s5.confidence", lang)), # ql_conf_plot
+ gr.update(label=t("ql.s5.themes", lang)), # ql_theme_plot
"---\n" + t("ql.s5.detail_title", lang), # ql_s5_detail_md
gr.update(label=t("ql.s5.index", lang)), # ql_rev_idx
gr.update(value=t("ql.s5.accept", lang)), # ql_rev_acc
@@ -1487,11 +1901,41 @@ def _switch_language(choice):
# -- QuantiKit Step 5 --
t("qt.s5.title", lang), # qt_s5_md
gr.update(value=t("qt.s5.run_btn", lang)), # qt_ebtn
+ gr.update(label=t("qt.s5.confusion_matrix", lang)), # qt_cm_plot
+ gr.update(label=t("qt.s5.per_class", lang)), # qt_pc_plot
+ gr.update(label=t("qt.s5.text_report", lang)), # qt_s5_text_acc
gr.update(label=t("qt.s5.report", lang)), # qt_eout
# -- QuantiKit Step 6 --
t("qt.s6.title", lang), # qt_s6_md
gr.update(value=t("qt.s6.export_btn", lang)), # qt_xbtn
gr.update(label=t("qt.s6.file", lang)), # qt_xfile
+ # -- Toolbox --
+ t("toolbox.description", lang), # tb_intro_md
+ t("icr.description", lang), # tb_icr_md
+ gr.update(label=t("toolbox.icr_upload", lang)), # tb_icr_file
+ gr.update(label=t("toolbox.icr_select_cols", lang)), # tb_icr_cols
+ gr.update(label=t("toolbox.icr_mode", lang)), # tb_icr_mode
+ gr.update(value=t("icr.compute_btn", lang)), # tb_icr_btn
+ gr.update(label=t("icr.report", lang)), # tb_icr_out
+ gr.update(value=t("toolbox.download_example", lang)), # tb_icr_ex_btn
+ t("consensus.description", lang), # tb_con_md
+ gr.update(label=t("toolbox.data_file", lang)), # tb_con_file
+ gr.update(label=t("toolbox.text_col", lang)), # tb_con_tcol
+ gr.update(label=t("toolbox.themes_input", lang)), # tb_con_themes
+ gr.update(value=t("toolbox.add_llm", lang)), # tb_con_add
+ gr.update(value=t("toolbox.remove_llm", lang)), # tb_con_rm
+ gr.update(value=t("consensus.run_btn", lang)), # tb_con_btn
+ gr.update(label=t("consensus.summary", lang)), # tb_con_summary
+ gr.update(label=t("consensus.results", lang)), # tb_con_results
+ gr.update(label=t("consensus.agreement", lang)), # tb_con_agreement
+ gr.update(value=t("toolbox.download_example", lang)), # tb_con_ex_btn
+ t("methods.description", lang), # tb_meth_md
+ gr.update(label=t("toolbox.import_log", lang)), # tb_meth_log
+ gr.update(value=t("methods.generate_btn", lang)), # tb_meth_log_btn
+ gr.update(label=t("methods.text_en", lang)), # tb_meth_en
+ gr.update(label=t("methods.text_zh", lang)), # tb_meth_zh
+ gr.update(value=t("toolbox.example_qt_log", lang)), # tb_meth_ex_qt_btn
+ gr.update(value=t("toolbox.example_ql_log", lang)), # tb_meth_ex_ql_btn
]
_lang_outputs = [
@@ -1499,6 +1943,9 @@ def _switch_language(choice):
ql_lang,
# Landing
landing_md,
+ # Project UI
+ proj_load_btn, proj_load_msg,
+ proj_qt_save_btn, proj_ql_save_btn,
# Step 1
ql_s1_md, ql_file, ql_tpl_btn, ql_tpl_file,
ql_text_preview, ql_s1_seg_md, ql_seg_mode, ql_seg_cw,
@@ -1517,7 +1964,9 @@ def _switch_language(choice):
ql_s4_md, ql_ext_be, ql_ext_mod, ql_ext_key,
ql_ext_btn, ql_ext_msg, ql_ext_tbl,
# Step 5
- ql_s5_md, ql_rev_stats, ql_rev_tbl, ql_s5_detail_md,
+ ql_s5_md, ql_rev_stats, ql_rev_tbl,
+ ql_review_plot, ql_conf_plot, ql_theme_plot,
+ ql_s5_detail_md,
ql_rev_idx, ql_rev_acc, ql_rev_rej, ql_rev_ctx,
ql_rev_edit_rq, ql_rev_edit_sub, ql_rev_edit_btn,
ql_s5_bulk_md, ql_rev_thr, ql_rev_bulk,
@@ -1553,9 +2002,20 @@ def _switch_language(choice):
qt_s4_aft_md, qt_aft_key, qt_aft_mod, qt_aft_ep, qt_aft_sfx,
qt_aft_btn, qt_aft_stat, qt_aft_chk, qt_aft_cnl, qt_aft_res,
# ---- QuantiKit Step 5 ----
- qt_s5_md, qt_ebtn, qt_eout,
+ qt_s5_md, qt_ebtn, qt_cm_plot, qt_pc_plot, qt_s5_text_acc, qt_eout,
# ---- QuantiKit Step 6 ----
qt_s6_md, qt_xbtn, qt_xfile,
+ # ---- Toolbox ----
+ tb_intro_md,
+ tb_icr_md, tb_icr_file, tb_icr_cols,
+ tb_icr_mode, tb_icr_btn, tb_icr_out,
+ tb_icr_ex_btn,
+ tb_con_md, tb_con_file, tb_con_tcol, tb_con_themes,
+ tb_con_add, tb_con_rm,
+ tb_con_btn, tb_con_summary, tb_con_results, tb_con_agreement,
+ tb_con_ex_btn,
+ tb_meth_md, tb_meth_log, tb_meth_log_btn, tb_meth_en, tb_meth_zh,
+ tb_meth_ex_qt_btn, tb_meth_ex_ql_btn,
]
lang_selector.change(
diff --git a/socialscikit/ui/qualikit_app.py b/socialscikit/ui/qualikit_app.py
index 99e8e8e..3726f1f 100644
--- a/socialscikit/ui/qualikit_app.py
+++ b/socialscikit/ui/qualikit_app.py
@@ -37,6 +37,7 @@
from socialscikit.qualikit.segmenter import Segmenter
from socialscikit.qualikit.segment_extractor import ResearchQuestion, SegmentExtractor
from socialscikit.qualikit.theme_reviewer import ThemeReviewer
+from socialscikit.core import charts
# ---------------------------------------------------------------------------
# Shared instances
@@ -450,6 +451,67 @@ def _accept_all_high_coding(review_session_state):
return review_session_state, msg
+# ---------------------------------------------------------------------------
+# Step 4b: Export Pipeline Log
+# ---------------------------------------------------------------------------
+
+
+def _export_pipeline_log(ext_session_state, rqs_state, review_session_state):
+ """Export QualiKit pipeline metadata as JSON for the Toolbox Methods Generator."""
+ import json, tempfile
+
+ if ext_session_state is None:
+ return None
+
+ log = {"pipeline": "qualikit"}
+
+ # Segment & theme info from extraction session
+ if hasattr(ext_session_state, "results") and ext_session_state.results:
+ results = ext_session_state.results
+ log["n_segments"] = len(results)
+
+ # Collect all themes from extraction results
+ all_themes = set()
+ for r in results:
+ if hasattr(r, "themes"):
+ all_themes.update(r.themes)
+ elif hasattr(r, "research_question"):
+ all_themes.add(r.research_question)
+ if all_themes:
+ log["theme_names"] = sorted(all_themes)
+ log["n_themes"] = len(all_themes)
+
+ # Research questions
+ if rqs_state:
+ rq_names = []
+ for rq in rqs_state:
+ if hasattr(rq, "name"):
+ rq_names.append(rq.name)
+ elif hasattr(rq, "question"):
+ rq_names.append(rq.question)
+ if rq_names:
+ log["research_questions"] = rq_names
+
+ # Review stats
+ if review_session_state is not None:
+ stats = {"accepted": 0, "rejected": 0, "edited": 0, "pending": 0}
+ for tier in [review_session_state.high, review_session_state.medium, review_session_state.low]:
+ for item in tier:
+ action = item.action.value if hasattr(item.action, "value") else str(item.action)
+ if action in stats:
+ stats[action] += 1
+ else:
+ stats["pending"] += 1
+ log["review_stats"] = stats
+
+ tmp = tempfile.NamedTemporaryFile(
+ mode="w", suffix="_qualikit_log.json", delete=False, encoding="utf-8",
+ )
+ json.dump(log, tmp, ensure_ascii=False, indent=2)
+ tmp.close()
+ return tmp.name
+
+
# ---------------------------------------------------------------------------
# Step 5: Export
# ---------------------------------------------------------------------------
@@ -622,6 +684,55 @@ def _ext_stats_text(session):
)
+def _make_ql_charts(session):
+ """Generate QualiKit visualization charts for the review dashboard.
+
+ Returns
+ -------
+ tuple
+ (review_cards_html, review_progress_fig, confidence_fig, theme_fig)
+ """
+ _empty = ("", None, None, None)
+ if session is None or not session.items:
+ return _empty
+
+ try:
+ stats = _extraction_reviewer.stats(session)
+
+ # Metric cards HTML
+ html = charts.format_review_stats_html(
+ stats["total"], stats["accepted"], stats["edited"],
+ stats["rejected"], stats["pending"],
+ )
+
+ # Review progress donut
+ review_fig = charts.plot_review_progress(
+ stats["accepted"], stats["edited"],
+ stats["rejected"], stats["pending"],
+ )
+
+ # Confidence histogram
+ confidences = [
+ item.result.confidence
+ for item in session.items
+ if item.result.confidence is not None
+ ]
+ conf_fig = charts.plot_confidence_histogram(confidences)
+
+ # Theme / RQ distribution
+ themes: dict[str, int] = {}
+ for item in session.items:
+ rq = item.final_rq_label
+ themes[rq] = themes.get(rq, 0) + 1
+ theme_fig = charts.plot_theme_distribution(themes)
+
+ return html, review_fig, conf_fig, theme_fig
+ except Exception as e:
+ import logging
+ logging.getLogger(__name__).warning("Chart generation failed: %s", e)
+ return _empty
+
+
def _run_extraction(raw_text, segments, rqs_text, backend, model, api_key):
"""LLM extraction — returns (session, summary, ext_tbl, rev_tbl, stats)."""
if not segments:
@@ -734,7 +845,41 @@ def _ext_show_context(session, index, raw_text):
}
status = status_map.get(item.action.value, item.action.value)
- # --- Part 1: full segment text ---
+ # --- Part 1: full segment text (with evidence highlighting if available) ---
+ seg_text = item.result.text
+ evidence = getattr(item.result, "evidence_span", "")
+
+ # Build segment body — highlight evidence span inline if found
+ if evidence:
+ ev_lower = evidence.lower()
+ seg_lower = seg_text.lower()
+ ev_idx = seg_lower.find(ev_lower)
+ if ev_idx >= 0:
+ before = esc(seg_text[:ev_idx])
+ match_text = esc(seg_text[ev_idx:ev_idx + len(evidence)])
+ after = esc(seg_text[ev_idx + len(evidence):])
+ seg_body = (
+ f'{before}'
+ '
'
+ f'{match_text}'
+ f'{after}'
+ )
+ else:
+ # Evidence not found verbatim — show segment + evidence block
+ seg_body = esc(seg_text)
+ else:
+ seg_body = esc(seg_text)
+
+ # Evidence block shown below segment text when present
+ evidence_html = ""
+ if evidence:
+ evidence_html = (
+ '
'
+ f'Evidence: \u201c{esc(evidence)}\u201d
'
+ )
+
full_text_html = (
'
'
@@ -747,7 +892,8 @@ def _ext_show_context(session, index, raw_text):
'
'
f'
'
- f'{esc(item.result.text)}
'
+ f'{seg_body}
'
+ f'{evidence_html}'
f'
'
f'判断依据:{esc(item.result.reasoning)}
'
'
'
@@ -1221,6 +1367,8 @@ def create_app() -> gr.Blocks:
# =============================================================
# Tab 4: LLM Coding
# =============================================================
+ consensus_report_state = gr.State(None)
+
with gr.Tab("4. 编码"):
gr.Markdown("使用 LLM 对文本进行主题编码。需要先锁定主题框架。")
with gr.Row():
@@ -1248,6 +1396,9 @@ def create_app() -> gr.Blocks:
outputs=[coding_review_state, code_review_msg],
)
+ # Consensus Coding has moved to the unified Toolbox tab
+ # in main_app.py — see `socialscikit.ui.toolbox_app`.
+
# =============================================================
# Tab 5: Export
# =============================================================
@@ -1269,6 +1420,9 @@ def create_app() -> gr.Blocks:
outputs=[export_excel, export_memo, export_msg],
)
+ # ICR and Methods Generator have moved to the unified Toolbox tab
+ # in main_app.py — see `socialscikit.ui.toolbox_app`.
+
return app
diff --git a/socialscikit/ui/quantikit_app.py b/socialscikit/ui/quantikit_app.py
index be3c593..8450781 100644
--- a/socialscikit/ui/quantikit_app.py
+++ b/socialscikit/ui/quantikit_app.py
@@ -34,6 +34,7 @@
from socialscikit.quantikit.method_recommender import MethodRecommender
from socialscikit.quantikit.prompt_classifier import PromptClassifier
from socialscikit.quantikit.prompt_optimizer import PromptOptimizer, PromptVariant
+from socialscikit.core import charts
logger = logging.getLogger(__name__)
@@ -728,6 +729,22 @@ def _get_stats_text(session):
)
+def _make_annotation_chart(session):
+ """Generate annotation progress donut chart for the dashboard.
+
+ Returns (progress_fig,) for .then() chaining.
+ """
+ if session is None:
+ return None
+ try:
+ stats = session.stats()
+ return charts.plot_annotation_progress(
+ stats.labeled, stats.skipped, stats.flagged, stats.pending,
+ )
+ except Exception:
+ return None
+
+
def _get_current_text(session):
if session is None:
return ""
@@ -1501,11 +1518,18 @@ def _cancel_api_ft_job(job_id_state, api_key):
def _evaluate_results(result_df_state, df_state, label_col, pred_col="predicted_label"):
- """Evaluate predictions against ground truth."""
+ """Evaluate predictions against ground truth.
+
+ Returns
+ -------
+ tuple
+ (text_report, metrics_html, confusion_fig, per_class_fig)
+ """
+ _empty = ("", "", None, None)
if result_df_state is None or df_state is None:
- return "请先运行分类。"
+ return ("请先运行分类。", *_empty[1:])
if not label_col or label_col not in df_state.columns:
- return "未找到标签列,无法评估。请确认数据中包含真实标签。"
+ return ("未找到标签列,无法评估。请确认数据中包含真实标签。", *_empty[1:])
true_labels = df_state[label_col].dropna().astype(str).tolist()
pred_labels = result_df_state[pred_col].tolist()
@@ -1517,7 +1541,82 @@ def _evaluate_results(result_df_state, df_state, label_col, pred_col="predicted_
evaluator = Evaluator()
report = evaluator.evaluate(true_labels, pred_labels)
- return Evaluator.format_report(report)
+
+ text = Evaluator.format_report(report)
+
+ # Metric summary cards
+ metrics_html = charts.format_eval_metrics_html(
+ report.accuracy, report.macro_f1, report.weighted_f1,
+ report.cohens_kappa, report.n_total, report.n_correct,
+ )
+
+ # Confusion matrix chart
+ cm_fig = None
+ if report.confusion_matrix:
+ try:
+ cm_fig = charts.plot_confusion_matrix(
+ report.confusion_matrix.labels, report.confusion_matrix.matrix,
+ )
+ except Exception as e:
+ logger.warning("Failed to plot confusion matrix: %s", e)
+
+ # Per-class metrics chart
+ pc_fig = None
+ if report.per_class:
+ try:
+ pc_data = [
+ {"label": pc.label, "precision": pc.precision,
+ "recall": pc.recall, "f1": pc.f1, "support": pc.support}
+ for pc in report.per_class
+ ]
+ pc_fig = charts.plot_per_class_metrics(pc_data)
+ except Exception as e:
+ logger.warning("Failed to plot per-class metrics: %s", e)
+
+ return text, metrics_html, cm_fig, pc_fig
+
+
+# ---------------------------------------------------------------------------
+# Step 5b: Export Pipeline Log
+# ---------------------------------------------------------------------------
+
+
+def _export_pipeline_log(result_df_state, df_state, label_col, pred_col="predicted_label"):
+ """Export QuantiKit pipeline metadata as JSON for the Toolbox Methods Generator."""
+ import json, tempfile
+
+ if result_df_state is None or df_state is None:
+ return None
+
+ log = {"pipeline": "quantikit", "n_samples": int(len(df_state))}
+
+ # Class info from predictions
+ if "predicted_label" in result_df_state.columns:
+ labels = result_df_state["predicted_label"].dropna().unique().tolist()
+ log["n_classes"] = len(labels)
+ log["class_labels"] = [str(l) for l in sorted(labels)]
+
+ # Evaluation metrics (re-compute if ground truth available)
+ if label_col and label_col in df_state.columns:
+ true_labels = df_state[label_col].dropna().astype(str).tolist()
+ pred_labels = result_df_state[pred_col].tolist()
+ min_len = min(len(true_labels), len(pred_labels))
+ true_labels = true_labels[:min_len]
+ pred_labels = pred_labels[:min_len]
+
+ evaluator = Evaluator()
+ report = evaluator.evaluate(true_labels, pred_labels)
+ log["accuracy"] = report.accuracy
+ log["macro_f1"] = report.macro_f1
+ log["weighted_f1"] = report.weighted_f1
+ log["cohens_kappa"] = report.cohens_kappa
+
+ tmp = tempfile.NamedTemporaryFile(
+ mode="w", suffix="_quantikit_log.json", delete=False, encoding="utf-8",
+ )
+ json.dump(log, tmp, ensure_ascii=False, indent=2)
+ tmp.close()
+ return tmp.name
# ---------------------------------------------------------------------------
@@ -2008,6 +2107,8 @@ def _ft_wrapper(df_state, text_col, label_col, model, bs, epochs, lr):
# =============================================================
# Tab 5: Evaluation
# =============================================================
+ eval_report_state = gr.State(None)
+
with gr.Tab("5. 评估"):
gr.Markdown("将分类结果与真实标签对比,计算评估指标。")
eval_label_col = gr.Textbox(label="真实标签列名", value="label")
@@ -2017,9 +2118,12 @@ def _ft_wrapper(df_state, text_col, label_col, model, bs, epochs, lr):
eval_btn.click(
fn=_evaluate_results,
inputs=[result_df_state, df_state, eval_label_col],
- outputs=[eval_output],
+ outputs=[eval_output, eval_report_state],
)
+ # ICR and Methods Generator have moved to the unified Toolbox tab
+ # in main_app.py — see `socialscikit.ui.toolbox_app`.
+
# =============================================================
# Tab 6: Export
# =============================================================
diff --git a/socialscikit/ui/toolbox_app.py b/socialscikit/ui/toolbox_app.py
new file mode 100644
index 0000000..4858db8
--- /dev/null
+++ b/socialscikit/ui/toolbox_app.py
@@ -0,0 +1,502 @@
+"""Toolbox tab callbacks — standalone ICR, Consensus Coding, and Methods Generator."""
+
+from __future__ import annotations
+
+import json
+import logging
+import tempfile
+from pathlib import Path
+
+import pandas as pd
+
+from socialscikit.core.icr import ICRCalculator
+from socialscikit.core.llm_client import LLMClient
+from socialscikit.core.methods_writer import (
+ MethodsWriter,
+ QuantiKitPipelineMetadata,
+ QualiKitPipelineMetadata,
+)
+from socialscikit.core import charts
+from socialscikit.qualikit.consensus import ConsensusCoder
+from socialscikit.qualikit.theme_definer import Theme
+
+logger = logging.getLogger(__name__)
+
+# Max number of LLM slots pre-created in the Consensus UI
+MAX_LLM_SLOTS = 5
+
+# Example files directory
+_EXAMPLES_DIR = Path(__file__).resolve().parent.parent.parent / "examples"
+
+
+# ---------------------------------------------------------------------------
+# ICR Calculator
+# ---------------------------------------------------------------------------
+
+
+def _icr_on_upload(file):
+ """When a CSV is uploaded, return its column names for the CheckboxGroup."""
+ if file is None:
+ return [], ""
+ try:
+ import gradio as gr
+ df = pd.read_csv(file.name if hasattr(file, "name") else file)
+ cols = df.columns.tolist()
+ return gr.update(choices=cols, value=[]), f"{len(df)} rows, {len(cols)} columns"
+ except Exception as e:
+ return [], f"Failed to read: {e}"
+
+
+def _compute_icr(file, selected_cols, mode):
+ """Compute ICR from one CSV with N selected coder columns.
+
+ Auto-selects metric:
+ - 2 coders → Cohen's Kappa + Krippendorff's Alpha + per-category
+ - 3+ coders → Krippendorff's Alpha only (Cohen's Kappa is 2-coder only)
+
+ Parameters
+ ----------
+ file : uploaded CSV
+ selected_cols : list[str] — selected column names (each = one coder)
+ mode : "single-label" or "multi-label"
+ """
+ if file is None:
+ return "Please upload a CSV file."
+
+ if not selected_cols or len(selected_cols) < 2:
+ return "Please select at least 2 coder columns."
+
+ try:
+ df = pd.read_csv(file.name if hasattr(file, "name") else file)
+ except Exception as e:
+ return f"Failed to read CSV: {e}"
+
+ for col in selected_cols:
+ if col not in df.columns:
+ return f"Column '{col}' not found."
+
+ n_coders = len(selected_cols)
+ calc = ICRCalculator()
+
+ if mode == "multi-label":
+ # Multi-label: comma-separated values → sets
+ if n_coders == 2:
+ themes1 = [
+ set(s.strip() for s in str(v).split(",") if s.strip())
+ for v in df[selected_cols[0]].fillna("")
+ ]
+ themes2 = [
+ set(s.strip() for s in str(v).split(",") if s.strip())
+ for v in df[selected_cols[1]].fillna("")
+ ]
+ report = calc.compute_all_multilabel(themes1, themes2)
+ report.coder_labels = selected_cols
+ report.summary_text = calc.format_report(report, multilabel=True)
+ return report.summary_text
+ else:
+ # 3+ coders multi-label: pairwise Jaccard average
+ from socialscikit.core.icr import ICRReport, ICRResult
+ all_themes_per_coder = []
+ for col in selected_cols:
+ themes = [
+ set(s.strip() for s in str(v).split(",") if s.strip())
+ for v in df[col].fillna("")
+ ]
+ all_themes_per_coder.append(themes)
+
+ # Pairwise Jaccard
+ from itertools import combinations
+ pair_jaccards = []
+ pair_labels = []
+ for i, j in combinations(range(n_coders), 2):
+ r = calc.compute_multilabel_agreement(
+ all_themes_per_coder[i], all_themes_per_coder[j]
+ )
+ pair_jaccards.append(r.value)
+ pair_labels.append(f"{selected_cols[i]} vs {selected_cols[j]}: {r.value:.4f}")
+
+ avg_jaccard = sum(pair_jaccards) / len(pair_jaccards)
+ lines = [
+ f"═══ Inter-Coder Reliability Report ({n_coders} coders, multi-label) ═══",
+ "",
+ f"Coders: {', '.join(selected_cols)}",
+ f"Items: {len(df)}",
+ "",
+ f"Average pairwise Jaccard: {avg_jaccard:.4f} ({calc._interpret_jaccard(avg_jaccard)})",
+ "",
+ "Pairwise breakdown:",
+ ]
+ for lbl in pair_labels:
+ lines.append(f" {lbl}")
+ return "\n".join(lines)
+
+ else:
+ # Single-label mode
+ if n_coders == 2:
+ labels1 = df[selected_cols[0]].astype(str).tolist()
+ labels2 = df[selected_cols[1]].astype(str).tolist()
+ report = calc.compute_all(labels1, labels2)
+ report.coder_labels = selected_cols
+ report.summary_text = calc.format_report(report)
+ return report.summary_text
+ else:
+ # 3+ coders: Krippendorff's Alpha only
+ # Build reliability matrix: (n_items, n_coders)
+ from socialscikit.core.icr import ICRReport, ICRResult
+ reliability_matrix = []
+ for _, row in df.iterrows():
+ item = []
+ for col in selected_cols:
+ val = row[col]
+ if pd.isna(val) or str(val).strip() == "":
+ item.append(None)
+ else:
+ item.append(str(val).strip())
+ reliability_matrix.append(item)
+
+ alpha_result = calc.compute_krippendorffs_alpha(reliability_matrix)
+
+ # Also compute pairwise Cohen's Kappa for reference
+ from itertools import combinations
+ pair_kappas = []
+ pair_labels = []
+ for i, j in combinations(range(n_coders), 2):
+ c_i = df[selected_cols[i]].astype(str).tolist()
+ c_j = df[selected_cols[j]].astype(str).tolist()
+ r = calc.compute_cohens_kappa(c_i, c_j)
+ pair_kappas.append(r.value)
+ pair_labels.append(
+ f"{selected_cols[i]} vs {selected_cols[j]}: "
+ f"κ = {r.value:.4f} ({r.interpretation})"
+ )
+
+ # Collect all categories
+ all_cats = set()
+ for row in reliability_matrix:
+ for v in row:
+ if v is not None:
+ all_cats.add(v)
+
+ lines = [
+ f"═══ Inter-Coder Reliability Report ({n_coders} coders) ═══",
+ "",
+ f"Coders: {', '.join(selected_cols)}",
+ f"Items: {len(reliability_matrix)}",
+ f"Categories: {len(all_cats)} ({', '.join(sorted(all_cats)[:10])}{'...' if len(all_cats) > 10 else ''})",
+ "",
+ f"Krippendorff's Alpha: {alpha_result.value:.4f} ({alpha_result.interpretation})",
+ "",
+ "Pairwise Cohen's Kappa:",
+ ]
+ for lbl in pair_labels:
+ lines.append(f" {lbl}")
+
+ avg_kappa = sum(pair_kappas) / len(pair_kappas) if pair_kappas else 0
+ lines.append(f"\nAverage pairwise Kappa: {avg_kappa:.4f} ({calc.interpret_kappa(avg_kappa)})")
+
+ return "\n".join(lines)
+
+
+def _make_icr_chart(file, selected_cols, mode):
+ """Generate ICR agreement bar chart from the same inputs as _compute_icr.
+
+ Returns a single matplotlib figure showing pairwise agreement metrics.
+ """
+ if file is None or not selected_cols or len(selected_cols) < 2:
+ return None
+
+ try:
+ import matplotlib
+ matplotlib.use("Agg")
+ import matplotlib.pyplot as plt
+ import numpy as np
+
+ df = pd.read_csv(file.name if hasattr(file, "name") else file)
+ calc = ICRCalculator()
+ n_coders = len(selected_cols)
+
+ from itertools import combinations
+
+ pair_names = []
+ pair_values = []
+
+ if mode == "multi-label":
+ all_themes = []
+ for col in selected_cols:
+ themes = [
+ set(s.strip() for s in str(v).split(",") if s.strip())
+ for v in df[col].fillna("")
+ ]
+ all_themes.append(themes)
+ for i, j in combinations(range(n_coders), 2):
+ r = calc.compute_multilabel_agreement(all_themes[i], all_themes[j])
+ pair_names.append(f"{selected_cols[i]}\nvs\n{selected_cols[j]}")
+ pair_values.append(r.value)
+ else:
+ for i, j in combinations(range(n_coders), 2):
+ c_i = df[selected_cols[i]].astype(str).tolist()
+ c_j = df[selected_cols[j]].astype(str).tolist()
+ r = calc.compute_cohens_kappa(c_i, c_j)
+ pair_names.append(f"{selected_cols[i]}\nvs\n{selected_cols[j]}")
+ pair_values.append(r.value)
+
+ if not pair_values:
+ return None
+
+ charts._setup_style()
+ n = len(pair_values)
+ figsize = (max(4, n * 1.5 + 1), 4)
+ fig, ax = plt.subplots(figsize=figsize, dpi=150)
+ fig.patch.set_facecolor("white")
+
+ colors = [charts.CAT_COLORS[i % len(charts.CAT_COLORS)] for i in range(n)]
+ bars = ax.bar(range(n), pair_values, color=colors, alpha=0.85,
+ edgecolor="white", linewidth=0.5, width=0.6)
+
+ for bar, v in zip(bars, pair_values):
+ ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.02,
+ f"{v:.3f}", ha="center", va="bottom", fontsize=9,
+ color=charts.PALETTE["text"], fontweight="500")
+
+ # Reference lines
+ ax.axhline(y=0.8, color=charts.PALETTE["secondary"], linewidth=0.8,
+ linestyle="--", alpha=0.5)
+ ax.axhline(y=0.6, color=charts.PALETTE["warning"], linewidth=0.8,
+ linestyle="--", alpha=0.5)
+ ax.text(n - 0.5, 0.81, "Good", fontsize=7,
+ color=charts.PALETTE["secondary"], alpha=0.7)
+ ax.text(n - 0.5, 0.61, "Moderate", fontsize=7,
+ color=charts.PALETTE["warning"], alpha=0.7)
+
+ ax.set_xticks(range(n))
+ ax.set_xticklabels(pair_names, fontsize=8)
+ ax.set_ylabel("Agreement" if mode == "multi-label" else "Cohen's κ",
+ fontsize=10, color=charts.PALETTE["text_sec"])
+ ax.set_title("Pairwise Agreement", fontsize=12, fontweight="600",
+ color=charts.PALETTE["text"], pad=10)
+ ax.set_ylim(0, max(max(pair_values) + 0.15, 1.0))
+ charts._clean_ax(ax)
+ ax.grid(axis="y", color=charts.PALETTE["grid"], linewidth=0.6)
+
+ fig.tight_layout(pad=1.2)
+ return fig
+ except Exception as e:
+ logger.warning("ICR chart failed: %s", e)
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Consensus Coding
+# ---------------------------------------------------------------------------
+
+
+def _run_standalone_consensus(data_file, text_col, themes_text, *llm_args):
+ """Standalone consensus coding with variable number of LLMs.
+
+ llm_args is a flat tuple: (b1, m1, k1, b2, m2, k2, ..., bN, mN, kN)
+ for up to MAX_LLM_SLOTS slots.
+ """
+ if data_file is None:
+ return "Please upload a data file.", None, ""
+
+ try:
+ df = pd.read_csv(data_file.name if hasattr(data_file, "name") else data_file)
+ except Exception as e:
+ return f"Failed to read CSV: {e}", None, ""
+
+ text_col = (text_col or "text").strip()
+ if text_col not in df.columns:
+ return f"Column '{text_col}' not found. Available: {', '.join(df.columns[:10])}", None, ""
+
+ texts = df[text_col].dropna().astype(str).tolist()
+ if not texts:
+ return "No texts found in the specified column.", None, ""
+
+ # Parse themes (one per line)
+ if not themes_text or not themes_text.strip():
+ return "Please define at least one theme (one per line).", None, ""
+
+ theme_lines = [line.strip() for line in themes_text.strip().split("\n") if line.strip()]
+ themes = []
+ for line in theme_lines:
+ if ":" in line:
+ name, desc = line.split(":", 1)
+ themes.append(Theme(name=name.strip(), description=desc.strip()))
+ else:
+ themes.append(Theme(name=line, description=""))
+
+ # Build LLM clients from variable-length args (groups of 3)
+ clients = []
+ args = list(llm_args)
+ for i in range(0, len(args), 3):
+ if i + 2 >= len(args):
+ break
+ backend, model, api_key = args[i], args[i + 1], args[i + 2]
+ if model and str(model).strip():
+ if not api_key and backend != "ollama":
+ continue
+ clients.append(LLMClient(
+ backend=backend, model=str(model).strip(),
+ api_key=str(api_key).strip() if api_key else None,
+ ))
+
+ if len(clients) < 2:
+ return "Consensus coding requires at least 2 valid LLMs configured.", None, ""
+
+ try:
+ consensus = ConsensusCoder(clients)
+ report = consensus.code(texts, themes)
+ except Exception as e:
+ return f"Consensus coding failed: {e}", None, ""
+
+ # Summary
+ summary = ConsensusCoder.format_report(report, lang="en")
+
+ # Results table
+ rows = []
+ for seg in report.segments:
+ rows.append({
+ "ID": seg.text_id,
+ "Text": (seg.text[:80] + "...") if len(seg.text) > 80 else seg.text,
+ "Consensus Themes": ", ".join(seg.consensus_themes),
+ "Agreement": f"{seg.agreement_rate:.2%}",
+ "Votes": "; ".join(f"{t}: {c}/{report.n_coders}" for t, c in seg.vote_counts.items()),
+ })
+ results_df = pd.DataFrame(rows) if rows else None
+
+ # Agreement
+ agreement = f"Overall agreement: {report.overall_agreement:.2%}\n"
+ agreement += f"Models: {', '.join(report.coder_models)}\n"
+ agreement += f"Total cost: ${report.total_cost:.4f}"
+
+ return summary, results_df, agreement
+
+
+# ---------------------------------------------------------------------------
+# Methods Section Generator
+# ---------------------------------------------------------------------------
+
+
+def _generate_methods_from_log(log_file):
+ """Import pipeline log JSON and auto-generate methods section."""
+ if log_file is None:
+ return "Please upload a pipeline log JSON file.", ""
+
+ try:
+ path = log_file.name if hasattr(log_file, "name") else log_file
+ with open(path, "r", encoding="utf-8") as f:
+ log = json.load(f)
+ except Exception as e:
+ return f"Failed to read log file: {e}", ""
+
+ pipeline = log.get("pipeline", "")
+ writer = MethodsWriter()
+
+ if pipeline == "quantikit":
+ meta = QuantiKitPipelineMetadata()
+ meta.n_samples = log.get("n_samples", 0)
+ meta.n_classes = log.get("n_classes", 0)
+ meta.class_labels = log.get("class_labels", [])
+ meta.model_name = log.get("model_name", "")
+ meta.model_backend = log.get("model_backend", "")
+ meta.classification_method = log.get("classification_method", "")
+ meta.n_annotations = log.get("n_annotations", 0)
+ meta.accuracy = log.get("accuracy", 0.0)
+ meta.macro_f1 = log.get("macro_f1", 0.0)
+ meta.weighted_f1 = log.get("weighted_f1", 0.0)
+ meta.cohens_kappa = log.get("cohens_kappa", 0.0)
+ section = writer.generate_quantikit_methods(meta)
+
+ elif pipeline == "qualikit":
+ meta = QualiKitPipelineMetadata()
+ meta.n_segments = log.get("n_segments", 0)
+ meta.n_themes = log.get("n_themes", 0)
+ meta.theme_names = log.get("theme_names", [])
+ meta.coding_model_name = log.get("coding_model_name", "")
+ meta.coding_model_backend = log.get("coding_model_backend", "")
+ meta.consensus_coding_used = log.get("consensus_coding_used", False)
+ meta.n_consensus_models = log.get("n_consensus_models", 0)
+ meta.consensus_model_names = log.get("consensus_model_names", [])
+ meta.consensus_agreement = log.get("consensus_agreement", 0.0)
+ meta.deidentification_performed = log.get("deidentification_performed", False)
+ meta.n_high_confidence = log.get("n_high_confidence", 0)
+ meta.n_medium_confidence = log.get("n_medium_confidence", 0)
+ meta.n_low_confidence = log.get("n_low_confidence", 0)
+ review = log.get("review_stats", {})
+ meta.n_accepted = review.get("accepted", 0)
+ meta.n_rejected = review.get("rejected", 0)
+ meta.n_edited = review.get("edited", 0)
+ section = writer.generate_qualikit_methods(meta)
+
+ else:
+ return (
+ f"Unknown pipeline type '{pipeline}'. "
+ "Expected 'quantikit' or 'qualikit' in the log JSON.",
+ "",
+ )
+
+ return section.text_en, section.text_zh
+
+
+def _generate_methods_from_form(
+ pipeline_type,
+ qt_n_samples, qt_n_classes, qt_class_labels, qt_model_name,
+ qt_accuracy, qt_macro_f1, qt_cohens_kappa,
+ ql_n_segments, ql_n_themes, ql_theme_names, ql_model_name,
+ ql_consensus_used, ql_n_consensus_models,
+):
+ """Fallback: generate methods section from manually filled form fields."""
+ writer = MethodsWriter()
+
+ if pipeline_type == "QuantiKit":
+ meta = QuantiKitPipelineMetadata()
+ meta.n_samples = int(qt_n_samples or 0)
+ meta.n_classes = int(qt_n_classes or 0)
+ if qt_class_labels:
+ meta.class_labels = [s.strip() for s in qt_class_labels.split(",") if s.strip()]
+ meta.model_name = qt_model_name or ""
+ meta.accuracy = float(qt_accuracy or 0)
+ meta.macro_f1 = float(qt_macro_f1 or 0)
+ meta.cohens_kappa = float(qt_cohens_kappa or 0)
+ section = writer.generate_quantikit_methods(meta)
+
+ elif pipeline_type == "QualiKit":
+ meta = QualiKitPipelineMetadata()
+ meta.n_segments = int(ql_n_segments or 0)
+ meta.n_themes = int(ql_n_themes or 0)
+ if ql_theme_names:
+ meta.theme_names = [s.strip() for s in ql_theme_names.split(",") if s.strip()]
+ meta.coding_model_name = ql_model_name or ""
+ meta.consensus_coding_used = bool(ql_consensus_used)
+ meta.n_consensus_models = int(ql_n_consensus_models or 0)
+ section = writer.generate_qualikit_methods(meta)
+
+ else:
+ return "Please select a pipeline type.", ""
+
+ return section.text_en, section.text_zh
+
+
+# ---------------------------------------------------------------------------
+# Example file downloaders
+# ---------------------------------------------------------------------------
+
+
+def _download_icr_example():
+ """Return the ICR example CSV path."""
+ return str(_EXAMPLES_DIR / "icr_example.csv")
+
+
+def _download_consensus_example():
+ """Return the Consensus Coding example CSV path."""
+ return str(_EXAMPLES_DIR / "consensus_example.csv")
+
+
+def _download_methods_example_qt():
+ """Return the QuantiKit pipeline log example JSON path."""
+ return str(_EXAMPLES_DIR / "methods_log_quantikit.json")
+
+
+def _download_methods_example_ql():
+ """Return the QualiKit pipeline log example JSON path."""
+ return str(_EXAMPLES_DIR / "methods_log_qualikit.json")
diff --git a/tests/test_charts.py b/tests/test_charts.py
new file mode 100644
index 0000000..8de8e35
--- /dev/null
+++ b/tests/test_charts.py
@@ -0,0 +1,274 @@
+"""Tests for socialscikit.core.charts — visualization dashboard charts."""
+
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+import pytest
+
+from socialscikit.core.charts import (
+ format_annotation_stats_html,
+ format_eval_metrics_html,
+ format_review_stats_html,
+ plot_annotation_progress,
+ plot_confidence_histogram,
+ plot_confusion_matrix,
+ plot_label_distribution,
+ plot_per_class_metrics,
+ plot_review_progress,
+ plot_theme_distribution,
+)
+
+
+# ======================================================================
+# Fixtures
+# ======================================================================
+
+
+@pytest.fixture
+def sample_per_class():
+ return [
+ {"label": "pos", "precision": 0.85, "recall": 0.90, "f1": 0.87, "support": 40},
+ {"label": "neg", "precision": 0.78, "recall": 0.72, "f1": 0.75, "support": 30},
+ {"label": "neu", "precision": 0.65, "recall": 0.60, "f1": 0.62, "support": 30},
+ ]
+
+
+@pytest.fixture
+def sample_cm():
+ return {
+ "labels": ["pos", "neg", "neu"],
+ "matrix": [
+ [36, 2, 2],
+ [5, 22, 3],
+ [4, 8, 18],
+ ],
+ }
+
+
+# ======================================================================
+# Confusion matrix
+# ======================================================================
+
+
+class TestConfusionMatrix:
+ def test_basic(self, sample_cm):
+ fig = plot_confusion_matrix(sample_cm["labels"], sample_cm["matrix"])
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_single_class(self):
+ fig = plot_confusion_matrix(["A"], [[10]])
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_large_matrix(self):
+ labels = [f"C{i}" for i in range(10)]
+ matrix = [[5 if i == j else 1 for j in range(10)] for i in range(10)]
+ fig = plot_confusion_matrix(labels, matrix)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_english_labels(self, sample_cm):
+ fig = plot_confusion_matrix(sample_cm["labels"], sample_cm["matrix"], lang="en")
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+
+# ======================================================================
+# Per-class metrics
+# ======================================================================
+
+
+class TestPerClassMetrics:
+ def test_basic(self, sample_per_class):
+ fig = plot_per_class_metrics(sample_per_class)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_empty(self):
+ fig = plot_per_class_metrics([])
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_single_class(self):
+ data = [{"label": "X", "precision": 1.0, "recall": 1.0, "f1": 1.0, "support": 10}]
+ fig = plot_per_class_metrics(data)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_english(self, sample_per_class):
+ fig = plot_per_class_metrics(sample_per_class, lang="en")
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+
+# ======================================================================
+# Label distribution
+# ======================================================================
+
+
+class TestLabelDistribution:
+ def test_basic(self):
+ true = {"pos": 40, "neg": 30, "neu": 30}
+ pred = {"pos": 45, "neg": 25, "neu": 30}
+ fig = plot_label_distribution(true, pred)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_empty(self):
+ fig = plot_label_distribution({}, {})
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_mismatched_labels(self):
+ true = {"A": 10, "B": 20}
+ pred = {"B": 15, "C": 15}
+ fig = plot_label_distribution(true, pred)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+
+# ======================================================================
+# Annotation progress
+# ======================================================================
+
+
+class TestAnnotationProgress:
+ def test_basic(self):
+ fig = plot_annotation_progress(50, 5, 3, 42)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_all_done(self):
+ fig = plot_annotation_progress(100, 0, 0, 0)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_all_pending(self):
+ fig = plot_annotation_progress(0, 0, 0, 100)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_empty(self):
+ fig = plot_annotation_progress(0, 0, 0, 0)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+
+# ======================================================================
+# Confidence histogram
+# ======================================================================
+
+
+class TestConfidenceHistogram:
+ def test_basic(self):
+ confs = [0.1, 0.3, 0.5, 0.7, 0.8, 0.85, 0.9, 0.95, 0.6, 0.4]
+ fig = plot_confidence_histogram(confs)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_empty(self):
+ fig = plot_confidence_histogram([])
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_all_high(self):
+ confs = [0.9, 0.95, 0.99, 0.88, 0.92]
+ fig = plot_confidence_histogram(confs)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+
+# ======================================================================
+# Theme distribution
+# ======================================================================
+
+
+class TestThemeDistribution:
+ def test_basic(self):
+ themes = {"RQ1": 15, "RQ2": 8, "RQ3": 22}
+ fig = plot_theme_distribution(themes)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_empty(self):
+ fig = plot_theme_distribution({})
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_single(self):
+ fig = plot_theme_distribution({"Theme": 5})
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_many_themes(self):
+ themes = {f"T{i}": (i + 1) * 3 for i in range(12)}
+ fig = plot_theme_distribution(themes)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+
+# ======================================================================
+# Review progress
+# ======================================================================
+
+
+class TestReviewProgress:
+ def test_basic(self):
+ fig = plot_review_progress(10, 3, 2, 5)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_all_done(self):
+ fig = plot_review_progress(15, 2, 1, 0)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_none_done(self):
+ fig = plot_review_progress(0, 0, 0, 20)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+ def test_empty(self):
+ fig = plot_review_progress(0, 0, 0, 0)
+ assert isinstance(fig, plt.Figure)
+ plt.close(fig)
+
+
+# ======================================================================
+# HTML metric cards
+# ======================================================================
+
+
+class TestHTMLCards:
+ def test_eval_metrics(self):
+ html = format_eval_metrics_html(0.85, 0.82, 0.84, 0.78, 100, 85)
+ assert "0.8500" in html
+ assert "0.8200" in html
+
+ def test_eval_metrics_english(self):
+ html = format_eval_metrics_html(0.9, 0.88, 0.89, 0.85, 50, 45, lang="en")
+ assert "Accuracy" in html
+ assert "Macro F1" in html
+
+ def test_review_stats(self):
+ html = format_review_stats_html(20, 10, 3, 2, 5)
+ assert "20" in html
+ assert "10" in html
+
+ def test_review_stats_english(self):
+ html = format_review_stats_html(20, 10, 3, 2, 5, lang="en")
+ assert "Accepted" in html
+ assert "Pending" in html
+
+ def test_annotation_stats(self):
+ html = format_annotation_stats_html(100, 60, 5, 2, 33, 120.5)
+ assert "100" in html
+ assert "60" in html
+
+ def test_annotation_stats_with_dist(self):
+ dist = {"pos": 30, "neg": 20, "neu": 10}
+ html = format_annotation_stats_html(100, 60, 5, 2, 33, 120.5, label_dist=dist)
+ assert "100" in html
+ # Distribution bar should be present
+ assert "pos" in html or "neg" in html
diff --git a/tests/test_consensus.py b/tests/test_consensus.py
new file mode 100644
index 0000000..ecac877
--- /dev/null
+++ b/tests/test_consensus.py
@@ -0,0 +1,288 @@
+"""Tests for the Multi-LLM Consensus Coding module."""
+
+import pytest
+from unittest.mock import MagicMock, patch
+
+from socialscikit.core.llm_client import LLMClient, LLMResponse
+from socialscikit.qualikit.coder import Coder, CodingReport, CodingResult
+from socialscikit.qualikit.consensus import ConsensusCoder, ConsensusReport, SegmentConsensus
+from socialscikit.qualikit.theme_definer import Theme
+
+
+@pytest.fixture
+def themes():
+ return [
+ Theme(name="economy", description="Economic topics"),
+ Theme(name="health", description="Health topics"),
+ Theme(name="education", description="Education topics"),
+ ]
+
+
+def _make_mock_client(backend="openai", model="test-model"):
+ """Create a mock LLMClient."""
+ client = MagicMock(spec=LLMClient)
+ client.backend = backend
+ client.model = model
+ client.call_log = []
+ return client
+
+
+def _make_coding_result(text_id, text, themes, confidences=None):
+ """Create a CodingResult for testing."""
+ if confidences is None:
+ confidences = {t: 0.9 for t in themes}
+ return CodingResult(
+ text_id=text_id,
+ text=text,
+ themes=themes,
+ confidences=confidences,
+ trigger_words={t: ["word"] for t in themes},
+ reasoning="test",
+ )
+
+
+# =====================================================================
+# Initialization
+# =====================================================================
+
+
+class TestConsensusCoder:
+ def test_requires_at_least_two_clients(self):
+ with pytest.raises(ValueError, match="at least 2"):
+ ConsensusCoder([_make_mock_client()])
+
+ def test_default_threshold(self):
+ clients = [_make_mock_client(), _make_mock_client()]
+ cc = ConsensusCoder(clients)
+ assert cc.majority_threshold == 1 # ceil(2/2) = 1
+
+ def test_three_coder_threshold(self):
+ clients = [_make_mock_client() for _ in range(3)]
+ cc = ConsensusCoder(clients)
+ assert cc.majority_threshold == 2 # ceil(3/2) = 2
+
+ def test_custom_threshold(self):
+ clients = [_make_mock_client(), _make_mock_client()]
+ cc = ConsensusCoder(clients, majority_threshold=2)
+ assert cc.majority_threshold == 2
+
+
+# =====================================================================
+# Majority Vote
+# =====================================================================
+
+
+class TestMajorityVote:
+ def test_all_agree(self):
+ results = [
+ _make_coding_result(0, "text", ["economy", "health"]),
+ _make_coding_result(0, "text", ["economy", "health"]),
+ _make_coding_result(0, "text", ["economy", "health"]),
+ ]
+ themes, confs, votes = ConsensusCoder._majority_vote(results, threshold=2)
+ assert set(themes) == {"economy", "health"}
+ assert votes["economy"] == 3
+ assert votes["health"] == 3
+
+ def test_none_agree(self):
+ results = [
+ _make_coding_result(0, "text", ["economy"]),
+ _make_coding_result(0, "text", ["health"]),
+ _make_coding_result(0, "text", ["education"]),
+ ]
+ themes, confs, votes = ConsensusCoder._majority_vote(results, threshold=2)
+ assert themes == []
+
+ def test_two_of_three_agree(self):
+ results = [
+ _make_coding_result(0, "text", ["economy", "health"]),
+ _make_coding_result(0, "text", ["economy"]),
+ _make_coding_result(0, "text", ["education"]),
+ ]
+ themes, confs, votes = ConsensusCoder._majority_vote(results, threshold=2)
+ assert "economy" in themes
+ assert "health" not in themes # only 1/3 voted
+ assert "education" not in themes
+
+ def test_confidence_averaging(self):
+ results = [
+ _make_coding_result(0, "text", ["economy"], {"economy": 0.9}),
+ _make_coding_result(0, "text", ["economy"], {"economy": 0.7}),
+ ]
+ themes, confs, votes = ConsensusCoder._majority_vote(results, threshold=1)
+ assert "economy" in themes
+ assert abs(confs["economy"] - 0.8) < 0.01 # avg of 0.9 and 0.7
+
+ def test_empty_results(self):
+ results = [
+ _make_coding_result(0, "text", []),
+ _make_coding_result(0, "text", []),
+ ]
+ themes, confs, votes = ConsensusCoder._majority_vote(results, threshold=1)
+ assert themes == []
+ assert votes == {}
+
+ def test_multi_theme_partial(self):
+ results = [
+ _make_coding_result(0, "text", ["a", "b", "c"]),
+ _make_coding_result(0, "text", ["a", "b"]),
+ _make_coding_result(0, "text", ["a", "d"]),
+ ]
+ themes, confs, votes = ConsensusCoder._majority_vote(results, threshold=2)
+ assert "a" in themes # 3/3
+ assert "b" in themes # 2/3
+ assert "c" not in themes # 1/3
+ assert "d" not in themes # 1/3
+
+
+# =====================================================================
+# Full Consensus Coding (with mocked Coder)
+# =====================================================================
+
+
+class TestConsensusCoding:
+ def test_two_coders_full_agreement(self, themes):
+ """Two coders agree on everything."""
+ clients = [_make_mock_client(), _make_mock_client()]
+ cc = ConsensusCoder(clients)
+
+ # Mock the coders
+ report = CodingReport(
+ results=[
+ _make_coding_result(0, "text1", ["economy"]),
+ _make_coding_result(1, "text2", ["health"]),
+ ],
+ n_total=2, n_coded=2, n_failed=0,
+ theme_distribution={"economy": 1, "health": 1},
+ )
+
+ with patch.object(Coder, 'code', return_value=report):
+ result = cc.code(["text1", "text2"], themes)
+
+ assert isinstance(result, ConsensusReport)
+ assert result.n_total == 2
+ assert result.n_coders == 2
+ assert len(result.segments) == 2
+ assert result.segments[0].consensus_themes == ["economy"]
+ assert result.segments[1].consensus_themes == ["health"]
+
+ def test_two_coders_no_agreement(self, themes):
+ """Two coders disagree on everything (threshold=2 for 2 coders)."""
+ clients = [_make_mock_client(), _make_mock_client()]
+ cc = ConsensusCoder(clients, majority_threshold=2)
+
+ report1 = CodingReport(
+ results=[_make_coding_result(0, "text1", ["economy"])],
+ n_total=1, n_coded=1, n_failed=0,
+ theme_distribution={"economy": 1},
+ )
+ report2 = CodingReport(
+ results=[_make_coding_result(0, "text1", ["health"])],
+ n_total=1, n_coded=1, n_failed=0,
+ theme_distribution={"health": 1},
+ )
+
+ with patch.object(Coder, 'code', side_effect=[report1, report2]):
+ result = cc.code(["text1"], themes)
+
+ assert result.segments[0].consensus_themes == []
+
+ def test_three_coders_partial(self, themes):
+ """Three coders, two agree on 'economy'."""
+ clients = [_make_mock_client() for _ in range(3)]
+ cc = ConsensusCoder(clients) # threshold = ceil(3/2) = 2
+
+ report1 = CodingReport(
+ results=[_make_coding_result(0, "text1", ["economy", "health"])],
+ n_total=1, n_coded=1, n_failed=0,
+ theme_distribution={"economy": 1, "health": 1},
+ )
+ report2 = CodingReport(
+ results=[_make_coding_result(0, "text1", ["economy"])],
+ n_total=1, n_coded=1, n_failed=0,
+ theme_distribution={"economy": 1},
+ )
+ report3 = CodingReport(
+ results=[_make_coding_result(0, "text1", ["education"])],
+ n_total=1, n_coded=1, n_failed=0,
+ theme_distribution={"education": 1},
+ )
+
+ with patch.object(Coder, 'code', side_effect=[report1, report2, report3]):
+ result = cc.code(["text1"], themes)
+
+ assert "economy" in result.segments[0].consensus_themes # 2/3
+ assert "health" not in result.segments[0].consensus_themes # 1/3
+ assert "education" not in result.segments[0].consensus_themes # 1/3
+
+
+# =====================================================================
+# SegmentConsensus
+# =====================================================================
+
+
+class TestSegmentConsensus:
+ def test_to_coding_result(self):
+ seg = SegmentConsensus(
+ text_id=0,
+ text="sample text",
+ consensus_themes=["economy", "health"],
+ consensus_confidences={"economy": 0.9, "health": 0.8},
+ agreement_rate=0.85,
+ individual_results=[
+ _make_coding_result(0, "sample text", ["economy", "health"]),
+ _make_coding_result(0, "sample text", ["economy"]),
+ ],
+ vote_counts={"economy": 2, "health": 1},
+ )
+
+ cr = seg.to_coding_result()
+ assert isinstance(cr, CodingResult)
+ assert cr.text_id == 0
+ assert set(cr.themes) == {"economy", "health"}
+ assert cr.confidences["economy"] == 0.9
+
+
+# =====================================================================
+# ConsensusReport
+# =====================================================================
+
+
+class TestConsensusReport:
+ def test_to_coding_report(self):
+ seg = SegmentConsensus(
+ text_id=0, text="text",
+ consensus_themes=["economy"],
+ consensus_confidences={"economy": 0.9},
+ individual_results=[],
+ )
+ report = ConsensusReport(
+ segments=[seg], n_coders=2, n_total=1, n_coded=1,
+ theme_distribution={"economy": 1},
+ )
+ cr = report.to_coding_report()
+ assert isinstance(cr, CodingReport)
+ assert len(cr.results) == 1
+ assert cr.results[0].themes == ["economy"]
+
+ def test_format_report_zh(self):
+ report = ConsensusReport(
+ n_coders=2, coder_models=["openai:gpt-4o", "anthropic:claude"],
+ n_total=10, n_coded=10, overall_agreement=0.85,
+ theme_distribution={"economy": 5, "health": 3},
+ total_cost=0.05,
+ )
+ text = ConsensusCoder.format_report(report, lang="zh")
+ assert "共识" in text
+ assert "85" in text # 0.85 formatted
+ assert "economy" in text
+
+ def test_format_report_en(self):
+ report = ConsensusReport(
+ n_coders=2, coder_models=["test:a", "test:b"],
+ n_total=5, n_coded=5, overall_agreement=0.9,
+ theme_distribution={"a": 3},
+ total_cost=0.01,
+ )
+ text = ConsensusCoder.format_report(report, lang="en")
+ assert "Consensus" in text
diff --git a/tests/test_icr.py b/tests/test_icr.py
new file mode 100644
index 0000000..9c01eb5
--- /dev/null
+++ b/tests/test_icr.py
@@ -0,0 +1,260 @@
+"""Tests for the ICR (Inter-Coder Reliability) module."""
+
+import pytest
+
+from socialscikit.core.icr import ICRCalculator, ICRReport, ICRResult, PerCategoryAgreement
+
+
+@pytest.fixture
+def calc():
+ return ICRCalculator()
+
+
+# =====================================================================
+# Cohen's Kappa
+# =====================================================================
+
+
+class TestCohensKappa:
+ def test_perfect_agreement(self, calc):
+ labels = ["pos", "neg", "pos", "neg", "pos"]
+ result = calc.compute_cohens_kappa(labels, labels)
+ assert result.metric_name == "cohens_kappa"
+ assert result.value == 1.0
+ assert "perfect" in result.interpretation.lower()
+
+ def test_no_agreement(self, calc):
+ c1 = ["pos", "pos", "pos", "pos"]
+ c2 = ["neg", "neg", "neg", "neg"]
+ result = calc.compute_cohens_kappa(c1, c2)
+ assert result.value <= 0.0
+
+ def test_moderate_agreement(self, calc):
+ c1 = ["pos", "neg", "pos", "neg", "pos", "neg", "pos", "neg", "pos", "neg"]
+ c2 = ["pos", "neg", "pos", "pos", "pos", "neg", "neg", "neg", "pos", "neg"]
+ result = calc.compute_cohens_kappa(c1, c2)
+ assert 0.0 < result.value < 1.0
+ assert result.n_items == 10
+ assert result.n_categories == 2
+
+ def test_empty_input(self, calc):
+ result = calc.compute_cohens_kappa([], [])
+ assert result.value == 0.0
+ assert result.n_items == 0
+
+ def test_length_mismatch(self, calc):
+ with pytest.raises(ValueError, match="Length mismatch"):
+ calc.compute_cohens_kappa(["a", "b"], ["a"])
+
+ def test_single_class(self, calc):
+ c1 = ["pos", "pos", "pos"]
+ c2 = ["pos", "pos", "pos"]
+ result = calc.compute_cohens_kappa(c1, c2)
+ # When all items are the same class, p_e = 1 and kappa is 0 by convention
+ # But our implementation returns 0.0 when p_e >= 1
+ assert result.value >= 0.0
+
+ def test_multiclass(self, calc):
+ c1 = ["a", "b", "c", "a", "b", "c"]
+ c2 = ["a", "b", "c", "b", "a", "c"]
+ result = calc.compute_cohens_kappa(c1, c2)
+ assert result.n_categories == 3
+ assert 0.0 < result.value < 1.0
+
+ def test_explicit_labels(self, calc):
+ c1 = ["pos", "neg"]
+ c2 = ["pos", "pos"]
+ result = calc.compute_cohens_kappa(c1, c2, labels=["pos", "neg", "neutral"])
+ assert result.n_categories == 3
+
+
+# =====================================================================
+# Krippendorff's Alpha
+# =====================================================================
+
+
+class TestKrippendorffsAlpha:
+ def test_perfect_agreement(self, calc):
+ matrix = [
+ ["a", "a"],
+ ["b", "b"],
+ ["c", "c"],
+ ]
+ result = calc.compute_krippendorffs_alpha(matrix)
+ assert result.metric_name == "krippendorffs_alpha"
+ assert result.value == 1.0
+ assert "reliable" in result.interpretation.lower()
+
+ def test_no_agreement(self, calc):
+ # Systematically opposing: coder1=a when coder2=b and vice versa
+ matrix = [
+ ["a", "b"],
+ ["b", "a"],
+ ["a", "b"],
+ ["b", "a"],
+ ]
+ result = calc.compute_krippendorffs_alpha(matrix)
+ assert result.value < 0.667 # unreliable
+
+ def test_with_missing_values(self, calc):
+ matrix = [
+ ["a", "a", None],
+ ["b", None, "b"],
+ ["a", "a", "a"],
+ [None, "b", "b"],
+ ]
+ result = calc.compute_krippendorffs_alpha(matrix)
+ assert result.n_coders == 3
+ assert result.n_items > 0
+
+ def test_empty_matrix(self, calc):
+ result = calc.compute_krippendorffs_alpha([])
+ assert result.value == 0.0
+
+ def test_single_value(self, calc):
+ matrix = [["a", "a"], ["a", "a"]]
+ result = calc.compute_krippendorffs_alpha(matrix)
+ assert result.value == 1.0
+
+ def test_three_coders(self, calc):
+ matrix = [
+ ["a", "a", "a"],
+ ["b", "b", "b"],
+ ["a", "a", "b"],
+ ["b", "a", "b"],
+ ]
+ result = calc.compute_krippendorffs_alpha(matrix)
+ assert result.n_coders == 3
+ assert 0.0 < result.value < 1.0
+
+ def test_unsupported_data_type(self, calc):
+ with pytest.raises(ValueError, match="nominal"):
+ calc.compute_krippendorffs_alpha([["a", "b"]], data_type="interval")
+
+
+# =====================================================================
+# Jaccard Agreement (Multi-label)
+# =====================================================================
+
+
+class TestMultilabelAgreement:
+ def test_identical_sets(self, calc):
+ s1 = [{"a", "b"}, {"c"}, {"a", "b", "c"}]
+ s2 = [{"a", "b"}, {"c"}, {"a", "b", "c"}]
+ result = calc.compute_multilabel_agreement(s1, s2)
+ assert result.value == 1.0
+
+ def test_disjoint_sets(self, calc):
+ s1 = [{"a", "b"}, {"c"}]
+ s2 = [{"c", "d"}, {"a"}]
+ result = calc.compute_multilabel_agreement(s1, s2)
+ assert result.value == 0.0
+
+ def test_partial_overlap(self, calc):
+ s1 = [{"a", "b", "c"}]
+ s2 = [{"a", "b"}]
+ result = calc.compute_multilabel_agreement(s1, s2)
+ # Jaccard = |{a,b}| / |{a,b,c}| = 2/3
+ assert abs(result.value - 2 / 3) < 0.01
+
+ def test_both_empty(self, calc):
+ s1 = [set(), set()]
+ s2 = [set(), set()]
+ result = calc.compute_multilabel_agreement(s1, s2)
+ # Both empty = perfect agreement
+ assert result.value == 1.0
+
+ def test_one_empty(self, calc):
+ s1 = [{"a", "b"}]
+ s2 = [set()]
+ result = calc.compute_multilabel_agreement(s1, s2)
+ assert result.value == 0.0
+
+ def test_empty_input(self, calc):
+ result = calc.compute_multilabel_agreement([], [])
+ assert result.value == 0.0
+
+ def test_length_mismatch(self, calc):
+ with pytest.raises(ValueError, match="Length mismatch"):
+ calc.compute_multilabel_agreement([{"a"}], [{"a"}, {"b"}])
+
+
+# =====================================================================
+# Comprehensive Reports
+# =====================================================================
+
+
+class TestComputeAll:
+ def test_returns_icr_report(self, calc):
+ c1 = ["pos", "neg", "pos", "neg"]
+ c2 = ["pos", "neg", "neg", "neg"]
+ report = calc.compute_all(c1, c2)
+ assert isinstance(report, ICRReport)
+ assert len(report.results) == 2 # kappa + alpha
+ assert report.results[0].metric_name == "cohens_kappa"
+ assert report.results[1].metric_name == "krippendorffs_alpha"
+ assert len(report.per_category) == 2 # pos + neg
+ assert report.summary_text # not empty
+
+ def test_per_category_populated(self, calc):
+ c1 = ["a", "b", "c", "a", "b"]
+ c2 = ["a", "b", "b", "a", "c"]
+ report = calc.compute_all(c1, c2)
+ assert len(report.per_category) == 3
+ for pc in report.per_category:
+ assert isinstance(pc, PerCategoryAgreement)
+ assert 0.0 <= pc.observed_agreement <= 1.0
+ assert 0.0 <= pc.specific_agreement <= 1.0
+
+
+class TestComputeAllMultilabel:
+ def test_returns_report(self, calc):
+ s1 = [{"a", "b"}, {"c"}, {"a"}]
+ s2 = [{"a"}, {"c", "d"}, {"a", "b"}]
+ report = calc.compute_all_multilabel(s1, s2)
+ assert isinstance(report, ICRReport)
+ assert len(report.results) == 2 # jaccard + avg_per_theme_kappa
+ assert report.results[0].metric_name == "jaccard_agreement"
+ assert report.results[1].metric_name == "avg_per_theme_kappa"
+ assert report.summary_text
+
+
+# =====================================================================
+# Interpretation
+# =====================================================================
+
+
+class TestInterpretation:
+ def test_kappa_scale(self, calc):
+ assert "poor" in calc.interpret_kappa(-0.1).lower()
+ assert "slight" in calc.interpret_kappa(0.1).lower()
+ assert "fair" in calc.interpret_kappa(0.3).lower()
+ assert "moderate" in calc.interpret_kappa(0.5).lower()
+ assert "substantial" in calc.interpret_kappa(0.7).lower()
+ assert "perfect" in calc.interpret_kappa(0.9).lower()
+
+ def test_alpha_scale(self, calc):
+ assert "discard" in calc.interpret_alpha(0.5).lower()
+ assert "tentative" in calc.interpret_alpha(0.7).lower()
+ assert "reliable" in calc.interpret_alpha(0.9).lower()
+
+
+# =====================================================================
+# Format Report
+# =====================================================================
+
+
+class TestFormatReport:
+ def test_format_zh(self, calc):
+ c1 = ["a", "b", "a"]
+ c2 = ["a", "a", "a"]
+ report = calc.compute_all(c1, c2)
+ text = report.summary_text
+ assert "信度" in text or "Kappa" in text
+
+ def test_format_en(self, calc):
+ c1 = ["a", "b"]
+ c2 = ["a", "b"]
+ report = calc.compute_all(c1, c2)
+ text = calc.format_report(report, lang="en")
+ assert "Inter-Coder" in text
diff --git a/tests/test_methods_writer.py b/tests/test_methods_writer.py
new file mode 100644
index 0000000..e83c21d
--- /dev/null
+++ b/tests/test_methods_writer.py
@@ -0,0 +1,227 @@
+"""Tests for the Methods Section Auto-generation module."""
+
+import pytest
+
+from socialscikit.core.methods_writer import (
+ MethodsSection,
+ MethodsWriter,
+ QuantiKitPipelineMetadata,
+ QualiKitPipelineMetadata,
+)
+
+
+@pytest.fixture
+def writer():
+ return MethodsWriter()
+
+
+# =====================================================================
+# QuantiKit Methods
+# =====================================================================
+
+
+class TestQuantiKitMethods:
+ def test_full_metadata_en(self, writer):
+ meta = QuantiKitPipelineMetadata(
+ dataset_name="twitter_sentiment",
+ n_samples=5000,
+ n_classes=3,
+ class_labels=["positive", "negative", "neutral"],
+ classification_method="few-shot",
+ model_name="gpt-4o",
+ model_backend="openai",
+ n_annotations=500,
+ prompt_optimization_used=True,
+ n_prompt_variants=5,
+ accuracy=0.87,
+ macro_f1=0.85,
+ weighted_f1=0.86,
+ cohens_kappa=0.80,
+ icr_kappa=0.78,
+ icr_alpha=0.82,
+ )
+ section = writer.generate_quantikit_methods(meta)
+ assert isinstance(section, MethodsSection)
+ assert "5,000" in section.text_en
+ assert "3 categories" in section.text_en
+ assert "few-shot" in section.text_en
+ assert "gpt-4o" in section.text_en
+ assert "500 samples" in section.text_en
+ assert "APE" in section.text_en or "Prompt Engineering" in section.text_en
+ assert "87" in section.text_en # accuracy
+ assert "0.85" in section.text_en # macro f1
+ assert "Kappa" in section.text_en
+ assert "Alpha" in section.text_en
+ assert section.metadata_used # not empty
+
+ def test_full_metadata_zh(self, writer):
+ meta = QuantiKitPipelineMetadata(
+ n_samples=3000,
+ n_classes=2,
+ class_labels=["正面", "负面"],
+ classification_method="zero-shot",
+ model_name="gpt-4o-mini",
+ model_backend="openai",
+ accuracy=0.92,
+ macro_f1=0.90,
+ )
+ section = writer.generate_quantikit_methods(meta)
+ assert "SocialSciKit" in section.text_zh
+ assert "3,000" in section.text_zh
+ assert "零样本" in section.text_zh
+ assert "92" in section.text_zh
+
+ def test_minimal_metadata(self, writer):
+ meta = QuantiKitPipelineMetadata(n_samples=100, n_classes=2)
+ section = writer.generate_quantikit_methods(meta)
+ assert "100" in section.text_en
+ assert "2 categories" in section.text_en
+ assert "SocialSciKit" in section.text_en
+ # Should not contain eval metrics
+ assert "accuracy" not in section.text_en.lower() or "N/A" in section.text_en
+
+ def test_zero_shot_method(self, writer):
+ meta = QuantiKitPipelineMetadata(
+ classification_method="zero-shot", model_name="gpt-4o",
+ )
+ section = writer.generate_quantikit_methods(meta)
+ assert "zero-shot" in section.text_en.lower()
+
+ def test_finetune_method(self, writer):
+ meta = QuantiKitPipelineMetadata(
+ classification_method="fine-tune-api", model_name="gpt-4o",
+ model_backend="openai",
+ )
+ section = writer.generate_quantikit_methods(meta)
+ assert "fine-tuned" in section.text_en.lower()
+
+ def test_with_icr(self, writer):
+ meta = QuantiKitPipelineMetadata(icr_kappa=0.75, icr_alpha=0.80)
+ section = writer.generate_quantikit_methods(meta)
+ assert "Inter-coder" in section.text_en
+ assert "0.75" in section.text_en
+ assert "0.80" in section.text_en
+
+ def test_without_eval(self, writer):
+ meta = QuantiKitPipelineMetadata(n_samples=50)
+ section = writer.generate_quantikit_methods(meta)
+ assert "achieved" not in section.text_en.lower()
+
+
+# =====================================================================
+# QualiKit Methods
+# =====================================================================
+
+
+class TestQualiKitMethods:
+ def test_full_metadata_en(self, writer):
+ meta = QualiKitPipelineMetadata(
+ dataset_name="interviews",
+ n_segments=200,
+ deidentification_performed=True,
+ n_pii_detected=45,
+ n_themes=6,
+ theme_names=["theme1", "theme2", "theme3", "theme4", "theme5", "theme6"],
+ coding_model_name="gpt-4o",
+ coding_model_backend="openai",
+ n_high_confidence=120,
+ n_medium_confidence=50,
+ n_low_confidence=30,
+ n_accepted=150,
+ n_rejected=10,
+ n_edited=40,
+ icr_jaccard=0.78,
+ icr_per_theme_kappa=0.72,
+ )
+ section = writer.generate_qualikit_methods(meta)
+ assert "200" in section.text_en
+ assert "de-identified" in section.text_en.lower()
+ assert "45" in section.text_en
+ assert "6 themes" in section.text_en
+ assert "gpt-4o" in section.text_en
+ assert "high (120)" in section.text_en
+ assert "150 accepted" in section.text_en
+ assert "Jaccard" in section.text_en
+
+ def test_full_metadata_zh(self, writer):
+ meta = QualiKitPipelineMetadata(
+ n_segments=100,
+ n_themes=4,
+ theme_names=["主题1", "主题2", "主题3", "主题4"],
+ coding_model_name="gpt-4o-mini",
+ n_high_confidence=60,
+ n_medium_confidence=30,
+ n_low_confidence=10,
+ )
+ section = writer.generate_qualikit_methods(meta)
+ assert "SocialSciKit" in section.text_zh
+ assert "100" in section.text_zh
+ assert "4 个主题" in section.text_zh
+
+ def test_with_consensus(self, writer):
+ meta = QualiKitPipelineMetadata(
+ n_segments=50,
+ consensus_coding_used=True,
+ n_consensus_models=3,
+ consensus_model_names=["gpt-4o", "claude-sonnet", "llama3"],
+ consensus_agreement=0.85,
+ )
+ section = writer.generate_qualikit_methods(meta)
+ assert "consensus" in section.text_en.lower() or "Multi-LLM" in section.text_en
+ assert "gpt-4o" in section.text_en
+ assert "85" in section.text_en
+
+ def test_with_deident(self, writer):
+ meta = QualiKitPipelineMetadata(
+ n_segments=30,
+ deidentification_performed=True,
+ n_pii_detected=12,
+ )
+ section = writer.generate_qualikit_methods(meta)
+ assert "de-identified" in section.text_en.lower()
+ assert "12" in section.text_en
+
+ def test_without_consensus(self, writer):
+ meta = QualiKitPipelineMetadata(
+ n_segments=50,
+ coding_model_name="claude-sonnet-4-20250514",
+ coding_model_backend="anthropic",
+ )
+ section = writer.generate_qualikit_methods(meta)
+ assert "consensus" not in section.text_en.lower()
+ assert "claude" in section.text_en.lower()
+
+
+# =====================================================================
+# Edge Cases
+# =====================================================================
+
+
+class TestEdgeCases:
+ def test_empty_quantikit_metadata(self, writer):
+ meta = QuantiKitPipelineMetadata()
+ section = writer.generate_quantikit_methods(meta)
+ assert isinstance(section, MethodsSection)
+ assert len(section.text_en) > 0
+ assert len(section.text_zh) > 0
+
+ def test_empty_qualikit_metadata(self, writer):
+ meta = QualiKitPipelineMetadata()
+ section = writer.generate_qualikit_methods(meta)
+ assert isinstance(section, MethodsSection)
+ assert len(section.text_en) > 0
+ assert len(section.text_zh) > 0
+
+ def test_metadata_to_dict(self, writer):
+ meta = QuantiKitPipelineMetadata(n_samples=100, accuracy=0.9)
+ section = writer.generate_quantikit_methods(meta)
+ assert section.metadata_used["n_samples"] == 100
+ assert section.metadata_used["accuracy"] == 0.9
+
+ def test_methods_section_fields(self, writer):
+ meta = QuantiKitPipelineMetadata(n_samples=10)
+ section = writer.generate_quantikit_methods(meta)
+ assert hasattr(section, "text_en")
+ assert hasattr(section, "text_zh")
+ assert hasattr(section, "metadata_used")
+ assert isinstance(section.metadata_used, dict)
diff --git a/tests/test_project_io.py b/tests/test_project_io.py
new file mode 100644
index 0000000..e8f108e
--- /dev/null
+++ b/tests/test_project_io.py
@@ -0,0 +1,307 @@
+"""Tests for project save & restore (project_io.py)."""
+
+import json
+import time
+from unittest.mock import patch
+
+import pandas as pd
+import pytest
+
+from socialscikit.core.project_io import (
+ PROJECT_VERSION,
+ load_project,
+ save_project,
+)
+from socialscikit.quantikit.annotator import (
+ Annotation,
+ AnnotationSession,
+ AnnotationStatus,
+)
+from socialscikit.qualikit.extraction_reviewer import (
+ ExtractionReviewSession,
+ ReviewAction,
+ ReviewedExtraction,
+)
+from socialscikit.qualikit.segment_extractor import (
+ ExtractionResult,
+ ResearchQuestion,
+)
+from socialscikit.qualikit.segmenter import TextPosition, TextSegment
+
+
+# ======================================================================
+# Helpers
+# ======================================================================
+
+
+def _make_position(start=0, end=100):
+ return TextPosition(
+ line_start=1, line_end=3,
+ char_start=start, char_end=end, paragraph_index=0,
+ )
+
+
+def _make_segment(sid=1, text="Hello world"):
+ return TextSegment(
+ segment_id=sid, text=text, position=_make_position(),
+ )
+
+
+def _make_extraction_result(sid=1, evidence="key phrase"):
+ return ExtractionResult(
+ segment_id=sid, text="Some text",
+ rq_label="RQ1", sub_theme="Theme A",
+ confidence=0.85, reasoning="because",
+ evidence_span=evidence,
+ position=_make_position(),
+ )
+
+
+def _make_review_session():
+ items = [
+ ReviewedExtraction(
+ result=_make_extraction_result(1, "evidence one"),
+ action=ReviewAction.ACCEPTED,
+ ),
+ ReviewedExtraction(
+ result=_make_extraction_result(2, ""),
+ action=ReviewAction.PENDING,
+ edited_rq_label="RQ2",
+ ),
+ ]
+ segments = [_make_segment(1, "Text A"), _make_segment(2, "Text B")]
+ rqs = [ResearchQuestion(rq_id="RQ1", description="Test RQ", sub_themes=["Theme A"])]
+ return ExtractionReviewSession(
+ items=items,
+ original_text="Full document text here.",
+ segments=segments,
+ research_questions=rqs,
+ )
+
+
+def _make_annotation_session():
+ items = [
+ Annotation(idx=0, text="First text", label="pos", status=AnnotationStatus.LABELED),
+ Annotation(idx=1, text="Second text", label=None, status=AnnotationStatus.PENDING),
+ Annotation(idx=2, text="Third text", label=None, status=AnnotationStatus.SKIPPED),
+ ]
+ sess = AnnotationSession(items=items, labels=["pos", "neg"], shuffle=False)
+ sess._cursor = 1
+ sess._history = [0]
+ return sess
+
+
+# ======================================================================
+# Round-trip tests
+# ======================================================================
+
+
+class TestDataFrameRoundTrip:
+ def test_basic_dataframe(self):
+ df = pd.DataFrame({"text": ["hello", "world"], "label": ["a", "b"]})
+ states = {"qt_df": df}
+ json_str = save_project(states)
+ loaded = load_project(json_str)
+ pd.testing.assert_frame_equal(loaded["qt_df"], df)
+
+ def test_empty_dataframe(self):
+ df = pd.DataFrame()
+ states = {"qt_df": df}
+ json_str = save_project(states)
+ loaded = load_project(json_str)
+ assert loaded["qt_df"].empty
+
+ def test_none_value(self):
+ states = {"qt_df": None, "qt_result_df": None}
+ json_str = save_project(states)
+ loaded = load_project(json_str)
+ assert loaded["qt_df"] is None
+ assert loaded["qt_result_df"] is None
+
+
+class TestTextSegmentRoundTrip:
+ def test_single_segment(self):
+ seg = _make_segment(5, "Test segment text")
+ states = {"ql_segments": [seg]}
+ json_str = save_project(states)
+ loaded = load_project(json_str)
+ result = loaded["ql_segments"][0]
+ assert isinstance(result, TextSegment)
+ assert result.segment_id == 5
+ assert result.text == "Test segment text"
+ assert result.position.line_start == 1
+
+ def test_segment_with_core(self):
+ seg = TextSegment(
+ segment_id=1, text="Full text",
+ position=_make_position(),
+ core_sentence="core", core_char_start=0, core_char_end=4,
+ )
+ json_str = save_project({"segs": [seg]})
+ loaded = load_project(json_str)
+ result = loaded["segs"][0]
+ assert result.core_sentence == "core"
+ assert result.core_char_start == 0
+
+
+class TestResearchQuestionRoundTrip:
+ def test_with_sub_themes(self):
+ rq = ResearchQuestion(rq_id="RQ1", description="Desc", sub_themes=["A", "B"])
+ json_str = save_project({"rqs": [rq]})
+ loaded = load_project(json_str)
+ result = loaded["rqs"][0]
+ assert isinstance(result, ResearchQuestion)
+ assert result.rq_id == "RQ1"
+ assert result.sub_themes == ["A", "B"]
+
+ def test_without_sub_themes(self):
+ rq = ResearchQuestion(rq_id="RQ2", description="Desc")
+ json_str = save_project({"rqs": [rq]})
+ loaded = load_project(json_str)
+ assert loaded["rqs"][0].sub_themes == []
+
+
+class TestExtractionResultRoundTrip:
+ def test_with_evidence(self):
+ r = _make_extraction_result(1, "important phrase")
+ json_str = save_project({"results": [r]})
+ loaded = load_project(json_str)
+ result = loaded["results"][0]
+ assert isinstance(result, ExtractionResult)
+ assert result.evidence_span == "important phrase"
+ assert result.confidence == 0.85
+
+ def test_without_evidence(self):
+ r = ExtractionResult(
+ segment_id=1, text="text",
+ rq_label="RQ1", sub_theme="T",
+ confidence=0.5,
+ )
+ json_str = save_project({"results": [r]})
+ loaded = load_project(json_str)
+ assert loaded["results"][0].evidence_span == ""
+
+ def test_position_none(self):
+ r = ExtractionResult(
+ segment_id=1, text="t",
+ rq_label="RQ1", sub_theme="T",
+ confidence=0.5, position=None,
+ )
+ json_str = save_project({"results": [r]})
+ loaded = load_project(json_str)
+ assert loaded["results"][0].position is None
+
+
+class TestExtractionReviewSessionRoundTrip:
+ def test_full_session(self):
+ sess = _make_review_session()
+ json_str = save_project({"ql_ext_session": sess})
+ loaded = load_project(json_str)
+ result = loaded["ql_ext_session"]
+ assert isinstance(result, ExtractionReviewSession)
+ assert len(result.items) == 2
+ assert result.items[0].action == ReviewAction.ACCEPTED
+ assert result.items[1].edited_rq_label == "RQ2"
+ assert result.original_text == "Full document text here."
+ assert len(result.segments) == 2
+ assert len(result.research_questions) == 1
+
+ def test_empty_session(self):
+ sess = ExtractionReviewSession()
+ json_str = save_project({"s": sess})
+ loaded = load_project(json_str)
+ result = loaded["s"]
+ assert isinstance(result, ExtractionReviewSession)
+ assert len(result.items) == 0
+
+
+class TestAnnotationSessionRoundTrip:
+ def test_full_session(self):
+ sess = _make_annotation_session()
+ json_str = save_project({"qt_ann_session": sess})
+ loaded = load_project(json_str)
+ result = loaded["qt_ann_session"]
+ assert isinstance(result, AnnotationSession)
+ assert len(result._items) == 3
+ assert result.labels == ["pos", "neg"]
+ assert result._cursor == 1
+ assert result._history == [0]
+ assert result._items[0].label == "pos"
+ assert result._items[0].status == AnnotationStatus.LABELED
+ assert result._items[2].status == AnnotationStatus.SKIPPED
+
+ def test_elapsed_time_preserved(self):
+ sess = _make_annotation_session()
+ json_str = save_project({"s": sess})
+ loaded = load_project(json_str)
+ result = loaded["s"]
+ # Elapsed time should be close to the original
+ original_elapsed = time.monotonic() - sess._start_time
+ restored_elapsed = time.monotonic() - result._start_time
+ assert abs(original_elapsed - restored_elapsed) < 2.0 # within 2 seconds
+
+
+class TestFullProjectRoundTrip:
+ def test_complete_project(self):
+ """Round-trip a full project with all state types."""
+ df = pd.DataFrame({"text": ["hello"], "label": ["a"]})
+ states = {
+ "qt_df": df,
+ "qt_result_df": None,
+ "qt_ann_session": _make_annotation_session(),
+ "ql_raw_text": "Document text",
+ "ql_segments": [_make_segment(1), _make_segment(2)],
+ "ql_rqs": [ResearchQuestion("RQ1", "Desc", ["A"])],
+ "ql_ext_session": _make_review_session(),
+ "ql_lang": "zh",
+ }
+ json_str = save_project(states)
+ loaded = load_project(json_str)
+
+ pd.testing.assert_frame_equal(loaded["qt_df"], df)
+ assert loaded["qt_result_df"] is None
+ assert isinstance(loaded["qt_ann_session"], AnnotationSession)
+ assert loaded["ql_raw_text"] == "Document text"
+ assert len(loaded["ql_segments"]) == 2
+ assert isinstance(loaded["ql_segments"][0], TextSegment)
+ assert isinstance(loaded["ql_ext_session"], ExtractionReviewSession)
+ assert loaded["ql_lang"] == "zh"
+
+
+class TestVersionAndMetadata:
+ def test_version_embedded(self):
+ json_str = save_project({"x": 1})
+ data = json.loads(json_str)
+ assert data["__project_version__"] == PROJECT_VERSION
+ assert data["__toolkit__"] == "SocialSciKit"
+
+ def test_unknown_keys_ignored(self):
+ """Future project files may have extra keys — they should not crash."""
+ data = {
+ "__project_version__": "99.0",
+ "__toolkit__": "SocialSciKit",
+ "ql_lang": "en",
+ "future_key": {"__type__": "UnknownType", "data": 42},
+ }
+ loaded = load_project(json.dumps(data))
+ assert loaded["ql_lang"] == "en"
+ # Unknown type is kept as a dict
+ assert isinstance(loaded["future_key"], dict)
+
+
+class TestErrorHandling:
+ def test_invalid_json(self):
+ with pytest.raises(ValueError, match="Invalid project file"):
+ load_project("not json at all {{{")
+
+ def test_non_object_json(self):
+ with pytest.raises(ValueError, match="must be a JSON object"):
+ load_project("[1, 2, 3]")
+
+ def test_primitives_preserved(self):
+ states = {"a": 42, "b": "hello", "c": True, "d": 3.14}
+ loaded = load_project(save_project(states))
+ assert loaded["a"] == 42
+ assert loaded["b"] == "hello"
+ assert loaded["c"] is True
+ assert loaded["d"] == 3.14
diff --git a/tests/test_quantikit_app.py b/tests/test_quantikit_app.py
index 4856a70..ce34b49 100644
--- a/tests/test_quantikit_app.py
+++ b/tests/test_quantikit_app.py
@@ -660,20 +660,28 @@ def test_all_labeled_skips(self, sample_df):
class TestEvaluate:
def test_no_data(self):
result = _evaluate_results(None, None, "label")
- assert "分类" in result
+ # Returns tuple: (text, html, cm_fig, pc_fig)
+ assert isinstance(result, tuple)
+ assert "分类" in result[0]
def test_no_label_col(self):
df = pd.DataFrame({"text": ["a"]})
result_df = pd.DataFrame({"predicted_label": ["pos"]})
result = _evaluate_results(result_df, df, "label")
- assert "未找到" in result
+ assert isinstance(result, tuple)
+ assert "未找到" in result[0]
def test_evaluation(self, sample_df):
result_df = pd.DataFrame({
"predicted_label": ["positive", "negative", "neutral", "positive", "positive"],
})
result = _evaluate_results(result_df, sample_df, "label")
- assert "F1" in result or "Accuracy" in result
+ assert isinstance(result, tuple)
+ text, html, cm_fig, pc_fig = result
+ assert "F1" in text or "Accuracy" in text
+ assert html # metrics HTML should be non-empty
+ assert cm_fig is not None # confusion matrix figure
+ assert pc_fig is not None # per-class figure
# ---------------------------------------------------------------------------