diff --git a/README.md b/README.md index a436f92..bf8416b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Python 3.9+ - Tests + Tests License: MIT Gradio UI i18n @@ -23,10 +23,18 @@ SocialSciKit is an open-source Python toolkit that enables social science researchers to perform text analysis **without writing a single line of code**. It provides a Gradio-based web interface with full bilingual support (English / Chinese). -Two core modules: +Three core modules: - **QuantiKit** — End-to-end text classification pipeline (method recommendation → annotation → prompt/fine-tuning classification → evaluation → export) -- **QualiKit** — End-to-end qualitative coding pipeline (upload → de-identification → research framework → LLM coding → human review → export) +- **QualiKit** — End-to-end qualitative coding pipeline (upload → de-identification → research framework → LLM coding with evidence grounding → human review → export) +- **Toolbox** — Standalone research methods tools: Inter-Coder Reliability (ICR) calculator, Multi-LLM Consensus Coding, and Methods Section Generator + +### Highlights + +- **Visualization Dashboard** — academic-style matplotlib charts (confusion matrix heatmaps, per-class P/R/F1 bars, confidence histograms, progress donuts, theme distribution) embedded throughout both pipelines +- **Evidence Highlighting** — LLM codings include a verbatim `evidence_span` from the source text; the review UI highlights the supporting quote inline in the original document +- **Project Save & Restore** — serialize the entire research project state (data, annotations, sessions, coding results) to a single JSON file; resume work later from the Home tab +- **Zero-code web UI** — Gradio 4.44+ with full EN/ZH language switching at runtime --- @@ -36,6 +44,8 @@ Two core modules: - [Quick Start](#quick-start) - [QuantiKit: Text Classification](#quantikit-text-classification) - [QualiKit: Qualitative Coding](#qualikit-qualitative-coding) +- [Toolbox: Research Methods Tools](#toolbox-research-methods-tools) +- [Project Save & Restore](#project-save--restore) - [Supported LLM Backends](#supported-llm-backends) - [Example Datasets](#example-datasets) - [Project Structure](#project-structure) @@ -161,8 +171,8 @@ QuantiKit guides you through the full text classification workflow in 6 steps. - Built-in annotation UI — no need for external tools - Label each text sample, with **skip**, **undo**, **flag for review** support +- **Real-time progress donut chart** — visual progress tracker updates after every action - Export annotated data as CSV, merge with original dataset -- Progress tracker shows completion percentage ### Step 4 · Classification @@ -181,13 +191,18 @@ Three sub-approaches available in parallel tabs: ### Step 5 · Evaluation -- Metrics: Accuracy, Macro-F1, Cohen's Kappa, per-class Precision / Recall / F1 -- Confusion matrix visualization -- Detailed classification report +Full visualization dashboard: + +- **Metric summary cards** (HTML) — Accuracy, Macro-F1, Weighted-F1, Cohen's Kappa, total/correct counts +- **Confusion matrix heatmap** — row-normalized, annotated with counts + percentages +- **Per-class metrics bar chart** — Precision / Recall / F1 grouped bars per class +- Collapsible full text report below charts ### Step 6 · Export - Download classification results as CSV (original text + predicted labels + confidence) +- **Pipeline log export** — JSON metadata usable by the Toolbox Methods Generator +- **Save project** — persist all research state (data, predictions, annotation session) to a single JSON file --- @@ -221,12 +236,18 @@ QualiKit supports the full qualitative coding workflow for interview transcripts ### Step 4 · LLM Coding - Batch coding: LLM reads each segment and assigns RQ + sub-theme labels with confidence scores +- **Evidence grounding**: the LLM prompt requires a verbatim `evidence_span` — the exact phrase or sentence from the source text that supports the coding decision - Supports OpenAI, Anthropic, and Ollama backends -- Results displayed with segment text, assigned codes, and confidence levels +- Results displayed with segment text, assigned codes, confidence levels, and evidence spans ### Step 5 · Review - Review coding results in a table sorted by confidence +- **Evidence highlighting**: when you select an item, the original text is shown with the LLM's `evidence_span` highlighted in green, so you can verify the coding decision at a glance; if the exact quote isn't found, a fallback "Evidence" block displays the cited text +- Visualization dashboard (collapsible accordion): + - **Review progress donut** — accepted / edited / rejected / pending counts + - **Confidence histogram** — low/medium/high tier shading + median marker + - **Theme distribution** — horizontal bar chart of RQ frequencies - Per-item actions: accept, reject, or edit (reassign RQ/sub-theme) - Bulk accept by confidence threshold - **Manual coding**: select a segment, preview its content, and manually assign RQ + sub-theme labels @@ -235,6 +256,54 @@ QualiKit supports the full qualitative coding workflow for interview transcripts ### Step 6 · Export - Export reviewed coding results as structured Excel file +- **Pipeline log export** — JSON metadata usable by the Toolbox Methods Generator +- **Save project** — persist the entire coding session (segments, RQs, review state, evidence spans) to a single JSON file + +--- + +## Toolbox: Research Methods Tools + +The Toolbox provides standalone research utilities that work independently or in combination with QuantiKit / QualiKit. + +### ICR Calculator + +Compute inter-coder reliability for 2 or more coders with automatic metric selection: + +| Scenario | Metric | +|----------|--------| +| 2 coders, single-label | Cohen's Kappa + Krippendorff's Alpha + per-category agreement | +| 3+ coders, single-label | Krippendorff's Alpha + pairwise Cohen's Kappa | +| 2 coders, multi-label | Jaccard index (pairwise) | +| 3+ coders, multi-label | Average pairwise Jaccard | + +- Upload a CSV with coder columns, select which columns to compare +- Interpretation follows the Landis & Koch (1977) scale + +### Consensus Coding + +Multi-LLM majority-vote coding for qualitative data: + +- Configure 2–5 LLM backends (OpenAI, Anthropic, Ollama) with independent models +- Each LLM codes every text segment; final label is determined by majority vote +- Agreement statistics across LLMs are reported automatically + +### Methods Section Generator + +Auto-generate a methods section paragraph (English + Chinese) for your paper: + +- **From pipeline log**: QuantiKit and QualiKit can export a pipeline log (JSON) capturing all metadata (sample size, model, metrics, themes, etc.). Import the log and generate a ready-to-use methods paragraph. +- **Manual input**: Fill in metadata fields manually if you prefer not to use the pipeline log. + +--- + +## Project Save & Restore + +Long research projects rarely finish in one session. SocialSciKit serialises the full state of your work — loaded DataFrames, annotation sessions (including cursor and history), extraction review sessions, research questions, de-identification results — into a single JSON file: + +- **Save**: at the end of any pipeline, click "Save Project" in Step 6 to download a `.json` archive +- **Restore**: return to the **Home** tab, expand "Load Saved Project", upload the JSON file, and all state is restored across both pipelines +- **Tagged-union serialisation**: complex types (`pd.DataFrame`, `AnnotationSession`, `ExtractionReviewSession`, `ResearchQuestion`, `ExtractionResult`, enums) round-trip losslessly; elapsed-time counters are preserved via monotonic time offsets +- **Version-aware**: project files include a `__project_version__` field so future readers can migrate old archives --- @@ -258,12 +327,16 @@ ollama pull llama3 The `examples/` directory contains ready-to-use sample data: -| File | Type | Description | -|------|------|-------------| +| File | Module | Description | +|------|--------|-------------| | `sentiment_example.csv` | QuantiKit | 50 Chinese product/service reviews with 3 sentiment labels | | `policy_example.csv` | QuantiKit | 40 Chinese policy text excerpts with 8 policy-instrument labels | | `interview_example.txt` | QualiKit | Single-person community healthcare interview transcript | | `interview_focus_group.txt` | QualiKit | 4-person focus group on elderly digital service experiences | +| `icr_example.csv` | Toolbox | 20 policy texts coded by 3 coders (A/B/C) for ICR calculation | +| `consensus_example.csv` | Toolbox | 15 interview segments for multi-LLM consensus coding | +| `methods_log_quantikit.json` | Toolbox | Sample QuantiKit pipeline log for methods generation | +| `methods_log_qualikit.json` | Toolbox | Sample QualiKit pipeline log for methods generation | ### Cookbook: Sentiment Classification (QuantiKit) @@ -298,6 +371,10 @@ socialscikit/ │ ├── data_validator.py # Schema validation + auto-fix │ ├── data_diagnostics.py # Data quality diagnostics report │ ├── llm_client.py # Unified LLM client (OpenAI/Anthropic/Ollama) +│ ├── icr.py # Inter-coder reliability (Kappa/Alpha/Jaccard) +│ ├── methods_writer.py # Methods section generator (EN/ZH templates) +│ ├── charts.py # Academic-style matplotlib charts (viz dashboard) +│ ├── project_io.py # Project state serialization (save/restore) │ └── templates/ # Template files for download │ ├── quantikit/ # Text classification module @@ -322,18 +399,21 @@ socialscikit/ │ ├── confidence_ranker.py # Confidence scoring & ranking │ ├── coding_reviewer.py # Human-in-the-loop coding review │ ├── extraction_reviewer.py # Extraction result review +│ ├── consensus.py # Multi-LLM consensus coding (majority vote) │ └── exporter.py # Excel / Markdown export │ ├── ui/ # Gradio web interface -│ ├── main_app.py # Unified app (Home + QuantiKit + QualiKit) +│ ├── main_app.py # Unified app (Home + QuantiKit + QualiKit + Toolbox) │ ├── quantikit_app.py # QuantiKit UI callbacks │ ├── qualikit_app.py # QualiKit UI callbacks +│ ├── toolbox_app.py # Toolbox UI callbacks (ICR/Consensus/Methods) │ └── i18n.py # Internationalization (EN / ZH) │ ├── cli.py # Command-line entry point │ examples/ # Sample datasets -tests/ # Test suite (562 tests) +tests/ # Test suite (676 tests) +promo/ # Promotional posters + HTML sources pyproject.toml # Package metadata & dependencies CITATION.cff # Citation metadata ``` diff --git a/README_zh.md b/README_zh.md index f52fad1..610ae73 100644 --- a/README_zh.md +++ b/README_zh.md @@ -7,7 +7,7 @@

Python 3.9+ - Tests + Tests License: MIT Gradio UI i18n @@ -23,10 +23,18 @@ SocialSciKit 是一个开源 Python 工具包,让社会科学研究者**无需编写任何代码**即可完成文本分析。基于 Gradio 构建的 Web 界面,完整支持中英双语。 -两大核心模块: +三大核心模块: - **QuantiKit** — 文本分类全流程(方法推荐 → 标注 → 提示词/微调分类 → 评估 → 导出) -- **QualiKit** — 质性编码全流程(上传 → 脱敏 → 研究框架 → LLM 编码 → 人工审核 → 导出) +- **QualiKit** — 质性编码全流程(上传 → 脱敏 → 研究框架 → LLM 编码(含证据溯源) → 人工审核 → 导出) +- **工具箱 (Toolbox)** — 独立研究方法工具:编码者间信度 (ICR) 计算、多 LLM 共识编码、方法论段落生成器 + +### 特性亮点 + +- **可视化仪表盘** — 学术风格的 matplotlib 图表(混淆矩阵热图、各类别 P/R/F1 柱状图、置信度直方图、进度圆环、主题分布)贯穿整个流程 +- **证据溯源** — LLM 编码时强制输出 `evidence_span`(原文关键句),审核界面在原文中以绿色高亮显示该支持性引文 +- **项目存档与恢复** — 将整个研究项目状态(数据、标注、会话、编码结果)序列化为单个 JSON 文件;下次从首页标签直接恢复全部进度 +- **零代码 Web 界面** — Gradio 4.44+,支持运行时中英文切换 --- @@ -36,6 +44,8 @@ SocialSciKit 是一个开源 Python 工具包,让社会科学研究者**无需 - [快速开始](#快速开始) - [QuantiKit:文本分类](#quantikit文本分类) - [QualiKit:质性编码](#qualikit质性编码) +- [工具箱:研究方法工具](#工具箱研究方法工具) +- [项目存档与恢复](#项目存档与恢复) - [支持的 LLM 后端](#支持的-llm-后端) - [示例数据集](#示例数据集) - [项目结构](#项目结构) @@ -161,8 +171,8 @@ QuantiKit 将文本分类全流程拆分为 6 个步骤,逐步引导操作。 - 内置标注界面,无需外部工具 - 逐条标注,支持**跳过**、**撤销**、**标记待审** +- **实时进度圆环图** — 每次操作后自动更新的可视化进度追踪 - 导出标注数据为 CSV,可与原始数据合并 -- 进度追踪显示完成百分比 ### 步骤 4 · 文本分类 @@ -181,13 +191,18 @@ QuantiKit 将文本分类全流程拆分为 6 个步骤,逐步引导操作。 ### 步骤 5 · 模型评估 -- 指标:Accuracy、Macro-F1、Cohen's Kappa、各类别 Precision / Recall / F1 -- 混淆矩阵可视化 -- 详细分类报告 +完整可视化仪表盘: + +- **指标卡片**(HTML)— Accuracy、Macro-F1、Weighted-F1、Cohen's Kappa、总样本数/正确数 +- **混淆矩阵热图** — 行归一化,标注计数 + 百分比 +- **各类别指标柱状图** — 各类别 Precision / Recall / F1 分组柱状图 +- 下方可折叠的完整文字报告 ### 步骤 6 · 导出 - 下载分类结果 CSV(原始文本 + 预测标签 + 置信度) +- **Pipeline log 导出** — 可用于工具箱 Methods 段落生成的 JSON 元数据 +- **保存项目** — 将所有研究状态(数据、预测结果、标注会话)持久化为单个 JSON 文件 --- @@ -221,12 +236,18 @@ QualiKit 支持访谈记录、焦点小组讨论和开放式问卷的完整质 ### 步骤 4 · LLM 编码 - 批量编码:LLM 逐段阅读并分配 RQ + 子主题标签,附置信度分数 +- **证据溯源**:LLM prompt 强制要求输出 `evidence_span` — 支持编码判断的原文关键句或短语的逐字引用 - 支持 OpenAI、Anthropic、Ollama 后端 -- 结果展示段落文本、分配的编码和置信度等级 +- 结果展示段落文本、分配的编码、置信度等级和证据文本 ### 步骤 5 · 人工审核 - 按置信度排序的审核表格 +- **证据高亮**:选中条目时,原文中会以**绿色高亮**显示 LLM 的 `evidence_span`,一眼即可验证编码判断;若逐字匹配失败,则以"Evidence"回退块展示 LLM 引用的文本 +- 可视化仪表盘(可折叠 accordion): + - **审查进度圆环** — 接受/编辑/拒绝/待审计数 + - **置信度直方图** — 低/中/高三档底色 + 中位数标注 + - **主题分布** — RQ 频次的横向柱状图 - 逐条操作:接受、拒绝或编辑(重新分配 RQ/子主题) - 按置信度阈值批量接受 - **手动编码**:选择段落、预览内容、手动分配 RQ + 子主题 @@ -235,6 +256,54 @@ QualiKit 支持访谈记录、焦点小组讨论和开放式问卷的完整质 ### 步骤 6 · 导出 - 导出审核后的编码结果为结构化 Excel 文件 +- **Pipeline log 导出** — 可用于工具箱 Methods 段落生成的 JSON 元数据 +- **保存项目** — 将整个编码会话(分段、RQ、审核状态、证据文本)持久化为单个 JSON 文件 + +--- + +## 工具箱:研究方法工具 + +工具箱提供独立的研究方法工具,可独立使用或与 QuantiKit / QualiKit 搭配。 + +### ICR 计算器 + +支持 2 名及以上编码者的编码者间信度计算,自动选择合适指标: + +| 场景 | 指标 | +|------|------| +| 2 名编码者,单标签 | Cohen's Kappa + Krippendorff's Alpha + 各类别一致率 | +| 3+ 名编码者,单标签 | Krippendorff's Alpha + 两两 Cohen's Kappa | +| 2 名编码者,多标签 | Jaccard 相似度(逐对) | +| 3+ 名编码者,多标签 | 平均逐对 Jaccard | + +- 上传 CSV 文件,选择编码者列进行比较 +- 解读遵循 Landis & Koch (1977) 量表 + +### 共识编码 + +基于多 LLM 多数投票的质性数据编码: + +- 配置 2–5 个 LLM 后端(OpenAI、Anthropic、Ollama),独立模型 +- 每个 LLM 对每段文本编码;最终标签由多数投票决定 +- 自动报告各 LLM 间的一致性统计 + +### 方法论段落生成器 + +自动生成论文方法部分段落(中英双语): + +- **从流水线日志生成**:QuantiKit 和 QualiKit 可导出流水线日志(JSON),包含所有元数据(样本量、模型、指标、主题等)。导入日志即可生成可用的方法论段落。 +- **手动输入**:也可手动填写元数据字段生成。 + +--- + +## 项目存档与恢复 + +长周期研究很少能一次完成。SocialSciKit 将整个研究项目的全部状态 — 已加载的 DataFrame、标注会话(含 cursor 和 history)、提取审核会话、研究问题、脱敏结果 — 序列化为单个 JSON 文件: + +- **保存**:在任意流程的步骤 6 中点击"保存项目"即可下载 `.json` 存档 +- **恢复**:回到**首页**,展开"加载已保存项目",上传 JSON 文件,两个模块的全部状态会一并恢复 +- **Tagged-union 序列化**:复杂类型(`pd.DataFrame`、`AnnotationSession`、`ExtractionReviewSession`、`ResearchQuestion`、`ExtractionResult`、枚举)均无损往返;已用时间计数器通过 monotonic 时间偏移保留 +- **版本感知**:项目文件包含 `__project_version__` 字段,方便未来的读取器迁移旧版本存档 --- @@ -264,6 +333,10 @@ ollama pull llama3 | `policy_example.csv` | QuantiKit | 40 条中文政策文本,8 类政策工具标签 | | `interview_example.txt` | QualiKit | 单人社区医疗访谈记录(中文) | | `interview_focus_group.txt` | QualiKit | 4 人焦点小组讨论 — 老年人数字服务体验(中文) | +| `icr_example.csv` | 工具箱 | 20 条政策文本,3 名编码者(A/B/C)标注,用于 ICR 计算 | +| `consensus_example.csv` | 工具箱 | 15 条访谈片段,用于多 LLM 共识编码 | +| `methods_log_quantikit.json` | 工具箱 | QuantiKit 流水线日志示例,用于方法论生成 | +| `methods_log_qualikit.json` | 工具箱 | QualiKit 流水线日志示例,用于方法论生成 | ### 实操教程:情感分类(QuantiKit) @@ -298,6 +371,10 @@ socialscikit/ │ ├── data_validator.py # 格式验证 + 自动修复 │ ├── data_diagnostics.py # 数据质量诊断报告 │ ├── llm_client.py # 统一 LLM 客户端(OpenAI/Anthropic/Ollama) +│ ├── icr.py # 编码者间信度(Kappa/Alpha/Jaccard) +│ ├── methods_writer.py # 方法论段落生成(中英模板) +│ ├── charts.py # 学术风格 matplotlib 图表(可视化仪表盘) +│ ├── project_io.py # 项目状态序列化(保存/恢复) │ └── templates/ # 模板文件 │ ├── quantikit/ # 文本分类模块 @@ -322,18 +399,21 @@ socialscikit/ │ ├── confidence_ranker.py # 置信度评分与排序 │ ├── coding_reviewer.py # 人机协同编码审核 │ ├── extraction_reviewer.py # 提取结果审核 +│ ├── consensus.py # 多 LLM 共识编码(多数投票) │ └── exporter.py # Excel / Markdown 导出 │ ├── ui/ # Gradio Web 界面 -│ ├── main_app.py # 统一应用(Home + QuantiKit + QualiKit) +│ ├── main_app.py # 统一应用(Home + QuantiKit + QualiKit + 工具箱) │ ├── quantikit_app.py # QuantiKit UI 回调函数 │ ├── qualikit_app.py # QualiKit UI 回调函数 +│ ├── toolbox_app.py # 工具箱 UI 回调(ICR / 共识编码 / 方法论) │ └── i18n.py # 国际化(EN / ZH) │ ├── cli.py # 命令行入口 │ examples/ # 示例数据集 -tests/ # 测试套件(562 个测试) +tests/ # 测试套件(676 个测试) +promo/ # 宣传海报 + HTML 源文件 pyproject.toml # 包元数据与依赖 CITATION.cff # 引用元数据 ``` diff --git a/examples/consensus_example.csv b/examples/consensus_example.csv new file mode 100644 index 0000000..5127d6c --- /dev/null +++ b/examples/consensus_example.csv @@ -0,0 +1,16 @@ +id,text +1,受访者表示社区医疗服务的便利性有了明显提升,但仍然觉得专科服务不够完善。 +2,许多老年人反映智能手机操作困难,希望政府能提供更多线下服务渠道。 +3,家长们普遍认为课后托管服务缓解了接送压力,但对托管质量存在担忧。 +4,居民对小区物业管理满意度较低,主要不满集中在维修响应速度慢。 +5,年轻白领群体更倾向于使用在线医疗咨询,认为节省了大量排队时间。 +6,农村地区的教育资源分配不均问题仍然突出,师资力量明显不足。 +7,受访企业主认为营商环境有所改善,但融资难的问题依然存在。 +8,退休人员对养老金调整幅度基本满意,但对医疗报销比例有更高期望。 +9,外卖骑手反映工作强度大、缺乏社会保障,希望纳入劳动法保护范围。 +10,社区志愿者表示参与公益活动的积极性很高,但组织协调机制有待完善。 +11,新市民群体在住房和子女教育方面面临较大压力,融入感不强。 +12,受访者普遍支持垃圾分类政策,但认为配套设施和宣传力度还需加强。 +13,中小学教师反映非教学任务过多,影响了正常的教学工作。 +14,患者对基层医疗机构的信任度不高,大病小病都倾向于去大医院。 +15,受访的快递从业人员表示收入不稳定,行业竞争日益激烈。 diff --git a/examples/icr_example.csv b/examples/icr_example.csv new file mode 100644 index 0000000..1c90cae --- /dev/null +++ b/examples/icr_example.csv @@ -0,0 +1,21 @@ +id,text,coder_A,coder_B,coder_C +1,国务院决定自2024年起全面推行企业注册登记电子化。,简政放权,简政放权,简政放权 +2,对违反数据安全法规的企业可处以营业额百分之五的罚款。,惩罚措施,惩罚措施,惩罚措施 +3,鼓励社会资本参与养老服务设施建设并给予税收优惠。,财政激励,财政激励,财政激励 +4,各级政府应建立健全突发公共卫生事件应急预案。,制度建设,制度建设,简政放权 +5,严禁非法采集和买卖公民个人生物识别信息。,惩罚措施,制度建设,惩罚措施 +6,中央财政安排专项资金支持中西部基础教育发展。,财政激励,财政激励,财政激励 +7,推动建立全国统一的碳排放权交易市场。,市场机制,市场机制,制度建设 +8,要求上市公司自2025年起披露ESG信息。,信息公开,信息公开,信息公开 +9,对在乡村振兴中做出突出贡献的企业给予奖励。,财政激励,财政激励,市场机制 +10,取消不合理的行政审批事项减轻企业负担。,简政放权,简政放权,简政放权 +11,对生产销售假冒伪劣食品的企业吊销营业执照。,惩罚措施,惩罚措施,惩罚措施 +12,在自贸区试点推行负面清单管理模式。,市场机制,简政放权,市场机制 +13,加大对科技创新型中小企业的融资支持力度。,财政激励,财政激励,财政激励 +14,建立健全网络安全事件报告和应急处置制度。,制度建设,制度建设,制度建设 +15,推进政务服务"一网通办"提升办事效率。,简政放权,简政放权,简政放权 +16,对排放超标企业实施限产停产措施。,惩罚措施,惩罚措施,市场机制 +17,设立国家绿色发展基金引导社会资本投入。,财政激励,市场机制,财政激励 +18,完善食品药品安全追溯体系建设。,制度建设,制度建设,制度建设 +19,鼓励地方政府在特定区域先行先试改革措施。,市场机制,简政放权,简政放权 +20,定期向社会公布环境质量监测数据。,信息公开,信息公开,信息公开 diff --git a/examples/methods_log_qualikit.json b/examples/methods_log_qualikit.json new file mode 100644 index 0000000..ee97fbb --- /dev/null +++ b/examples/methods_log_qualikit.json @@ -0,0 +1,21 @@ +{ + "pipeline": "qualikit", + "n_segments": 86, + "n_themes": 5, + "theme_names": ["医疗服务", "教育资源", "就业保障", "社区治理", "数字鸿沟"], + "coding_model_name": "gpt-4o-mini", + "coding_model_backend": "openai", + "deidentification_performed": true, + "consensus_coding_used": true, + "n_consensus_models": 2, + "consensus_model_names": ["gpt-4o-mini", "claude-sonnet-4-20250514"], + "consensus_agreement": 0.84, + "n_high_confidence": 52, + "n_medium_confidence": 24, + "n_low_confidence": 10, + "review_stats": { + "accepted": 68, + "rejected": 5, + "edited": 13 + } +} diff --git a/examples/methods_log_quantikit.json b/examples/methods_log_quantikit.json new file mode 100644 index 0000000..46975b6 --- /dev/null +++ b/examples/methods_log_quantikit.json @@ -0,0 +1,14 @@ +{ + "pipeline": "quantikit", + "n_samples": 500, + "n_classes": 5, + "class_labels": ["简政放权", "惩罚措施", "财政激励", "制度建设", "市场机制"], + "classification_method": "zero-shot", + "model_name": "gpt-4o-mini", + "model_backend": "openai", + "n_annotations": 100, + "accuracy": 0.823, + "macro_f1": 0.796, + "weighted_f1": 0.815, + "cohens_kappa": 0.771 +} diff --git a/launch_app.py b/launch_app.py new file mode 100644 index 0000000..a2efa6b --- /dev/null +++ b/launch_app.py @@ -0,0 +1,21 @@ +"""Launch helper that patches Gradio client utils bug before starting the app.""" +import gradio_client.utils as gu + +# Patch for Gradio 4.44.1 bug: additionalProperties can be bool, not dict +_orig_fn = gu._json_schema_to_python_type +def _patched_fn(schema, defs=None): + if isinstance(schema, bool): + return "Any" + return _orig_fn(schema, defs) +gu._json_schema_to_python_type = _patched_fn + +_orig_gt = gu.get_type +def _patched_gt(schema): + if isinstance(schema, bool): + return "Any" + return _orig_gt(schema) +gu.get_type = _patched_gt + +from socialscikit.ui.main_app import create_app +app = create_app() +app.launch(server_port=7860, server_name="0.0.0.0", share=False) diff --git a/promo/01_cover.png b/promo/01_cover.png new file mode 100644 index 0000000..2c7a234 Binary files /dev/null and b/promo/01_cover.png differ diff --git a/promo/02_comparison.png b/promo/02_comparison.png new file mode 100644 index 0000000..04f7282 Binary files /dev/null and b/promo/02_comparison.png differ diff --git a/promo/03_backends.png b/promo/03_backends.png new file mode 100644 index 0000000..9f342ca Binary files /dev/null and b/promo/03_backends.png differ diff --git a/promo/04_quantikit.png b/promo/04_quantikit.png new file mode 100644 index 0000000..376f9de Binary files /dev/null and b/promo/04_quantikit.png differ diff --git a/promo/05_qualikit.png b/promo/05_qualikit.png new file mode 100644 index 0000000..ad5929b Binary files /dev/null and b/promo/05_qualikit.png differ diff --git a/promo/06_toolbox.png b/promo/06_toolbox.png new file mode 100644 index 0000000..b888a44 Binary files /dev/null and b/promo/06_toolbox.png differ diff --git a/promo/backends.html b/promo/backends.html new file mode 100644 index 0000000..0810853 --- /dev/null +++ b/promo/backends.html @@ -0,0 +1,98 @@ + + + + + + + +

支持三大 LLM 后端

+
灵活选择 · 按需切换 · 数据可控
+ +
+
+
AI
+
+

OpenAI

+
gpt-4o · gpt-4o-mini · gpt-4.1
+
最强通用能力,适合复杂分类和编码任务
+
+
+ +
+
C
+
+

Anthropic

+
Claude Sonnet 4 · Claude Haiku
+
长文本理解优秀,适合访谈和政策文本
+
+
+ +
+
O
+
+

Ollama(本地部署)

+
Llama 3 · Mistral · Qwen 2.5
+
本地运行 · 无需 API Key · 完全免费
+
+
+
+ +
+

数据敏感?用 Ollama 本地跑,数据不出机器
一行命令:ollama pull llama3

+
+ +
+ 分类 + 编码 + Prompt 优化 + 子主题建议 + 共识编码 + Fine-tuning +
+ + diff --git a/promo/comparison.html b/promo/comparison.html new file mode 100644 index 0000000..b9ef132 --- /dev/null +++ b/promo/comparison.html @@ -0,0 +1,80 @@ + + + + + + + +

传统方式 vs SocialSciKit

+
Social science 文本分析的痛点,一次解决
+ +
+
+

传统方式

+
😓装 Python 环境 + 各种依赖
+
😓写代码读数据、清洗格式
+
😓查文献选分类方法
+
😓Excel 手动标注数据
+
😓手写 Prompt 和 API 调用
+
😓跑 fine-tuning 脚本
+
😓手写 F1 / Kappa 指标代码
+
😓手动脱敏访谈记录
+
😓逐段手工编码 + Excel 整理
+
😓SPSS 算编码者间信度
+
😓手写论文方法学段落
+
⏱️耗时数天 – 数周
+
+ +
+

SocialSciKit

+
pip install 一行搞定
+
拖拽上传,自动验证修复
+
自动推荐方法 + 附文献
+
内置标注界面,点击即标
+
Prompt 自动生成 + 优化
+
一键 fine-tuning
+
📊可视化仪表盘 混淆矩阵 + P/R/F1
+
自动脱敏 + 逐条审核
+
LLM 批量编码 + 证据高亮
+
🔬一键 ICR Kappa + Alpha + Jaccard
+
📝Methods 段落 中英双语自动生成
+
💾项目存档 一键保存/恢复进度
+
+
+ + diff --git a/promo/cover.html b/promo/cover.html new file mode 100644 index 0000000..0fe5e65 --- /dev/null +++ b/promo/cover.html @@ -0,0 +1,92 @@ + + + + + + + +
+
+
+ +
+
OPEN SOURCE
+
SocialSciKit
+
零代码文本分析与研究方法工具包
+
Text Classification · Qualitative Coding · Research Methods
+
+ +
+
+

QuantiKit

+
文本分类
+

方法推荐 → 标注
→ 分类 → 评估可视化

+
+
+

QualiKit

+
质性编码
+

脱敏 → 框架定义
→ 编码 → 证据审阅

+
+
+

Toolbox

+
研究方法
+

信度 · 共识编码
方法学段落生成

+
+
+ +
+
+ GPT · Claude · Ollama + 可视化仪表盘 + 中英双语 + 项目存档 +
+
pip install socialscikit
+
+ + diff --git a/promo/qualikit_ui.html b/promo/qualikit_ui.html new file mode 100644 index 0000000..fad1da6 --- /dev/null +++ b/promo/qualikit_ui.html @@ -0,0 +1,128 @@ + + + + + + + +

QualiKit

+
Qualitative Coding Pipeline · 质性编码全流程
+ +
+
+
1
+
+

Upload & Segment 上传与分段

+

访谈 / 焦点小组 / 开放式问卷文本
自动检测说话人,段落 / 轮次 / 上下文窗口

+
+
+
+ +
+
2
+
+

De-identification 脱敏处理

+

自动检测人名 / 邮箱 / 电话 / 身份证号
支持中文姓名识别 + 逐条审核接受/拒绝/编辑

+
+
+
+ +
+
3
+
+

Research Framework 研究框架

+

交互表格定义 RQ 和子主题,动态增删
LLM 自动分析文本,建议相关子主题

+
+
+
+ +
+
4
+
+

LLM Coding LLM 编码证据溯源

+

LLM 逐段阅读,分配 RQ + 子主题 + 置信度
强制输出 evidence_span — 原文关键句溯源

+
+
+
+ +
+
5
+
+

Human Review 人工审核高亮+仪表盘

+

原文中绿色高亮显示 LLM 判断依据
审查进度圆环 + 置信度直方图 + 主题分布

+
+
+
+ +
+
6
+
+

Export 导出存档

+

Excel 结构化摘录表 + JSON pipeline log
一键保存项目,包含审核状态 + 证据文本

+
+
+
+ +
+
输入
访谈 / 焦点小组 .txt
+
+
编码方式
LLM + 证据 + 人工
+
+
输出
Excel + 仪表盘 + 项目
+
+ +
+ IRB 友好 + 证据可追溯 + 可视化分析 + 人机协作 + 进度持久化 +
+ + diff --git a/promo/quantikit_ui.html b/promo/quantikit_ui.html new file mode 100644 index 0000000..1cd4ef7 --- /dev/null +++ b/promo/quantikit_ui.html @@ -0,0 +1,128 @@ + + + + + + + +

QuantiKit

+
Text Classification Pipeline · 文本分类全流程
+ +
+
+
1
+
+

Data Upload 数据上传

+

CSV / Excel / JSON 拖拽上传
自动验证 + 诊断报告 + 一键修复

+
+
+
+ +
+
2
+
+

Recommendation 方法推荐

+

自动推荐 zero-shot / few-shot / fine-tuning
附文献依据 + 标注预算 + 边际收益曲线

+
+
+
+ +
+
3
+
+

Annotation 数据标注可视化

+

内置标注界面,逐条标注
实时进度圆环 + 跳过 / 撤销 / 标记待审

+
+
+
+ +
+
4
+
+

Classification 文本分类

+

Prompt 分类 · 本地微调 · API 微调
Prompt 自动生成 + APE 优化 + 一键全量分类

+
+
+
+ +
+
5
+
+

Evaluation 模型评估仪表盘

+

指标卡片 + 混淆矩阵热图 + 各类别 P/R/F1 柱状图
Accuracy / Macro-F1 / Weighted-F1 / Cohen's κ

+
+
+
+ +
+
6
+
+

Export 导出存档

+

CSV 结果 + JSON pipeline log
一键保存项目进度,下次继续

+
+
+
+ +
+
输入
CSV / Excel / JSON
+
+
分类方式
Prompt / 微调 / API
+
+
输出
CSV + 仪表盘 + 项目
+
+ +
+ 零代码 + 文献驱动 + 人机协作 + 可视化分析 + 进度持久化 +
+ + diff --git a/promo/server.py b/promo/server.py new file mode 100644 index 0000000..5613832 --- /dev/null +++ b/promo/server.py @@ -0,0 +1,4 @@ +import http.server +import os +os.chdir(os.path.dirname(__file__)) +http.server.HTTPServer(("", 8888), http.server.SimpleHTTPRequestHandler).serve_forever() diff --git a/promo/toolbox_ui.html b/promo/toolbox_ui.html new file mode 100644 index 0000000..413557f --- /dev/null +++ b/promo/toolbox_ui.html @@ -0,0 +1,132 @@ + + + + + + + +

Toolbox

+
Research Methods Toolkit · 独立研究方法工具
+ +
+ +
+
+
κ
+
+

ICR Calculator 编码者间信度

+
Inter-Coder Reliability
+
+
+
+ 一键计算多编码者的一致性:Cohen's Kappa、Krippendorff's Alpha、Multi-label Jaccard +
+
+ Cohen's κ: 0.758 ✔ Substantial
+ Krippendorff α: 0.681 ✔ Tentatively reliable +
+
+ 单/多标签 + N 个编码者 + 可视化柱状图 +
+
+ +
+
+
Σ
+
+

Consensus Coding 共识编码

+
Multi-LLM Majority-Vote Coding
+
+
+
+ 2–5 个 LLM 并行编码,多数投票选出共识主题 — 更稳健、更透明。可混合 OpenAI + Anthropic + Ollama,打破单模型偏差 +
+
+ 多数投票 + 一致性报告 + 动态增删 LLM + 云端 + 本地 +
+
+ +
+
+
§
+
+

Methods Generator 方法学段落

+
Auto-generated Methods Section
+
+
+
+ 导入 QuantiKit / QualiKit 流程 log,自动生成论文方法学段落(中英双语)。包含样本量、类别数、模型信息、评估指标、ICR 报告 +
+
+ 中英双语 + 可直接编辑 + 从 log 一键生成 +
+
+ +
+ +
+

独立可用 — 不依赖 QuantiKit/QualiKit,可对任意 CSV 或流程 log 使用

+
+ + diff --git a/pyproject.toml b/pyproject.toml index 9d3d8b4..5322e93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,14 +5,14 @@ build-backend = "hatchling.build" [project] name = "socialscikit" version = "0.1.0" -description = "Zero-code toolkit for social science text analysis — classification (QuantiKit) and qualitative coding (QualiKit)" +description = "Zero-code toolkit for social science text analysis — classification (QuantiKit), qualitative coding (QualiKit), and research methods tools (Toolbox)" readme = "README.md" license = "MIT" requires-python = ">=3.10" authors = [ { name = "SocialSciKit Contributors" }, ] -keywords = ["social-science", "text-classification", "qualitative-coding", "NLP", "LLM"] +keywords = ["social-science", "text-classification", "qualitative-coding", "inter-coder-reliability", "consensus-coding", "NLP", "LLM"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Science/Research", diff --git a/socialscikit/core/__init__.py b/socialscikit/core/__init__.py index 12ca4a0..7e87a04 100644 --- a/socialscikit/core/__init__.py +++ b/socialscikit/core/__init__.py @@ -1 +1,16 @@ -"""Core utilities: data loading, validation, diagnostics, and LLM client.""" +"""Core utilities: data loading, validation, diagnostics, LLM client, ICR, methods writer, and charts.""" + +from socialscikit.core.icr import ICRCalculator, ICRReport, ICRResult, PerCategoryAgreement +from socialscikit.core.methods_writer import ( + MethodsSection, + MethodsWriter, + QuantiKitPipelineMetadata, + QualiKitPipelineMetadata, +) +from socialscikit.core import charts # noqa: F401 + +__all__ = [ + "ICRCalculator", "ICRReport", "ICRResult", "PerCategoryAgreement", + "MethodsWriter", "MethodsSection", "QuantiKitPipelineMetadata", "QualiKitPipelineMetadata", + "charts", +] diff --git a/socialscikit/core/charts.py b/socialscikit/core/charts.py new file mode 100644 index 0000000..1796708 --- /dev/null +++ b/socialscikit/core/charts.py @@ -0,0 +1,697 @@ +"""Academic-style matplotlib charts for SocialSciKit dashboards. + +All charts follow a consistent visual language: +- White background, subtle gridlines +- Blue (#4A90D9) primary palette with complementary greens/oranges +- CJK font fallback for Chinese label support +- Clean spines, readable tick labels + +Every ``plot_*`` function returns a ``matplotlib.figure.Figure`` ready for +``gr.Plot``. The ``format_*_html`` helpers produce styled HTML cards for +``gr.HTML``. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import matplotlib.ticker as mticker # noqa: E402 +import numpy as np # noqa: E402 + +logger = logging.getLogger(__name__) + +# ====================================================================== +# Palette & style +# ====================================================================== + +PALETTE = { + "primary": "#4A90D9", + "secondary": "#5BA88D", + "accent": "#E8734A", + "warning": "#F5A623", + "purple": "#9B7ED8", + "neutral": "#8B9DC3", + "bg": "#FFFFFF", + "text": "#333333", + "text_sec": "#666666", + "grid": "#F0F0F0", + "spine": "#DDDDDD", + "card_bg": "#F8F9FA", +} + +# Categorical palette (up to 10 classes) +CAT_COLORS = [ + "#4A90D9", "#5BA88D", "#E8734A", "#F5A623", "#9B7ED8", + "#E85D75", "#5BCBCF", "#B8CC4A", "#D4A76A", "#8B9DC3", +] + +CJK_FONTS = [ + "PingFang SC", "Heiti SC", "STHeiti", + "Microsoft YaHei", "SimHei", "DejaVu Sans", +] + + +def _setup_style() -> None: + """Apply the global academic-style defaults.""" + plt.rcParams.update({ + "font.sans-serif": CJK_FONTS, + "axes.unicode_minus": False, + "figure.facecolor": PALETTE["bg"], + "axes.facecolor": PALETTE["bg"], + "axes.edgecolor": PALETTE["spine"], + "axes.labelcolor": PALETTE["text"], + "xtick.color": PALETTE["text_sec"], + "ytick.color": PALETTE["text_sec"], + }) + + +def _clean_ax(ax: plt.Axes, *, keep_left: bool = True, + keep_bottom: bool = True) -> None: + """Remove top/right spines and style remaining ones.""" + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + if not keep_left: + ax.spines["left"].set_visible(False) + ax.spines["left"].set_color(PALETTE["spine"]) + ax.spines["bottom"].set_color(PALETTE["spine"]) + ax.tick_params(colors=PALETTE["text_sec"], labelsize=9, length=3) + + +# ====================================================================== +# QuantiKit — Evaluation charts +# ====================================================================== + + +def plot_confusion_matrix( + labels: list[str], + matrix: list[list[int]], + lang: str = "zh", +) -> plt.Figure: + """Confusion matrix heatmap with annotated counts. + + Parameters + ---------- + labels : list[str] + Class labels (row = true, col = predicted). + matrix : list[list[int]] + Confusion matrix as nested list. + """ + _setup_style() + k = len(labels) + + # Empty / degenerate input — return a placeholder + if k == 0 or not matrix: + fig, ax = plt.subplots(figsize=(5, 3), dpi=150) + fig.patch.set_facecolor("white") + msg = "无数据" if lang == "zh" else "No data" + ax.text(0.5, 0.5, msg, ha="center", va="center", + transform=ax.transAxes, fontsize=14, color=PALETTE["text_sec"]) + ax.axis("off") + return fig + + arr = np.array(matrix, dtype=float) + + # Normalise for colour (row-wise, avoid /0) + row_sums = arr.sum(axis=1, keepdims=True) + row_sums[row_sums == 0] = 1 + norm = arr / row_sums + + figsize = (max(4.5, 0.9 * k + 2), max(4, 0.9 * k + 1.5)) + fig, ax = plt.subplots(figsize=figsize, dpi=150) + fig.patch.set_facecolor("white") + + cmap = plt.cm.Blues + im = ax.imshow(norm, interpolation="nearest", cmap=cmap, aspect="auto", + vmin=0, vmax=1) + + # Annotate cells + thresh = norm.max() / 2.0 + for i in range(k): + for j in range(k): + color = "white" if norm[i, j] > thresh else PALETTE["text"] + count = int(arr[i, j]) + pct = norm[i, j] * 100 + text = f"{count}\n({pct:.0f}%)" if count > 0 else "0" + fontsize = 11 if k <= 6 else 9 + ax.text(j, i, text, ha="center", va="center", + color=color, fontsize=fontsize, fontweight="500") + + # Axis labels + ax.set_xticks(range(k)) + ax.set_yticks(range(k)) + tick_fontsize = 9 if k <= 8 else 7 + ax.set_xticklabels(labels, fontsize=tick_fontsize, rotation=45 if k > 5 else 0, + ha="right" if k > 5 else "center") + ax.set_yticklabels(labels, fontsize=tick_fontsize) + + xlabel = "预测标签" if lang == "zh" else "Predicted" + ylabel = "真实标签" if lang == "zh" else "True" + title = "混淆矩阵" if lang == "zh" else "Confusion Matrix" + ax.set_xlabel(xlabel, fontsize=10.5, color=PALETTE["text"], labelpad=8) + ax.set_ylabel(ylabel, fontsize=10.5, color=PALETTE["text"], labelpad=8) + ax.set_title(title, fontsize=12, fontweight="600", color=PALETTE["text"], + pad=12) + + # Colour bar + cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.06) + cbar.ax.tick_params(labelsize=8, colors=PALETTE["text_sec"]) + cbar_label = "行归一化比例" if lang == "zh" else "Row-normalised" + cbar.set_label(cbar_label, fontsize=9, color=PALETTE["text_sec"]) + + fig.tight_layout(pad=1.5) + return fig + + +def plot_per_class_metrics( + per_class: list[dict[str, Any]], + lang: str = "zh", +) -> plt.Figure: + """Grouped horizontal bar chart of Precision / Recall / F1 per class. + + Parameters + ---------- + per_class : list of dict + Each dict has keys: label, precision, recall, f1, support. + """ + _setup_style() + n = len(per_class) + if n == 0: + fig, ax = plt.subplots(figsize=(5, 2), dpi=150) + ax.text(0.5, 0.5, "No data", ha="center", va="center") + return fig + + labels = [pc["label"] for pc in per_class] + precision = [pc["precision"] for pc in per_class] + recall = [pc["recall"] for pc in per_class] + f1 = [pc["f1"] for pc in per_class] + + y = np.arange(n) + bar_h = 0.25 + + figsize = (6, max(3, 0.6 * n + 1.5)) + fig, ax = plt.subplots(figsize=figsize, dpi=150) + fig.patch.set_facecolor("white") + + bars_p = ax.barh(y + bar_h, precision, bar_h, color=PALETTE["primary"], + label="Precision", alpha=0.85, edgecolor="white", linewidth=0.5) + bars_r = ax.barh(y, recall, bar_h, color=PALETTE["secondary"], + label="Recall", alpha=0.85, edgecolor="white", linewidth=0.5) + bars_f = ax.barh(y - bar_h, f1, bar_h, color=PALETTE["accent"], + label="F1", alpha=0.85, edgecolor="white", linewidth=0.5) + + # Value labels + for bars in [bars_p, bars_r, bars_f]: + for bar in bars: + w = bar.get_width() + if w > 0.05: + ax.text(w + 0.01, bar.get_y() + bar.get_height() / 2, + f"{w:.2f}", va="center", fontsize=8, + color=PALETTE["text_sec"]) + + ax.set_yticks(y) + ax.set_yticklabels(labels, fontsize=9) + ax.set_xlim(0, 1.12) + ax.xaxis.set_major_formatter(mticker.PercentFormatter(xmax=1, decimals=0)) + + title = "各类别指标" if lang == "zh" else "Per-Class Metrics" + ax.set_title(title, fontsize=12, fontweight="600", color=PALETTE["text"], + pad=10) + ax.legend(fontsize=8, frameon=False, loc="lower right") + _clean_ax(ax) + ax.grid(axis="x", color=PALETTE["grid"], linewidth=0.6) + + fig.tight_layout(pad=1.2) + return fig + + +def plot_label_distribution( + true_counts: dict[str, int], + pred_counts: dict[str, int], + lang: str = "zh", +) -> plt.Figure: + """Side-by-side vertical bar chart of true vs predicted distributions. + + Parameters + ---------- + true_counts, pred_counts : dict[str, int] + Label → count mappings. + """ + _setup_style() + all_labels = sorted(set(true_counts) | set(pred_counts)) + n = len(all_labels) + if n == 0: + fig, ax = plt.subplots(figsize=(5, 2), dpi=150) + ax.text(0.5, 0.5, "No data", ha="center", va="center") + return fig + + true_vals = [true_counts.get(l, 0) for l in all_labels] + pred_vals = [pred_counts.get(l, 0) for l in all_labels] + + x = np.arange(n) + w = 0.35 + + figsize = (max(5, 0.7 * n + 2), 3.5) + fig, ax = plt.subplots(figsize=figsize, dpi=150) + fig.patch.set_facecolor("white") + + true_label = "真实" if lang == "zh" else "True" + pred_label = "预测" if lang == "zh" else "Predicted" + + ax.bar(x - w / 2, true_vals, w, color=PALETTE["primary"], alpha=0.8, + label=true_label, edgecolor="white", linewidth=0.5) + ax.bar(x + w / 2, pred_vals, w, color=PALETTE["accent"], alpha=0.8, + label=pred_label, edgecolor="white", linewidth=0.5) + + ax.set_xticks(x) + ax.set_xticklabels(all_labels, fontsize=9, + rotation=45 if n > 5 else 0, + ha="right" if n > 5 else "center") + ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) + + title = "标签分布对比" if lang == "zh" else "Label Distribution" + ylabel = "数量" if lang == "zh" else "Count" + ax.set_title(title, fontsize=12, fontweight="600", color=PALETTE["text"], + pad=10) + ax.set_ylabel(ylabel, fontsize=10, color=PALETTE["text_sec"]) + ax.legend(fontsize=8, frameon=False) + _clean_ax(ax) + ax.grid(axis="y", color=PALETTE["grid"], linewidth=0.6) + + fig.tight_layout(pad=1.2) + return fig + + +# ====================================================================== +# Annotation progress chart +# ====================================================================== + + +def plot_annotation_progress( + labeled: int, + skipped: int, + flagged: int, + pending: int, + lang: str = "zh", +) -> plt.Figure: + """Donut chart showing annotation session progress.""" + _setup_style() + + sizes = [labeled, skipped, flagged, pending] + colors = [PALETTE["primary"], PALETTE["warning"], PALETTE["purple"], "#E0E0E0"] + if lang == "zh": + seg_labels = ["已标注", "已跳过", "已标记", "待标注"] + else: + seg_labels = ["Labeled", "Skipped", "Flagged", "Pending"] + + # Filter out zero segments + filtered = [(s, c, l) for s, c, l in zip(sizes, colors, seg_labels) if s > 0] + if not filtered: + fig, ax = plt.subplots(figsize=(4, 4), dpi=150) + ax.text(0.5, 0.5, "No data", ha="center", va="center", + transform=ax.transAxes) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.axis("off") + return fig + + sizes_f, colors_f, labels_f = zip(*filtered) + total = sum(sizes_f) + + fig, ax = plt.subplots(figsize=(4, 4), dpi=150) + fig.patch.set_facecolor("white") + + wedges, texts, autotexts = ax.pie( + sizes_f, labels=labels_f, colors=colors_f, + autopct=lambda pct: f"{pct:.0f}%" if pct > 5 else "", + startangle=90, pctdistance=0.78, + wedgeprops=dict(width=0.35, edgecolor="white", linewidth=2), + textprops=dict(fontsize=9, color=PALETTE["text"]), + ) + for at in autotexts: + at.set_fontsize(8) + at.set_color("white") + at.set_fontweight("600") + + # Centre text + pct = (labeled / total * 100) if total > 0 else 0 + ax.text(0, 0, f"{pct:.0f}%", ha="center", va="center", + fontsize=22, fontweight="700", color=PALETTE["primary"]) + progress_text = "完成" if lang == "zh" else "Done" + ax.text(0, -0.15, progress_text, ha="center", va="center", + fontsize=9, color=PALETTE["text_sec"]) + + title = "标注进度" if lang == "zh" else "Annotation Progress" + ax.set_title(title, fontsize=12, fontweight="600", color=PALETTE["text"], + pad=12, y=1.02) + + fig.tight_layout(pad=0.5) + return fig + + +# ====================================================================== +# QualiKit — Extraction / Review charts +# ====================================================================== + + +def plot_confidence_histogram( + confidences: list[float], + lang: str = "zh", +) -> plt.Figure: + """Histogram of extraction confidence scores with tier shading.""" + _setup_style() + if not confidences: + fig, ax = plt.subplots(figsize=(5, 3), dpi=150) + ax.text(0.5, 0.5, "No data", ha="center", va="center", + transform=ax.transAxes) + ax.axis("off") + return fig + + fig, ax = plt.subplots(figsize=(5.5, 3.5), dpi=150) + fig.patch.set_facecolor("white") + + # Background tier shading + ax.axvspan(0, 0.5, alpha=0.06, color="#E8734A") # low + ax.axvspan(0.5, 0.75, alpha=0.06, color="#F5A623") # medium + ax.axvspan(0.75, 1.0, alpha=0.06, color="#5BA88D") # high + + ax.hist(confidences, bins=20, range=(0, 1), color=PALETTE["primary"], + alpha=0.8, edgecolor="white", linewidth=0.5) + + # Tier labels + tier_y = ax.get_ylim()[1] * 0.92 + if lang == "zh": + ax.text(0.25, tier_y, "低", ha="center", fontsize=8, + color=PALETTE["accent"], alpha=0.7) + ax.text(0.625, tier_y, "中", ha="center", fontsize=8, + color=PALETTE["warning"], alpha=0.7) + ax.text(0.875, tier_y, "高", ha="center", fontsize=8, + color=PALETTE["secondary"], alpha=0.7) + else: + ax.text(0.25, tier_y, "Low", ha="center", fontsize=8, + color=PALETTE["accent"], alpha=0.7) + ax.text(0.625, tier_y, "Med", ha="center", fontsize=8, + color=PALETTE["warning"], alpha=0.7) + ax.text(0.875, tier_y, "High", ha="center", fontsize=8, + color=PALETTE["secondary"], alpha=0.7) + + # Median line + median = float(np.median(confidences)) + ax.axvline(median, color=PALETTE["accent"], linewidth=1.2, linestyle="--", + alpha=0.8) + median_label = f"中位数 {median:.2f}" if lang == "zh" else f"Median {median:.2f}" + ax.text(median + 0.02, ax.get_ylim()[1] * 0.8, median_label, + fontsize=8, color=PALETTE["accent"]) + + title = "置信度分布" if lang == "zh" else "Confidence Distribution" + xlabel = "置信度" if lang == "zh" else "Confidence" + ylabel = "频次" if lang == "zh" else "Count" + ax.set_title(title, fontsize=12, fontweight="600", color=PALETTE["text"], + pad=10) + ax.set_xlabel(xlabel, fontsize=10, color=PALETTE["text_sec"]) + ax.set_ylabel(ylabel, fontsize=10, color=PALETTE["text_sec"]) + ax.set_xlim(0, 1) + ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) + _clean_ax(ax) + + fig.tight_layout(pad=1.2) + return fig + + +def plot_theme_distribution( + theme_counts: dict[str, int], + lang: str = "zh", +) -> plt.Figure: + """Horizontal bar chart of theme / research question frequencies.""" + _setup_style() + if not theme_counts: + fig, ax = plt.subplots(figsize=(5, 2), dpi=150) + ax.text(0.5, 0.5, "No data", ha="center", va="center", + transform=ax.transAxes) + ax.axis("off") + return fig + + # Sort ascending for horizontal bars (bottom to top) + sorted_items = sorted(theme_counts.items(), key=lambda x: x[1]) + labels = [item[0] for item in sorted_items] + values = [item[1] for item in sorted_items] + n = len(labels) + + colors = [CAT_COLORS[i % len(CAT_COLORS)] for i in range(n)] + + figsize = (6, max(2.5, 0.45 * n + 1)) + fig, ax = plt.subplots(figsize=figsize, dpi=150) + fig.patch.set_facecolor("white") + + bars = ax.barh(range(n), values, color=colors, alpha=0.85, + edgecolor="white", linewidth=0.5, height=0.6) + + # Value labels + for bar, v in zip(bars, values): + ax.text(bar.get_width() + max(values) * 0.02, + bar.get_y() + bar.get_height() / 2, + str(v), va="center", fontsize=9, color=PALETTE["text_sec"]) + + ax.set_yticks(range(n)) + ax.set_yticklabels(labels, fontsize=9) + ax.xaxis.set_major_locator(mticker.MaxNLocator(integer=True)) + + title = "主题分布" if lang == "zh" else "Theme Distribution" + xlabel = "提取数" if lang == "zh" else "Extractions" + ax.set_title(title, fontsize=12, fontweight="600", color=PALETTE["text"], + pad=10) + ax.set_xlabel(xlabel, fontsize=10, color=PALETTE["text_sec"]) + _clean_ax(ax) + ax.grid(axis="x", color=PALETTE["grid"], linewidth=0.6) + + fig.tight_layout(pad=1.2) + return fig + + +def plot_review_progress( + accepted: int, + edited: int, + rejected: int, + pending: int, + lang: str = "zh", +) -> plt.Figure: + """Donut chart of review action distribution.""" + _setup_style() + + sizes = [accepted, edited, rejected, pending] + colors_list = [PALETTE["secondary"], PALETTE["primary"], + PALETTE["accent"], "#E0E0E0"] + if lang == "zh": + seg_labels = ["接受", "编辑", "拒绝", "待审"] + else: + seg_labels = ["Accepted", "Edited", "Rejected", "Pending"] + + filtered = [(s, c, l) for s, c, l in zip(sizes, colors_list, seg_labels) if s > 0] + if not filtered: + fig, ax = plt.subplots(figsize=(4, 4), dpi=150) + ax.text(0.5, 0.5, "No data", ha="center", va="center", + transform=ax.transAxes) + ax.axis("off") + return fig + + sizes_f, colors_f, labels_f = zip(*filtered) + total = sum(sizes_f) + done = accepted + edited + rejected + + fig, ax = plt.subplots(figsize=(4, 4), dpi=150) + fig.patch.set_facecolor("white") + + wedges, texts, autotexts = ax.pie( + sizes_f, labels=labels_f, colors=colors_f, + autopct=lambda pct: f"{pct:.0f}%" if pct > 5 else "", + startangle=90, pctdistance=0.78, + wedgeprops=dict(width=0.35, edgecolor="white", linewidth=2), + textprops=dict(fontsize=9, color=PALETTE["text"]), + ) + for at in autotexts: + at.set_fontsize(8) + at.set_color("white") + at.set_fontweight("600") + + pct = (done / total * 100) if total > 0 else 0 + ax.text(0, 0, f"{pct:.0f}%", ha="center", va="center", + fontsize=22, fontweight="700", color=PALETTE["secondary"]) + progress_text = "已审查" if lang == "zh" else "Reviewed" + ax.text(0, -0.15, progress_text, ha="center", va="center", + fontsize=9, color=PALETTE["text_sec"]) + + title = "审查进度" if lang == "zh" else "Review Progress" + ax.set_title(title, fontsize=12, fontweight="600", color=PALETTE["text"], + pad=12, y=1.02) + + fig.tight_layout(pad=0.5) + return fig + + +# ====================================================================== +# HTML metric cards +# ====================================================================== + +_CARD_CSS = ( + "display:inline-flex;flex-direction:column;align-items:center;" + "justify-content:center;background:{bg};border-radius:10px;" + "padding:18px 16px 14px;text-align:center;border-left:4px solid {color};" + "min-width:120px;flex:1;" +) + +_GRID_CSS = ( + "display:flex;flex-wrap:wrap;gap:12px;margin:8px 0 16px;" +) + + +def _metric_card(value: str, label: str, color: str) -> str: + style = _CARD_CSS.format(bg=PALETTE["card_bg"], color=color) + return ( + f'
' + f'
{value}
' + f'
{label}
' + f'
' + ) + + +def format_eval_metrics_html( + accuracy: float, + macro_f1: float, + weighted_f1: float, + cohens_kappa: float, + n_total: int, + n_correct: int, + lang: str = "zh", +) -> str: + """Render evaluation metrics as styled HTML cards. + + Returns + ------- + str + HTML string for ``gr.HTML``. + """ + if lang == "zh": + lbl_acc = "准确率" + lbl_mf1 = "Macro F1" + lbl_wf1 = "Weighted F1" + lbl_kap = "Cohen's κ" + lbl_tot = "总样本" + lbl_cor = "正确数" + else: + lbl_acc = "Accuracy" + lbl_mf1 = "Macro F1" + lbl_wf1 = "Weighted F1" + lbl_kap = "Cohen's κ" + lbl_tot = "Total" + lbl_cor = "Correct" + + cards = [ + _metric_card(f"{accuracy:.4f}", lbl_acc, PALETTE["primary"]), + _metric_card(f"{macro_f1:.4f}", lbl_mf1, PALETTE["secondary"]), + _metric_card(f"{weighted_f1:.4f}", lbl_wf1, PALETTE["accent"]), + _metric_card(f"{cohens_kappa:.4f}", lbl_kap, PALETTE["purple"]), + _metric_card(str(n_total), lbl_tot, PALETTE["neutral"]), + _metric_card(str(n_correct), lbl_cor, PALETTE["primary"]), + ] + + return f'
{"".join(cards)}
' + + +def format_review_stats_html( + total: int, + accepted: int, + edited: int, + rejected: int, + pending: int, + lang: str = "zh", +) -> str: + """Render review progress as styled HTML cards.""" + if lang == "zh": + labels = ["总条目", "已接受", "已编辑", "已拒绝", "待审"] + else: + labels = ["Total", "Accepted", "Edited", "Rejected", "Pending"] + + colors = [PALETTE["neutral"], PALETTE["secondary"], PALETTE["primary"], + PALETTE["accent"], "#AAAAAA"] + values = [str(total), str(accepted), str(edited), str(rejected), str(pending)] + + cards = [_metric_card(v, l, c) for v, l, c in zip(values, labels, colors)] + return f'
{"".join(cards)}
' + + +def format_annotation_stats_html( + total: int, + labeled: int, + skipped: int, + flagged: int, + pending: int, + elapsed: float, + label_dist: dict[str, int] | None = None, + lang: str = "zh", +) -> str: + """Render annotation session stats as styled HTML cards.""" + if lang == "zh": + labels = ["总数", "已标注", "已跳过", "已标记", "待标注"] + else: + labels = ["Total", "Labeled", "Skipped", "Flagged", "Pending"] + + colors = [PALETTE["neutral"], PALETTE["primary"], PALETTE["warning"], + PALETTE["purple"], "#AAAAAA"] + values = [str(total), str(labeled), str(skipped), str(flagged), str(pending)] + + cards = [_metric_card(v, l, c) for v, l, c in zip(values, labels, colors)] + + # Progress percentage card + pct = (labeled / total * 100) if total > 0 else 0 + pct_label = "完成率" if lang == "zh" else "Progress" + cards.append(_metric_card(f"{pct:.0f}%", pct_label, PALETTE["secondary"])) + + # Time card + mins = int(elapsed // 60) + secs = int(elapsed % 60) + time_str = f"{mins}:{secs:02d}" + time_label = "用时" if lang == "zh" else "Time" + cards.append(_metric_card(time_str, time_label, PALETTE["accent"])) + + html = f'
{"".join(cards)}
' + + # Add label distribution bar if available + if label_dist: + html += _mini_label_bar(label_dist, lang) + + return html + + +def _mini_label_bar(label_dist: dict[str, int], lang: str = "zh") -> str: + """Inline stacked bar showing label distribution.""" + total = sum(label_dist.values()) + if total == 0: + return "" + + segments = [] + for i, (label, count) in enumerate(sorted(label_dist.items())): + pct = count / total * 100 + color = CAT_COLORS[i % len(CAT_COLORS)] + segments.append( + f'
' + 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 # ---------------------------------------------------------------------------