diff --git a/docs/ir-and-source-map.zh-CN.md b/docs/ir-and-source-map.zh-CN.md index e9847da..fe5715f 100644 --- a/docs/ir-and-source-map.zh-CN.md +++ b/docs/ir-and-source-map.zh-CN.md @@ -46,12 +46,7 @@ content.json -> element_ir.json -> source_map.json -> notes.md / GUI / coverage ## 构建时机 -IR 会在两个阶段写入: - -1. `export_content` 阶段写基础 IR,供后续 prompt、source map 和 coverage 使用。 -2. coverage 阶段结束后刷新最终 IR,合入 `covered`、`missing`、`marker-only` 等实际状态。 - -这样最终 `element_ir.json` 不是只停留在前置状态,而是反映生成后的覆盖结果。 +构建过程中,prompt、coverage 和 source map 会直接从当前 `Deck` 构造需要的元素视图。coverage 阶段结束后写入一次最终 `element_ir.json`,合入 `covered`、`missing`、`marker-only` 等实际状态,避免重复生成中间文件。 ## source_map.json diff --git a/docs/pipeline.zh-CN.md b/docs/pipeline.zh-CN.md index 9385e17..6df8f98 100644 --- a/docs/pipeline.zh-CN.md +++ b/docs/pipeline.zh-CN.md @@ -8,15 +8,19 @@ Ingest -> Understand -> Write -> Guard -> Export 底层模块可以保持细粒度,方便缓存、调试和局部刷新;用户侧和 LLM 工作流应该看到清楚的阶段边界。 +实现中由 `BUILD_PHASES` 按这五个阶段组织步骤。构建开始时会根据 preset 和选项排除未启用的 OCR、Vision、图裁剪等步骤,并把实际计划写入 `progress.json` 的 `planned_stages`;`current_phase` 表示当前产品阶段。逐步耗时仍保留在 `run_summary.json`,方便定位慢点。 + ## 阶段总览 | 阶段 | 目标 | 典型产物 | | --- | --- | --- | -| Ingest | 把 PPT/PDF 变成稳定、可追溯、可复现的结构化材料。 | `content.json`、`element_ir.json`、`source_map.json`、截图、图片资产、parser adapter | -| Understand | 理解课件主题、章节结构、页面角色、图表含义和关键元素。 | `deck_understanding.json`、`page_understanding.json`、`sections.json`、`deck_brief.json`、`semantic_layout.json`、`table_understanding.json`、`figure_grounding.json` | +| Ingest | 解析 PPT/PDF,提取页面元素和资源。 | 内存中的 `Deck`、截图、图片资产 | +| Understand | 理解课件主题、章节结构、页面角色、图表含义和关键元素。 | `content.json`、`page_modalities.json`、`deck_understanding.json`、`page_understanding.json`、`sections.json`、`deck_brief.json`、`semantic_layout.json`、`table_understanding.json`、`figure_grounding.json`、`content_guard.json` | | Write | 生成可读学习笔记,而不是机械逐页搬运。 | `notes.md`、`page_notes.json`、`weave_report.json`、`teaching_enrichment.json` | -| Guard | 检查是否漏掉关键内容、是否有来源、是否像讲义。 | `coverage.json`、`coverage.md`、`content_guard.json`、`quality_report.json` | -| Export | 输出阅读和复习材料。 | `notes.toc.md`、`notes.docx`、`notes.pdf`、`notes.tex`、`review.md`、`exam.html` | +| Guard | 检查是否漏掉关键内容、是否有来源、是否像讲义。 | `coverage.json`、`coverage.md`、`element_ir.json`、`source_map.json`、`quality_report.json` | +| Export | 输出阅读材料和构建摘要。 | `notes.toc.md`、`notes.docx`、`notes.pdf`、`notes.tex`、`run_summary.json` | + +`lecture` 的教学补充采用按章节判断:整合后的章节稿已有足够正文,并包含例子、易错点和自测线索时,跳过额外模型调用;`force` 仍会执行补充。 ## 什么不交给 LLM diff --git a/gui/README_GUI.md b/gui/README_GUI.md index 4caf012..cb207c2 100644 --- a/gui/README_GUI.md +++ b/gui/README_GUI.md @@ -13,6 +13,7 @@ SlideNote Studio is a Streamlit interface for `python -m slidenote build` and `p - Enter Text / Vision / OCR API keys on the page. Keys are passed only through the child-process environment, not command-line flags. - Select extra exports: Markdown ZIP, TOC Markdown, Word, PDF, or LaTeX. - Keep progress, ETA, Doctor readiness, usage, and cost details in compact diagnostics panels. +- Saved page modality corrections apply to the next build of the same source file; a different file does not inherit them. - Generate a study pack from the Notes workspace: `review.md`, `exam.md`, `exam.json`, `exam.html`, and related files. - Download `notes.zip`, `notes.md`, `coverage.md`, export files, or the complete output ZIP. - Switch to **Textbook library**, upload a PDF textbook, and build a RAG-ready corpus. The corpus is not connected to note generation yet. diff --git a/gui/README_GUI.zh-CN.md b/gui/README_GUI.zh-CN.md index f8d0987..a653dd0 100644 --- a/gui/README_GUI.zh-CN.md +++ b/gui/README_GUI.zh-CN.md @@ -13,6 +13,7 @@ SlideNote Studio 是一个基于 Streamlit 的图形界面。它包装 `python - - 在页面里临时填写 Text / Vision / OCR API key;key 只通过本次子进程环境变量传入,不写进命令行。 - 选择是否导出 `notes.zip`、目录 Markdown、Word、PDF 或 LaTeX。 - 进度、ETA、Doctor、用量和成本信息收在紧凑的诊断区里。 +- 在页面里保存的模态修正会用于同一源文件的下一次构建;更换文件后不会沿用旧修正。 - 在 Notes workspace 基于已有输出目录生成复习包:`review.md`、`exam.md`、`exam.json`、`exam.html` 等。 - 下载 `notes.zip`、`notes.md`、`coverage.md`、导出文件或完整结果 ZIP。 - 切换到 **Textbook library**,上传 PDF 教材,构建 RAG-ready 教材库;该库当前不会自动参与笔记生成。 diff --git a/gui/app.py b/gui/app.py index 8cf6e04..d57af9d 100644 --- a/gui/app.py +++ b/gui/app.py @@ -1,16 +1,20 @@ from __future__ import annotations -import io +import hashlib import html +import io import json import os import re import shutil import subprocess +import threading import time import zipfile -from datetime import datetime +from collections import deque +from datetime import datetime, timezone from pathlib import Path +from queue import Empty, Queue from typing import Any import streamlit as st @@ -180,6 +184,12 @@ def _run_simplified_app() -> None: st.error(f"Could not prepare output folder: {exc}") return config = _clone_config_for_run(preview_config, input_path=input_path, output_dir=output_dir, progress_json=progress_json) + if _carry_modality_overrides( + Path(st.session_state["last_output_dir"]) if st.session_state.get("last_output_dir") else None, + input_path, + output_dir, + ): + st.caption("Saved page modality corrections will be used for this build.") _run_build(config) st.session_state["last_output_dir"] = str(output_dir) @@ -662,7 +672,8 @@ def _clone_config_for_run(config: StudioConfig, input_path: Path, output_dir: Pa def _prepare_run_paths(uploaded, output_base: Path, timestamped_subfolder: bool) -> tuple[Path, Path, Path]: - run_name = f"{safe_run_name(uploaded.name)}_{int(time.time())}" + # Keep each uploaded source immutable so corrections can be checked against its bytes. + run_name = f"{safe_run_name(uploaded.name)}_{time.time_ns()}" input_path = UPLOADS_DIR / f"{run_name}{Path(uploaded.name).suffix.lower()}" input_path.write_bytes(uploaded.getbuffer()) output_base.mkdir(parents=True, exist_ok=True) @@ -672,6 +683,65 @@ def _prepare_run_paths(uploaded, output_base: Path, timestamped_subfolder: bool) return input_path, output_dir, progress_json +def _output_source_path(output_dir: Path) -> Path | None: + content = _read_json(output_dir / "content.json") or {} + if not isinstance(content, dict): + return None + source_name = content.get("source_path") + if not isinstance(source_name, str) or not source_name: + return None + source_path = Path(source_name) + if not source_path.is_absolute(): + source_path = ROOT / source_path + return source_path + + +def _output_source_matches(output_dir: Path, input_path: Path) -> bool: + manifest = _read_json(output_dir / "page_modalities.overrides.json") or {} + source_hash = manifest.get("source_sha256") if isinstance(manifest, dict) else None + if source_hash is not None: + if not isinstance(source_hash, str) or len(source_hash) != 64 or any(char not in "0123456789abcdefABCDEF" for char in source_hash): + return False + try: + digest = hashlib.sha256() + with input_path.open("rb") as current_file: + while chunk := current_file.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() == source_hash.lower() + except OSError: + return False + source_path = _output_source_path(output_dir) + if source_path is None: + return False + try: + if source_path.stat().st_size != input_path.stat().st_size: + return False + with source_path.open("rb") as old_file, input_path.open("rb") as new_file: + while old_chunk := old_file.read(1024 * 1024): + if old_chunk != new_file.read(len(old_chunk)): + return False + return not new_file.read(1) + except OSError: + return False + + +def _carry_modality_overrides(previous_output_dir: Path | None, input_path: Path, output_dir: Path) -> bool: + manifest_name = "page_modalities.overrides.json" + target = output_dir / manifest_name + if target.is_file(): + if _output_source_matches(output_dir, input_path): + return True + # Preserve corrections for the old source without applying them to a different upload. + target.replace(output_dir / f"page_modalities.overrides.stale-{time.time_ns()}.json") + if previous_output_dir is None or previous_output_dir == output_dir: + return False + source = previous_output_dir / manifest_name + if not source.is_file() or not _output_source_matches(previous_output_dir, input_path): + return False + shutil.copy2(source, target) + return True + + def _prepare_textbook_paths(uploaded) -> tuple[Path, Path]: if Path(uploaded.name).suffix.lower() != ".pdf": raise ValueError("Textbook library v1 only accepts PDF files.") @@ -691,7 +761,7 @@ def _run_build(config: StudioConfig) -> None: status_box = st.empty() stage_box = st.empty() log_box = st.empty() - logs: list[str] = [] + logs: deque[str] = deque(maxlen=120) process = subprocess.Popen( cmd, @@ -704,21 +774,38 @@ def _run_build(config: StudioConfig) -> None: errors="replace", bufsize=1, ) - while process.poll() is None: + output_queue: Queue[str] = Queue() + + def read_output() -> None: if process.stdout is not None: - line = process.stdout.readline() - if line: - logs.append(line.rstrip()) + with process.stdout: + for line in process.stdout: + output_queue.put(line.rstrip("\r\n")) + + reader = threading.Thread(target=read_output, daemon=True) + reader.start() + + while True: + for _ in range(200): + try: + logs.append(output_queue.get_nowait()) + except Empty: + break _update_progress_ui(config.progress_json, progress_bar, status_box, stage_box) - log_box.code("\n".join(logs[-80:]) or "Running...", language="text") + log_box.code("\n".join(list(logs)[-80:]) or "Running...", language="text") + if process.poll() is not None: + break time.sleep(0.25) - if process.stdout is not None: - rest = process.stdout.read() - if rest: - logs.extend(rest.splitlines()) + reader.join(timeout=2.0) + while True: + try: + logs.append(output_queue.get_nowait()) + except Empty: + break + process.wait() _update_progress_ui(config.progress_json, progress_bar, status_box, stage_box) - log_box.code("\n".join(logs[-120:]) or "No console output.", language="text") + log_box.code("\n".join(logs) or "No console output.", language="text") if process.returncode == 0: _generate_cost_report(config.output_dir) @@ -1121,7 +1208,21 @@ def _save_modality_override(output_dir: Path, slide_id: int, modality: str, note path = output_dir / "page_modalities.overrides.json" data = _read_json(path) or {"schema_version": 1, "pages": {}} pages = data.setdefault("pages", {}) - pages[str(slide_id)] = {"modality": modality, "note": note, "updated_at": datetime.utcnow().isoformat(timespec="seconds") + "Z"} + pages[str(slide_id)] = { + "modality": modality, + "note": note, + "updated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + } + source_path = _output_source_path(output_dir) + if source_path is not None: + try: + digest = hashlib.sha256() + with source_path.open("rb") as source_file: + while chunk := source_file.read(1024 * 1024): + digest.update(chunk) + data["source_sha256"] = digest.hexdigest() + except OSError: + pass path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/gui/studio_core.py b/gui/studio_core.py index fb90bf0..70915c4 100644 --- a/gui/studio_core.py +++ b/gui/studio_core.py @@ -224,7 +224,8 @@ def progress_percent(progress: dict[str, Any]) -> float: current = progress.get("current_stage") or {} stages = progress.get("stages") or [] completed = len(stages) - total_known_stages = 13 + planned = progress.get("planned_stages") + total_known_stages = len(planned) if isinstance(planned, list) and planned else 13 base = min(completed / total_known_stages, 0.95) stage_total = current.get("total") or 0 stage_current = current.get("current") or 0 diff --git a/slidenote/build/artifacts.py b/slidenote/build/artifacts.py index 3d3d58a..194bee8 100644 --- a/slidenote/build/artifacts.py +++ b/slidenote/build/artifacts.py @@ -3,12 +3,12 @@ from pathlib import Path from typing import Any -from slidenote.pipeline import ArtifactRegistry, BuildContext, FunctionStage, StageResult, run_stage +from slidenote.pipeline import ArtifactRegistry def _run_json_stage( deck, - context: BuildContext, + state, *, name: str, artifact_name: str, @@ -16,30 +16,13 @@ def _run_json_stage( message: str, complete_message: str, runner, - dependencies: list[str] | None = None, ) -> dict[str, Any]: - progress = context.progress + progress = state.progress progress.start_stage(name, message=message) - - def stage_runner(stage_deck, stage_context: BuildContext) -> StageResult: - report = runner(stage_deck) - artifacts: dict[str, str] = {} - if stage_context.artifacts is not None: - stage_context.artifacts.write_json(artifact_name, artifact_path, report) - registered = stage_context.artifacts.relative_path(artifact_name) - if registered: - artifacts[artifact_name] = registered - return StageResult(name=name, report=report, artifacts=artifacts) - - stage = FunctionStage( - name=name, - dependencies=dependencies or [], - artifacts=[artifact_name], - runner=stage_runner, - ) - result = run_stage(deck, context, stage) + report = runner(deck) + state.artifacts.write_json(artifact_name, artifact_path, report) progress.finish_stage(complete_message) - return result.report or {} + return report or {} def _register_export_artifacts(artifacts: ArtifactRegistry, export_report: dict[str, Any]) -> None: diff --git a/slidenote/build/progress.py b/slidenote/build/progress.py index 91b58cd..a239144 100644 --- a/slidenote/build/progress.py +++ b/slidenote/build/progress.py @@ -27,6 +27,9 @@ def callback(event: dict[str, Any]) -> None: def _llm_progress(progress: ProgressReporter): def callback(record: dict[str, Any]) -> None: + if record.get("event") == "total": + progress.set_total(record.get("total")) + return label = record.get("context_id") or record.get("slide_id") progress.advance( message=f"LLM context {label}", diff --git a/slidenote/build/runner.py b/slidenote/build/runner.py index 227a1e9..5e2a1c0 100644 --- a/slidenote/build/runner.py +++ b/slidenote/build/runner.py @@ -8,9 +8,10 @@ _friendly_build_error, ) from slidenote.build.errors import UserFacingConfigError -from slidenote.build.stages import BUILD_STAGES, _print_build_outputs +from slidenote.build.stages import BUILD_PHASES, _print_build_outputs from slidenote.build.state import create_build_state from slidenote.exporting import parse_export_formats +from slidenote.pipeline import run_build_plan def run_build(args: argparse.Namespace) -> int: @@ -23,8 +24,7 @@ def run_build(args: argparse.Namespace) -> int: state = create_build_state(args, export_formats) try: - for stage in BUILD_STAGES: - stage(state) + run_build_plan(state, BUILD_PHASES) except Exception as exc: friendly_message = _friendly_build_error(exc, args) if friendly_message: diff --git a/slidenote/build/stages.py b/slidenote/build/stages.py index a41811a..e799f4a 100644 --- a/slidenote/build/stages.py +++ b/slidenote/build/stages.py @@ -21,6 +21,7 @@ from slidenote.image_ranking import rank_deck_images from slidenote.ir import build_deck_ir from slidenote.models import Deck +from slidenote.pipeline import BuildPhase, BuildStep from slidenote.notes import NoteGenerationResult, NoteOptions, estimate_note_generation_steps, generate_notes_result from slidenote.notes.quality import build_note_quality_report from slidenote.ocr import enrich_deck_with_ocr @@ -31,7 +32,7 @@ from slidenote.composite_figures import enrich_deck_with_composite_figures from slidenote.figure_grounding import enrich_deck_with_figure_grounding from slidenote.figures import enrich_deck_with_figures -from slidenote.modality import enrich_deck_with_modalities +from slidenote.modality import apply_modality_overrides, enrich_deck_with_modalities from slidenote.semantic_layout import enrich_deck_with_semantic_layout from slidenote.vision import enrich_deck_with_vision @@ -46,13 +47,17 @@ def _stage_modality(state: BuildState) -> None: deck = _require_deck(state) state.modality_report = _run_json_stage( deck, - state.build_context, + state, name="modality", artifact_name="page_modalities", artifact_path="page_modalities.json", message="Classifying page modalities", complete_message="Page modality classification complete", - runner=lambda stage_deck: enrich_deck_with_modalities(stage_deck), + runner=lambda stage_deck: apply_modality_overrides( + stage_deck, + enrich_deck_with_modalities(stage_deck), + state.output_root / "page_modalities.overrides.json", + ), ) @@ -60,9 +65,8 @@ def _stage_table_understanding(state: BuildState) -> None: deck = _require_deck(state) state.table_understanding_report = _run_json_stage( deck, - state.build_context, + state, name="table_understanding", - dependencies=["modality"], artifact_name="table_understanding", artifact_path="table_understanding.json", message="Summarizing table conclusions", @@ -76,9 +80,8 @@ def _stage_semantic_layout(state: BuildState) -> None: deck = _require_deck(state) state.semantic_layout_report = _run_json_stage( deck, - state.build_context, + state, name="semantic_layout", - dependencies=["table_understanding"], artifact_name="semantic_layout", artifact_path="semantic_layout.json", message="Building semantic page blocks", @@ -105,8 +108,6 @@ def _stage_semantic_layout(state: BuildState) -> None: def _stage_composite_figures(state: BuildState) -> None: args = state.args - if args.composite_figures == "off": - return deck = _require_deck(state) state.progress.start_stage("composite_figures", message="Detecting composite figures") state.composite_figure_report = enrich_deck_with_composite_figures( @@ -120,9 +121,6 @@ def _stage_composite_figures(state: BuildState) -> None: def _stage_figure_crop(state: BuildState) -> None: args = state.args - should_run_figure_crop = args.figure_crop == "vision" or (args.figure_crop == "auto" and args.vision != "off") - if not should_run_figure_crop: - return deck = _require_deck(state) state.progress.start_stage("figure_crop", message="Cropping local figures") state.figure_report = enrich_deck_with_figures( @@ -152,8 +150,6 @@ def _stage_figure_crop(state: BuildState) -> None: def _stage_image_importance(state: BuildState) -> None: args = state.args - if args.image_ranking == "off": - return deck = _require_deck(state) state.progress.start_stage("image_importance", message="Ranking image importance") state.image_importance_report = rank_deck_images(deck, state.output_root, mode=args.image_ranking, stage="pre_vision") @@ -162,8 +158,6 @@ def _stage_image_importance(state: BuildState) -> None: def _stage_ocr(state: BuildState) -> None: args = state.args - if args.ocr == "off": - return deck = _require_deck(state) state.progress.start_stage("ocr", message="Running OCR") state.ocr_report = enrich_deck_with_ocr( @@ -190,9 +184,6 @@ def _stage_ocr(state: BuildState) -> None: def _stage_vision(state: BuildState) -> None: args = state.args - should_run_vision = args.vision != "off" or args.figure_grounding == "vision" - if not should_run_vision: - return deck = _require_deck(state) vision_mode = args.vision if args.vision != "off" else "auto" state.progress.start_stage("vision", message="Running vision analysis") @@ -223,8 +214,6 @@ def _stage_vision(state: BuildState) -> None: def _stage_figure_grounding(state: BuildState) -> None: args = state.args - if args.figure_grounding == "off": - return deck = _require_deck(state) state.progress.start_stage("figure_grounding", message="Grounding figures to page text") state.figure_grounding_report = enrich_deck_with_figure_grounding( @@ -274,8 +263,6 @@ def _stage_sections(state: BuildState) -> None: def _stage_deck_brief(state: BuildState) -> None: args = state.args - if not _should_build_deck_brief(args): - return deck = _require_deck(state) state.progress.start_stage("deck_brief", message="Building deck brief") state.deck_brief_report = build_deck_brief( @@ -298,8 +285,6 @@ def _stage_deck_brief(state: BuildState) -> None: def _stage_content_guard(state: BuildState) -> None: args = state.args - if args.content_guard == "off": - return deck = _require_deck(state) state.progress.start_stage("content_guard", message="Classifying required learning content") state.content_guard_report = build_content_guard( @@ -341,7 +326,6 @@ def _stage_export_content(state: BuildState) -> None: deck = _require_deck(state) state.progress.start_stage("export_content", message="Writing structured content") state.artifacts.write_json("content", "content.json", deck.to_dict()) - state.artifacts.write_json("element_ir", "element_ir.json", build_deck_ir(deck, content_guard=state.content_guard_report)) if state.image_importance_report is not None: state.artifacts.write_json("image_importance", "image_importance.json", state.image_importance_report) if state.figure_report is not None: @@ -465,8 +449,6 @@ def _stage_quality_report(state: BuildState) -> None: def _stage_export(state: BuildState) -> None: args = state.args - if not state.export_formats: - return state.progress.start_stage("export", message="Exporting requested note formats") state.export_report = build_export_artifacts(state.notes_markdown, state.output_root, state.export_formats, export_toc=args.export_toc) if state.export_report is not None: @@ -482,6 +464,7 @@ def _stage_run_summary(state: BuildState) -> None: notes_result = _require_notes_result(state) coverage_report = _require_report(state.coverage_report, "coverage") source_map = _require_report(state.source_map, "source_map") + state.progress.set_phase(None) state.progress.complete("Build complete") state.artifacts.register("run_summary", state.output_root / "run_summary.json") run_summary = _build_run_summary( @@ -596,25 +579,35 @@ def _require_report(report: dict[str, Any] | None, name: str) -> dict[str, Any]: return report -BUILD_STAGES = ( - _stage_parse, - _stage_modality, - _stage_table_understanding, - _stage_semantic_layout, - _stage_composite_figures, - _stage_figure_crop, - _stage_image_importance, - _stage_ocr, - _stage_vision, - _stage_figure_grounding, - _stage_sections, - _stage_deck_brief, - _stage_content_guard, - _stage_understanding, - _stage_export_content, - _stage_notes, - _stage_coverage, - _stage_quality_report, - _stage_export, - _stage_run_summary, +BUILD_PHASES = ( + BuildPhase("ingest", ( + BuildStep("parse", _stage_parse), + )), + BuildPhase("understand", ( + BuildStep("modality", _stage_modality), + BuildStep("table_understanding", _stage_table_understanding), + BuildStep("semantic_layout", _stage_semantic_layout), + BuildStep("composite_figures", _stage_composite_figures, lambda state: state.args.composite_figures != "off"), + BuildStep("figure_crop", _stage_figure_crop, lambda state: state.args.figure_crop == "vision" or (state.args.figure_crop == "auto" and state.args.vision != "off")), + BuildStep("image_importance", _stage_image_importance, lambda state: state.args.image_ranking != "off"), + BuildStep("ocr", _stage_ocr, lambda state: state.args.ocr != "off"), + BuildStep("vision", _stage_vision, lambda state: state.args.vision != "off" or state.args.figure_grounding == "vision"), + BuildStep("figure_grounding", _stage_figure_grounding, lambda state: state.args.figure_grounding != "off"), + BuildStep("sections", _stage_sections), + BuildStep("deck_brief", _stage_deck_brief, lambda state: _should_build_deck_brief(state.args)), + BuildStep("content_guard", _stage_content_guard, lambda state: state.args.content_guard != "off"), + BuildStep("understanding", _stage_understanding), + BuildStep("export_content", _stage_export_content), + )), + BuildPhase("write", ( + BuildStep("notes", _stage_notes), + )), + BuildPhase("guard", ( + BuildStep("coverage", _stage_coverage), + BuildStep("quality_report", _stage_quality_report), + )), + BuildPhase("export", ( + BuildStep("export", _stage_export, lambda state: bool(state.export_formats)), + BuildStep("run_summary", _stage_run_summary, tracks_progress=False), + )), ) diff --git a/slidenote/build/state.py b/slidenote/build/state.py index 652b682..61823c4 100644 --- a/slidenote/build/state.py +++ b/slidenote/build/state.py @@ -8,7 +8,7 @@ from slidenote.build.config import _parse_slide_ranges, _resolve_api_concurrency, _resolve_cache_dirs from slidenote.models import Deck from slidenote.notes import NoteGenerationResult -from slidenote.pipeline import ArtifactRegistry, BuildContext +from slidenote.pipeline import ArtifactRegistry from slidenote.progress import ProgressReporter from slidenote.utils import ensure_clean_dir @@ -24,7 +24,6 @@ class BuildState: api_concurrency: dict[str, int] cache_dirs: dict[str, Path | None] artifacts: ArtifactRegistry - build_context: BuildContext export_formats: list[str] deck: Deck | None = None modality_report: dict[str, Any] | None = None @@ -64,16 +63,6 @@ def create_build_state(args: argparse.Namespace, export_formats: list[str]) -> B cache_dirs = _resolve_cache_dirs(args, output_root) artifacts = ArtifactRegistry(output_root) artifacts.register("progress", progress.path) - build_context = BuildContext( - args=args, - input_path=input_path, - output_root=output_root, - progress=progress, - cache_dirs=cache_dirs, - refresh_slide_ids=refresh_slide_ids, - concurrency=concurrency, - artifacts=artifacts, - ) return BuildState( args=args, input_path=input_path, @@ -84,6 +73,5 @@ def create_build_state(args: argparse.Namespace, export_formats: list[str]) -> B api_concurrency=api_concurrency, cache_dirs=cache_dirs, artifacts=artifacts, - build_context=build_context, export_formats=export_formats, ) diff --git a/slidenote/figures.py b/slidenote/figures.py index e057894..29e5f3a 100644 --- a/slidenote/figures.py +++ b/slidenote/figures.py @@ -11,7 +11,7 @@ from slidenote.image_assets import image_metadata from slidenote.llm import LLMClient, resolve_provider_runtime from slidenote.llm_cache import LLM_CACHE_SCHEMA_VERSION, LLMCache, make_cache_key, sha256_text, utc_now_iso -from slidenote.modality import page_has_hint +from slidenote.modality import page_has_hint, page_has_manual_modality from slidenote.models import Deck, ImageAsset, SlidePage, normalize_rel_path from slidenote.utils import ( as_float, @@ -161,6 +161,8 @@ def select_figure_targets(deck: Deck, max_targets: int = 80) -> list[FigureTarge for page in deck.pages: if not page.page_screenshot: continue + if page_has_manual_modality(page) and not page_has_hint(page, "crop_figures_from_screenshot"): + continue if _page_has_content_images(page): continue if page_has_hint(page, "crop_figures_from_screenshot"): diff --git a/slidenote/modality.py b/slidenote/modality.py index 6c735a2..e3d1996 100644 --- a/slidenote/modality.py +++ b/slidenote/modality.py @@ -1,13 +1,18 @@ from __future__ import annotations +import hashlib +import json +import os from collections import Counter from dataclasses import dataclass +from pathlib import Path from typing import Any from slidenote.llm_cache import utc_now_iso from slidenote.models import Deck, SlidePage PAGE_MODALITIES = {"native_text", "mixed", "image_only", "shape_diagram", "decorative"} +OVERRIDE_MODALITIES = PAGE_MODALITIES | {"unknown"} @dataclass(frozen=True, slots=True) @@ -30,6 +35,81 @@ def enrich_deck_with_modalities(deck: Deck) -> dict[str, Any]: return build_modality_report(deck, results) +def apply_modality_overrides(deck: Deck, report: dict[str, Any], manifest_path: Path | str) -> dict[str, Any]: + """Apply reviewer page labels after local classification, before visual processing. + + The GUI's version 1 manifest stores overrides under string slide IDs. Invalid or + stale entries are reported and ignored so a handwritten manifest cannot break a + build. A source hash, when provided, takes precedence over the upload path. + """ + path = Path(manifest_path) + if not path.is_file(): + return report + + warnings: list[str] = [] + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + warnings.append(f"Could not read modality override manifest: {exc}") + return _record_override_result(report, path, [], warnings) + + if not isinstance(manifest, dict) or type(manifest.get("schema_version")) is not int or manifest["schema_version"] != 1: + warnings.append("Modality override manifest must be a version 1 JSON object.") + return _record_override_result(report, path, [], warnings) + if not _manifest_matches_source(manifest, deck.source_path, warnings): + return _record_override_result(report, path, [], warnings) + + overrides = manifest.get("pages") + if not isinstance(overrides, dict): + warnings.append("Modality override manifest.pages must be an object keyed by slide ID.") + return _record_override_result(report, path, [], warnings) + + pages_by_id = {page.slide_id: page for page in deck.pages} + report_pages = { + item.get("slide_id"): item + for item in report.get("pages", []) + if isinstance(item, dict) + } + applied: list[dict[str, Any]] = [] + for raw_slide_id, entry in overrides.items(): + if not isinstance(raw_slide_id, str) or not raw_slide_id.isdecimal() or int(raw_slide_id) < 1 or str(int(raw_slide_id)) != raw_slide_id: + warnings.append(f"Invalid modality override slide ID: {raw_slide_id!r}.") + continue + slide_id = int(raw_slide_id) + page = pages_by_id.get(slide_id) + report_page = report_pages.get(slide_id) + if page is None or report_page is None: + warnings.append(f"Modality override slide {slide_id} is not in this deck.") + continue + if not isinstance(entry, dict) or not isinstance(entry.get("modality"), str) or entry["modality"] not in OVERRIDE_MODALITIES: + warnings.append(f"Invalid modality override for slide {slide_id}.") + continue + + modality = entry["modality"] + previous_modality = page.page_modality + note = entry.get("note") if isinstance(entry.get("note"), str) else "" + updated_at = entry.get("updated_at") if isinstance(entry.get("updated_at"), str) else None + hints = list(page.processing_hints) if modality == "unknown" else _override_processing_hints(page, modality) + confidence = 0.0 if modality == "unknown" else 1.0 + page.page_modality = modality + page.modality_confidence = confidence + page.modality_reasons = ["manual_override"] + page.processing_hints = hints + report_page.update( + modality=modality, + confidence=confidence, + reasons=list(page.modality_reasons), + processing_hints=list(hints), + classifier_modality=previous_modality, + manual_override={"note": note, "updated_at": updated_at}, + ) + applied.append({"slide_id": slide_id, "modality": modality, "previous_modality": previous_modality}) + + if applied: + report["summary"] = _modality_summary(report["pages"]) + return _record_override_result(report, path, applied, warnings) + + def classify_page_modality(page: SlidePage) -> PageModalityResult: stats = _page_stats(page) text_len = int(stats["text_chars"]) @@ -83,33 +163,24 @@ def classify_page_modality(page: SlidePage) -> PageModalityResult: def build_modality_report(deck: Deck, results: list[PageModalityResult]) -> dict[str, Any]: - counts = Counter(result.modality for result in results) + pages = [ + { + "slide_id": result.slide_id, + "modality": result.modality, + "confidence": result.confidence, + "reasons": result.reasons, + "processing_hints": result.processing_hints, + "stats": result.stats, + } + for result in results + ] return { "schema_version": 1, "generated_at": utc_now_iso(), "source_path": deck.source_path, "source_type": deck.source_type, - "summary": { - "pages_total": len(results), - "modalities": dict(sorted(counts.items())), - "image_driven_pages": sum(1 for result in results if result.modality in {"image_only", "shape_diagram"}), - "embedded_image_pages": sum(1 for result in results if "use_embedded_images" in result.processing_hints), - "ocr_recommended_pages": sum(1 for result in results if "ocr_page_screenshot" in result.processing_hints), - "figure_crop_recommended_pages": sum( - 1 for result in results if "crop_figures_from_screenshot" in result.processing_hints - ), - }, - "pages": [ - { - "slide_id": result.slide_id, - "modality": result.modality, - "confidence": result.confidence, - "reasons": result.reasons, - "processing_hints": result.processing_hints, - "stats": result.stats, - } - for result in results - ], + "summary": _modality_summary(pages), + "pages": pages, } @@ -117,6 +188,101 @@ def page_has_hint(page: SlidePage, hint: str) -> bool: return hint in (page.processing_hints or []) +def page_has_manual_modality(page: SlidePage) -> bool: + return page.page_modality != "unknown" and "manual_override" in (page.modality_reasons or []) + + +def _override_processing_hints(page: SlidePage, modality: str) -> list[str]: + has_text = bool(page.text_blocks or page.tables) + has_images = any(not image.ignored and image.role != "page_image" for image in page.images) + has_ocr_images = any(not image.ignored for image in page.images) + has_screenshot = bool(page.page_screenshot) + if modality == "native_text": + return ["use_extracted_text"] + if modality == "mixed": + hints = ["use_extracted_text"] if has_text else [] + if has_images: + hints.extend(["use_embedded_images", "vision_large_images"]) + elif has_screenshot: + hints.append("vision_page_screenshot") + if has_screenshot and sum(len(block.content.strip()) for block in page.text_blocks) < 80: + hints.append("ocr_page_screenshot") + return hints + if modality == "image_only": + hints = ["ocr_page_screenshot"] if has_screenshot or has_ocr_images else [] + if has_screenshot: + hints.extend(["crop_figures_from_screenshot", "vision_page_screenshot"]) + if has_images: + hints.extend(["use_embedded_images", "vision_large_images"]) + return hints + if modality == "shape_diagram": + hints = ["use_extracted_text"] if has_text else [] + if has_screenshot: + hints.extend(["crop_figures_from_screenshot", "vision_page_screenshot"]) + if has_images: + hints.extend(["use_embedded_images", "vision_large_images"]) + return hints + if modality == "decorative": + return ["low_priority"] + return [] + + +def _modality_summary(pages: list[dict[str, Any]]) -> dict[str, Any]: + counts = Counter(str(page["modality"]) for page in pages) + return { + "pages_total": len(pages), + "modalities": dict(sorted(counts.items())), + "image_driven_pages": sum(1 for page in pages if page["modality"] in {"image_only", "shape_diagram"}), + "embedded_image_pages": sum(1 for page in pages if "use_embedded_images" in page["processing_hints"]), + "ocr_recommended_pages": sum(1 for page in pages if "ocr_page_screenshot" in page["processing_hints"]), + "figure_crop_recommended_pages": sum(1 for page in pages if "crop_figures_from_screenshot" in page["processing_hints"]), + } + + +def _record_override_result( + report: dict[str, Any], path: Path, applied: list[dict[str, Any]], warnings: list[str] +) -> dict[str, Any]: + report.setdefault("summary", {})["override_pages"] = len(applied) + report["overrides"] = { + "manifest_path": str(path), + "applied": applied, + "warnings": warnings, + } + return report + + +def _manifest_matches_source(manifest: dict[str, Any], source_path: str, warnings: list[str]) -> bool: + source_hash = manifest.get("source_sha256") + if source_hash is not None: + if not isinstance(source_hash, str) or len(source_hash) != 64 or any(char not in "0123456789abcdefABCDEF" for char in source_hash): + warnings.append("Modality override source_sha256 must be a 64-character hex digest.") + return False + try: + digest = hashlib.sha256() + with Path(source_path).open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + warnings.append(f"Could not verify modality override source: {exc}") + return False + if digest.hexdigest() != source_hash.lower(): + warnings.append("Modality override source hash does not match this deck.") + return False + return True + + manifest_source_path = manifest.get("source_path") + if manifest_source_path is not None: + if not isinstance(manifest_source_path, str) or not manifest_source_path: + warnings.append("Modality override source_path must be a non-empty string.") + return False + expected = os.path.normcase(os.path.normpath(str(Path(manifest_source_path).resolve()))) + actual = os.path.normcase(os.path.normpath(str(Path(source_path).resolve()))) + if expected != actual: + warnings.append("Modality override source path does not match this deck.") + return False + return True + + def _page_stats(page: SlidePage) -> dict[str, Any]: text_chars = sum(len(block.content.strip()) for block in page.text_blocks) content_images = sum(1 for image in page.images if not image.ignored and image.role != "page_image") diff --git a/slidenote/notes/lecture_weave.py b/slidenote/notes/lecture_weave.py index be1c89f..18d855c 100644 --- a/slidenote/notes/lecture_weave.py +++ b/slidenote/notes/lecture_weave.py @@ -22,7 +22,7 @@ _select_note_contexts, ) from .llm_calls import _generate_page_lecture_context, _generate_teaching_enrichment_context, _generate_weave_context -from .options import should_run_teaching_enrichment +from .options import needs_teaching_enrichment, should_run_teaching_enrichment from .prompt_payload import _section_title_by_slide from .repair import _repair_required_markdown_once from .usage import _build_usage_report @@ -184,7 +184,18 @@ def process_weave(context: NoteContext) -> tuple[str, str, dict[str, Any]]: teaching_records: list[dict[str, Any]] = [] teaching_report: dict[str, Any] | None = None - if should_run_teaching_enrichment(note_profile, teaching_enrichment, "lecture-weave"): + teaching_contexts = ( + [ + context for context in weave_contexts + if teaching_enrichment == "force" + or needs_teaching_enrichment(final_chunks.get(context.id, ""), len(context.pages)) + ] + if should_run_teaching_enrichment(note_profile, teaching_enrichment, "lecture-weave") + else [] + ) + if progress_callback and teaching_enrichment == "auto": + progress_callback({"event": "total", "total": len(page_contexts) + len(weave_contexts) + len(teaching_contexts)}) + if teaching_contexts: teaching_results: dict[str, tuple[str, dict[str, Any]]] = {} def process_teaching(context: NoteContext) -> tuple[str, str, dict[str, Any]]: @@ -205,21 +216,21 @@ def process_teaching(context: NoteContext) -> tuple[str, str, dict[str, Any]]: return context.id, _postprocess_llm_markdown(content, source_display=source_display), record if workers == 1: - for context in weave_contexts: + for context in teaching_contexts: context_id, content, record = process_teaching(context) teaching_results[context_id] = (content, record) if progress_callback: progress_callback(record) else: with ThreadPoolExecutor(max_workers=workers) as executor: - futures = {executor.submit(process_teaching, context): context for context in weave_contexts} + futures = {executor.submit(process_teaching, context): context for context in teaching_contexts} for future in as_completed(futures): context_id, content, record = future.result() teaching_results[context_id] = (content, record) if progress_callback: progress_callback(record) - for context in weave_contexts: + for context in teaching_contexts: content, record = teaching_results[context.id] final_chunks[context.id] = content teaching_records.append(record) @@ -300,7 +311,7 @@ def process_teaching(context: NoteContext) -> tuple[str, str, dict[str, Any]]: note_depth=note_depth, note_language=note_language, term_policy=term_policy, - contexts=weave_contexts, + contexts=teaching_contexts, final_chunks=final_chunks, page_markdown_by_slide=page_markdown_by_slide, teaching_records=teaching_records, diff --git a/slidenote/notes/options.py b/slidenote/notes/options.py index 77b5bbe..a64a7d1 100644 --- a/slidenote/notes/options.py +++ b/slidenote/notes/options.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re + NOTE_PROFILES = {"auto", "lecture-notes", "study-guide"} TEACHING_ENRICHMENT_MODES = {"auto", "off", "force"} @@ -20,3 +22,17 @@ def should_run_teaching_enrichment(note_profile: str, teaching_enrichment: str, if teaching_enrichment == "force": return True return note_profile in {"lecture-notes", "study-guide"} + + +def needs_teaching_enrichment(markdown: str, page_count: int) -> bool: + """Skip the extra model pass when the woven draft already has teaching signals.""" + visible = re.sub(r"", "", markdown, flags=re.DOTALL) + body_chars = sum(char.isalnum() for char in visible) + if body_chars < max(100, 60 * page_count): + return True + teaching_signals = ( + r"例如|例子|比如|类比|for example|e\.g\.|analogy", + r"易错|误解|陷阱|常见错误|pitfall|misconception|common mistake", + r"自测|思考题|练习题|检查自己|self[- ]?check|quiz|review question", + ) + return not all(re.search(pattern, visible, flags=re.IGNORECASE) for pattern in teaching_signals) diff --git a/slidenote/ocr.py b/slidenote/ocr.py index f30c0a7..1933508 100644 --- a/slidenote/ocr.py +++ b/slidenote/ocr.py @@ -15,7 +15,7 @@ from slidenote.api_retry import with_api_retries from slidenote.llm_cache import LLMCache, make_cache_key, utc_now_iso -from slidenote.modality import page_has_hint +from slidenote.modality import page_has_hint, page_has_manual_modality from slidenote.models import Deck, SlidePage from slidenote.utils import ( cleanup_temp_image, @@ -319,7 +319,9 @@ def select_ocr_targets( targets: list[OCRTarget] = [] for page in deck.pages: text_len = sum(len(block.content.strip()) for block in page.text_blocks) - needs_page_ocr = page_has_hint(page, "ocr_page_screenshot") or text_len < min_text_chars or bool(page.warnings) + needs_page_ocr = page_has_hint(page, "ocr_page_screenshot") + if not page_has_manual_modality(page): + needs_page_ocr = needs_page_ocr or text_len < min_text_chars or bool(page.warnings) if mode == "all" or needs_page_ocr: if page.page_screenshot: reason = "all_page_screenshot" if mode == "all" else page.page_modality or "low_extracted_text" diff --git a/slidenote/pipeline.py b/slidenote/pipeline.py index 6457403..34ce5e0 100644 --- a/slidenote/pipeline.py +++ b/slidenote/pipeline.py @@ -1,56 +1,43 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable, Protocol +from typing import Any, Callable -from slidenote.models import Deck from slidenote.utils import display_path, write_json, write_text -@dataclass(slots=True) -class StageResult: +@dataclass(frozen=True, slots=True) +class BuildStep: name: str - status: str = "ok" - report: dict[str, Any] | None = None - artifacts: dict[str, str] = field(default_factory=dict) - warnings: list[str] = field(default_factory=list) - - -@dataclass(slots=True) -class BuildContext: - args: Any - input_path: Path - output_root: Path - progress: Any - cache_dirs: dict[str, Path | None] = field(default_factory=dict) - refresh_slide_ids: set[int] = field(default_factory=set) - concurrency: int = 1 - artifacts: "ArtifactRegistry" | None = None - reports: dict[str, StageResult] = field(default_factory=dict) - - -class Stage(Protocol): - name: str - dependencies: list[str] - artifacts: list[str] - - def run(self, deck: Deck, context: BuildContext) -> StageResult: - ... + runner: Callable[[Any], None] + enabled: Callable[[Any], bool] | None = None + tracks_progress: bool = True -@dataclass(slots=True) -class FunctionStage: +@dataclass(frozen=True, slots=True) +class BuildPhase: name: str - runner: Callable[[Deck, BuildContext], StageResult | dict[str, Any] | None] - dependencies: list[str] = field(default_factory=list) - artifacts: list[str] = field(default_factory=list) - - def run(self, deck: Deck, context: BuildContext) -> StageResult: - result = self.runner(deck, context) - if isinstance(result, StageResult): - return result - return StageResult(name=self.name, report=result) + steps: tuple[BuildStep, ...] + + +def run_build_plan(state: Any, phases: tuple[BuildPhase, ...]) -> None: + """Run one explicit plan; disabled steps never enter progress accounting.""" + planned = [ + (phase.name, step) + for phase in phases + for step in phase.steps + if step.enabled is None or step.enabled(state) + ] + state.progress.set_plan([step.name for _, step in planned if step.tracks_progress]) + for phase in phases: + phase_steps = [step for phase_name, step in planned if phase_name == phase.name] + if not phase_steps: + continue + state.progress.set_phase(phase.name) + for step in phase_steps: + step.runner(state) + state.progress.set_phase(None) class ArtifactRegistry: @@ -84,12 +71,3 @@ def relative_path(self, name: str) -> str | None: def as_summary(self) -> dict[str, str]: return dict(sorted(self._artifacts.items())) - - -def run_stage(deck: Deck, context: BuildContext, stage: Stage) -> StageResult: - for dependency in stage.dependencies: - if dependency not in context.reports: - raise RuntimeError(f"Stage `{stage.name}` depends on missing stage `{dependency}`.") - result = stage.run(deck, context) - context.reports[stage.name] = result - return result diff --git a/slidenote/progress.py b/slidenote/progress.py index dfd69fa..31dd762 100644 --- a/slidenote/progress.py +++ b/slidenote/progress.py @@ -31,10 +31,20 @@ def __init__(self, path: Path, quiet: bool = False) -> None: self._stage_started = self._run_started self.current_stage: StageRecord | None = None self.stages: list[StageRecord] = [] + self.current_phase: str | None = None + self.planned_stages: list[str] = [] self.status = "running" self.message = "" self.write() + def set_plan(self, stage_names: list[str]) -> None: + self.planned_stages = list(stage_names) + self.write() + + def set_phase(self, phase: str | None) -> None: + self.current_phase = phase + self.write() + def start_stage(self, name: str, total: int | None = None, message: str | None = None) -> None: self._stage_started = time.perf_counter() self.current_stage = StageRecord(name=name, started_at=utc_now_iso(), total=total) @@ -104,6 +114,8 @@ def snapshot(self) -> dict[str, Any]: "updated_at": utc_now_iso(), "elapsed_seconds": round(elapsed, 3), "current_stage": current, + "current_phase": self.current_phase, + "planned_stages": list(self.planned_stages), "stages": [_stage_to_dict(stage) for stage in self.stages], } diff --git a/slidenote/semantic_layout.py b/slidenote/semantic_layout.py index 85b30e2..ae9dd30 100644 --- a/slidenote/semantic_layout.py +++ b/slidenote/semantic_layout.py @@ -8,7 +8,7 @@ from slidenote.llm import LLMClient, resolve_provider_runtime from slidenote.llm_cache import LLM_CACHE_SCHEMA_VERSION, LLMCache, make_cache_key, sha256_text, utc_now_iso -from slidenote.modality import page_has_hint +from slidenote.modality import page_has_hint, page_has_manual_modality from slidenote.models import Deck, ImageAsset, SlidePage, TableBlock, TextBlock from slidenote.table_understanding import table_preview from slidenote.utils import ( @@ -584,6 +584,8 @@ def _validated_vision_layout( def _page_needs_vision_enhancement(page: SlidePage, local_result: dict[str, Any]) -> bool: if not page.page_screenshot: return False + if page_has_manual_modality(page) and page.page_modality in {"native_text", "decorative"}: + return False if page_has_hint(page, "vision_page_screenshot") or page_has_hint(page, "crop_figures_from_screenshot"): return True if page.page_modality in {"mixed", "image_only", "shape_diagram"}: diff --git a/slidenote/vision.py b/slidenote/vision.py index dd9345e..b0c044f 100644 --- a/slidenote/vision.py +++ b/slidenote/vision.py @@ -9,7 +9,7 @@ from slidenote.image_ranking import sorted_images_by_importance from slidenote.llm import LLMClient, resolve_provider_runtime from slidenote.llm_cache import LLM_CACHE_SCHEMA_VERSION, LLMCache, make_cache_key, sha256_text, utc_now_iso -from slidenote.modality import page_has_hint +from slidenote.modality import page_has_hint, page_has_manual_modality from slidenote.models import Deck, SlidePage from slidenote.table_understanding import table_preview from slidenote.utils import ( @@ -251,6 +251,12 @@ def select_vision_targets( targets: list[VisionTarget] = [] for page in deck.pages: if mode == "auto": + if page_has_manual_modality(page): + if page.page_screenshot and page_has_hint(page, "vision_page_screenshot"): + targets.append(VisionTarget(page.slide_id, "page_screenshot", page.page_screenshot, reason="manual_modality")) + elif page_has_hint(page, "vision_large_images"): + targets.extend(_large_image_targets(page, output_root, min_area=min_area, first_only=True)) + continue figure_targets = _role_image_targets(page, output_root, role="figure_crop", min_area=0, first_only=False) if figure_targets: targets.extend(figure_targets) diff --git a/tests/test_cli.py b/tests/test_cli.py index 0a4b04e..6a5286e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import hashlib import json import zipfile from argparse import Namespace @@ -72,6 +73,9 @@ def test_build_writes_progress_and_run_summary(tmp_path): composite_figures = json.loads((out / "composite_figures.json").read_text(encoding="utf-8")) figure_grounding = json.loads((out / "figure_grounding.json").read_text(encoding="utf-8")) assert progress["status"] == "complete" + assert progress["planned_stages"][0] == "parse" + assert "ocr" not in progress["planned_stages"] + assert "vision" not in progress["planned_stages"] assert run_summary["counts"]["pages"] == 1 assert run_summary["artifacts"]["progress"] == "progress.json" assert run_summary["artifacts"]["source_map"] == "source_map.json" @@ -138,6 +142,30 @@ def test_build_writes_progress_and_run_summary(tmp_path): assert not (out / "export_report.json").exists() +def test_build_applies_saved_page_modality_correction(tmp_path): + source = tmp_path / "lecture.pdf" + doc = fitz.open() + page = doc.new_page() + page.insert_text((72, 72), "A short native-text page") + doc.save(source) + doc.close() + out = tmp_path / "out" + out.mkdir() + (out / "page_modalities.overrides.json").write_text(json.dumps({ + "schema_version": 1, + "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(), + "pages": {"1": {"modality": "image_only", "note": "Check screenshot"}}, + }), encoding="utf-8") + + assert main(["build", str(source), "--out", str(out), "--quiet", "--preset", "local"]) == 0 + + modalities = json.loads((out / "page_modalities.json").read_text(encoding="utf-8")) + content = json.loads((out / "content.json").read_text(encoding="utf-8")) + assert modalities["summary"]["override_pages"] == 1 + assert modalities["pages"][0]["modality"] == "image_only" + assert content["pages"][0]["page_modality"] == "image_only" + + def test_internal_quality_concurrency_is_wired_to_build_stages(tmp_path, monkeypatch): source = tmp_path / "lecture.pdf" doc = fitz.open() diff --git a/tests/test_modality.py b/tests/test_modality.py index 197b6d4..4230bbe 100644 --- a/tests/test_modality.py +++ b/tests/test_modality.py @@ -1,5 +1,11 @@ -from slidenote.modality import classify_page_modality, enrich_deck_with_modalities +import hashlib +import json + +from slidenote.figures import select_figure_targets +from slidenote.modality import apply_modality_overrides, classify_page_modality, enrich_deck_with_modalities from slidenote.models import Deck, ImageAsset, SlidePage, TableBlock, TextBlock +from slidenote.ocr import select_ocr_targets +from slidenote.vision import select_vision_targets def test_modality_detects_mixed_page_with_embedded_content_image(): @@ -63,3 +69,143 @@ def test_enrich_deck_with_modalities_writes_page_fields_and_report(): assert deck.pages[1].page_modality == "image_only" assert report["summary"]["pages_total"] == 2 assert report["summary"]["ocr_recommended_pages"] == 1 + + +def test_manual_image_only_override_updates_report_and_visual_routing(tmp_path): + original_source = tmp_path / "first-upload.pdf" + new_source = tmp_path / "second-upload.pdf" + original_source.write_bytes(b"same deck bytes") + new_source.write_bytes(original_source.read_bytes()) + page = SlidePage( + slide_id=1, + text_blocks=[TextBlock(id="s1_t1", type="paragraph", content="A" * 1000)], + page_screenshot="screenshots/slide1.png", + ) + deck = Deck(source_path=str(new_source), source_type="pdf", pages=[page]) + report = enrich_deck_with_modalities(deck) + assert page.page_modality == "native_text" + + manifest_path = tmp_path / "page_modalities.overrides.json" + manifest_path.write_text(json.dumps({ + "schema_version": 1, + "source_path": str(original_source), + "source_sha256": hashlib.sha256(original_source.read_bytes()).hexdigest(), + "pages": {"1": {"modality": "image_only", "note": "Scanned screenshot", "updated_at": "2026-09-23T10:00:00Z"}}, + }), encoding="utf-8") + + updated = apply_modality_overrides(deck, report, manifest_path) + + assert updated is report + assert page.page_modality == "image_only" + assert page.modality_reasons == ["manual_override"] + assert {"ocr_page_screenshot", "vision_page_screenshot", "crop_figures_from_screenshot"} <= set(page.processing_hints) + assert report["summary"]["modalities"] == {"image_only": 1} + assert report["summary"]["ocr_recommended_pages"] == 1 + assert report["summary"]["override_pages"] == 1 + assert report["pages"][0]["classifier_modality"] == "native_text" + assert report["pages"][0]["manual_override"]["note"] == "Scanned screenshot" + assert [target.slide_id for target in select_ocr_targets(deck, tmp_path, mode="auto")] == [1] + assert [target.slide_id for target in select_vision_targets(deck, tmp_path, mode="auto")] == [1] + assert [target.slide_id for target in select_figure_targets(deck)] == [1] + + +def test_manual_native_text_override_suppresses_auto_visual_routing(tmp_path): + page = SlidePage(slide_id=1, page_screenshot="screenshots/slide1.png") + deck = Deck(source_path=str(tmp_path / "lecture.pdf"), source_type="pdf", pages=[page]) + report = enrich_deck_with_modalities(deck) + assert page.page_modality == "image_only" + manifest_path = tmp_path / "page_modalities.overrides.json" + manifest_path.write_text(json.dumps({ + "schema_version": 1, + "pages": {"1": {"modality": "native_text", "note": "Reviewed page"}}, + }), encoding="utf-8") + + apply_modality_overrides(deck, report, manifest_path) + + assert page.page_modality == "native_text" + assert select_ocr_targets(deck, tmp_path, mode="auto") == [] + assert select_vision_targets(deck, tmp_path, mode="auto") == [] + assert select_figure_targets(deck) == [] + assert report["pages"][0]["classifier_modality"] == "image_only" + assert report["pages"][0]["manual_override"]["note"] == "Reviewed page" + + +def test_manual_image_only_override_uses_embedded_image_when_no_screenshot(tmp_path): + page = SlidePage( + slide_id=1, + text_blocks=[TextBlock(id="s1_t1", type="paragraph", content="A" * 1000)], + images=[ImageAsset(id="s1_img1", path="images/diagram.png")], + ) + deck = Deck(source_path=str(tmp_path / "lecture.pptx"), source_type="pptx", pages=[page]) + report = enrich_deck_with_modalities(deck) + manifest_path = tmp_path / "page_modalities.overrides.json" + manifest_path.write_text(json.dumps({ + "schema_version": 1, + "pages": {"1": {"modality": "image_only"}}, + }), encoding="utf-8") + + apply_modality_overrides(deck, report, manifest_path) + + assert "ocr_page_screenshot" in page.processing_hints + assert [target.image_id for target in select_ocr_targets(deck, tmp_path, mode="auto")] == ["s1_img1"] + assert [target.slide_id for target in select_vision_targets(deck, tmp_path, mode="auto")] == [1] + + +def test_manual_unknown_preserves_auto_visual_routing(tmp_path): + page = SlidePage(slide_id=1, page_screenshot="screenshots/slide1.png") + deck = Deck(source_path=str(tmp_path / "lecture.pdf"), source_type="pdf", pages=[page]) + report = enrich_deck_with_modalities(deck) + original_hints = list(page.processing_hints) + manifest_path = tmp_path / "page_modalities.overrides.json" + manifest_path.write_text(json.dumps({ + "schema_version": 1, + "pages": {"1": {"modality": "unknown"}}, + }), encoding="utf-8") + + apply_modality_overrides(deck, report, manifest_path) + + assert page.page_modality == "unknown" + assert page.modality_confidence == 0.0 + assert page.processing_hints == original_hints + assert [target.slide_id for target in select_ocr_targets(deck, tmp_path, mode="auto")] == [1] + assert [target.slide_id for target in select_vision_targets(deck, tmp_path, mode="auto")] == [1] + assert [target.slide_id for target in select_figure_targets(deck)] == [1] + + +def test_manual_override_skips_invalid_pages_and_modalities(tmp_path): + deck = Deck(source_path="lecture.pdf", source_type="pdf", pages=[SlidePage(slide_id=1, page_screenshot="s1.png")]) + report = enrich_deck_with_modalities(deck) + manifest_path = tmp_path / "page_modalities.overrides.json" + manifest_path.write_text(json.dumps({ + "schema_version": 1, + "pages": { + "0": {"modality": "decorative"}, + "2": {"modality": "decorative"}, + "1": {"modality": []}, + }, + }), encoding="utf-8") + + apply_modality_overrides(deck, report, manifest_path) + + assert deck.pages[0].page_modality == "image_only" + assert report["summary"]["override_pages"] == 0 + assert len(report["overrides"]["warnings"]) == 3 + + +def test_manual_override_rejects_mismatched_source_hash(tmp_path): + source = tmp_path / "deck.pdf" + source.write_bytes(b"current deck") + deck = Deck(source_path=str(source), source_type="pdf", pages=[SlidePage(slide_id=1)]) + report = enrich_deck_with_modalities(deck) + manifest_path = tmp_path / "page_modalities.overrides.json" + manifest_path.write_text(json.dumps({ + "schema_version": 1, + "source_sha256": hashlib.sha256(b"different deck").hexdigest(), + "pages": {"1": {"modality": "image_only"}}, + }), encoding="utf-8") + + apply_modality_overrides(deck, report, manifest_path) + + assert deck.pages[0].page_modality == "decorative" + assert report["summary"]["override_pages"] == 0 + assert "does not match" in report["overrides"]["warnings"][0] diff --git a/tests/test_notes.py b/tests/test_notes.py index c4d964f..ca8e494 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -997,6 +997,58 @@ class Result: assert analyze_coverage(deck, result.markdown)["missing"] == 0 +def test_lecture_auto_skips_teaching_pass_when_weave_already_has_teaching_content(tmp_path, monkeypatch): + deck = Deck( + source_path="lecture.pdf", + source_type="pdf", + pages=[SlidePage(slide_id=1, title="Quorum", text_blocks=[TextBlock(id="s1_t1", type="paragraph", content="Read and write quorums overlap.")])], + ) + tasks = [] + + class FakeClient: + def __init__(self, **kwargs): + pass + + def generate_with_usage(self, prompt): + class Result: + usage = {"input_tokens": 3, "output_tokens": 4, "total_tokens": 7} + + result = Result() + if '"task": "page_lecture"' in prompt: + tasks.append("page") + result.text = "Read and write quorums overlap. " + elif '"task": "weave_page_lectures"' in prompt: + tasks.append("weave") + result.text = ( + "A read quorum intersects a write quorum, so a read can observe the latest write. " + "For example, three replicas can use overlapping sets to preserve visibility. " + "A common mistake is assuming that any two sets of replicas will intersect. " + "Review question: what happens when a read set misses the last write set? " + "" + ) + else: + raise AssertionError("The complete weave should not trigger another model call") + return result + + monkeypatch.setattr("slidenote.notes.llm_calls.LLMClient", FakeClient) + + result = generate_notes_result( + deck, + tmp_path, + use_llm=True, + provider="openai", + api_key="test", + note_strategy="lecture-weave", + note_profile="lecture-notes", + note_context="document", + ) + + assert tasks == ["page", "weave"] + assert result.teaching_report is None + assert result.llm_usage["summary"]["teaching_enrichment_calls"] == 0 + assert "Review question" in result.markdown + + def test_lecture_weave_prompt_uses_deck_brief_as_guarded_navigation(tmp_path, monkeypatch): deck = Deck( source_path="lecture.pdf", diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index bdc3e00..6ec8e78 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,45 +1,45 @@ -from argparse import Namespace - -from slidenote.models import Deck -from slidenote.pipeline import ArtifactRegistry, BuildContext, FunctionStage, StageResult, run_stage +from types import SimpleNamespace + +from slidenote.pipeline import ArtifactRegistry, BuildPhase, BuildStep, run_build_plan +from slidenote.progress import ProgressReporter + + +def test_build_plan_runs_enabled_steps_in_phase_order(tmp_path): + progress = ProgressReporter(tmp_path / "progress.json", quiet=True) + state = SimpleNamespace(progress=progress, calls=[]) + + def step(name, phase): + def run(current_state): + assert current_state.progress.current_phase == phase + current_state.progress.start_stage(name) + current_state.calls.append(name) + current_state.progress.finish_stage() + return run + + phases = ( + BuildPhase("ingest", (BuildStep("parse", step("parse", "ingest")),)), + BuildPhase("understand", ( + BuildStep("ocr", step("ocr", "understand"), enabled=lambda _: False), + BuildStep("layout", step("layout", "understand")), + )), + BuildPhase("write", ( + BuildStep("notes", step("notes", "write")), + BuildStep("summary", lambda current_state: current_state.calls.append("summary"), tracks_progress=False), + )), + ) + run_build_plan(state, phases) -def test_function_stage_records_result_and_registered_artifact(tmp_path): - deck = Deck(source_path="demo.pdf", source_type="pdf", pages=[]) - registry = ArtifactRegistry(tmp_path) - context = BuildContext( - args=Namespace(), - input_path=tmp_path / "demo.pdf", - output_root=tmp_path, - progress=None, - artifacts=registry, - ) + assert state.calls == ["parse", "layout", "notes", "summary"] + snapshot = progress.snapshot() + assert snapshot["planned_stages"] == state.calls[:-1] + assert [stage["name"] for stage in snapshot["stages"]] == state.calls[:-1] + assert snapshot["current_phase"] is None - def runner(stage_deck, stage_context): - stage_context.artifacts.write_json("demo", "demo.json", {"source_type": stage_deck.source_type}) - return StageResult(name="demo_stage", report={"ok": True}, artifacts={"demo": "demo.json"}) - result = run_stage(deck, context, FunctionStage(name="demo_stage", runner=runner, artifacts=["demo"])) +def test_artifact_registry_records_written_file(tmp_path): + registry = ArtifactRegistry(tmp_path) + registry.write_json("demo", "demo.json", {"ok": True}) - assert result.report == {"ok": True} - assert context.reports["demo_stage"] is result assert registry.as_summary()["demo"] == "demo.json" assert (tmp_path / "demo.json").exists() - - -def test_stage_dependencies_are_checked(tmp_path): - deck = Deck(source_path="demo.pdf", source_type="pdf", pages=[]) - context = BuildContext( - args=Namespace(), - input_path=tmp_path / "demo.pdf", - output_root=tmp_path, - progress=None, - ) - stage = FunctionStage(name="needs_parse", dependencies=["parse"], runner=lambda *_: {}) - - try: - run_stage(deck, context, stage) - except RuntimeError as exc: - assert "depends on missing stage" in str(exc) - else: - raise AssertionError("missing dependency should fail") diff --git a/tests/test_studio_core.py b/tests/test_studio_core.py index e43f690..facb67b 100644 --- a/tests/test_studio_core.py +++ b/tests/test_studio_core.py @@ -1,9 +1,12 @@ from __future__ import annotations import ast +import hashlib +import json import os import sys from pathlib import Path +from unittest.mock import MagicMock import pytest @@ -18,6 +21,7 @@ discover_outputs, discover_textbook_outputs, performance_tips, + progress_percent, safe_run_name, ) @@ -173,6 +177,15 @@ def test_env_and_speed_tips(tmp_path: Path): assert safe_run_name("我的 课件!!.pdf") +def test_progress_percent_uses_planned_stage_count(): + progress = { + "status": "running", + "planned_stages": ["parse", "understand", "notes", "export"], + "stages": [{"stage": "parse"}], + } + assert progress_percent(progress) == pytest.approx(0.25) + + def test_gui_api_status_accepts_provider_alias_env(monkeypatch): pytest.importorskip("streamlit") from gui.app import _api_status @@ -226,3 +239,135 @@ def test_gui_workbench_file_size_helper(): assert _format_file_size(512) == "512 B" assert _format_file_size(1536) == "1.5 KB" assert _format_file_size(None) == "unknown size" + + +def test_gui_carries_modality_corrections_for_identical_source_only(tmp_path: Path): + pytest.importorskip("streamlit") + from gui.app import _carry_modality_overrides + + previous_source = tmp_path / "previous.pdf" + previous_source.write_bytes(b"same input bytes") + current_source = tmp_path / "current.pdf" + current_source.write_bytes(previous_source.read_bytes()) + changed_source = tmp_path / "changed.pdf" + changed_source.write_bytes(b"same input byteX") + previous_output = tmp_path / "previous-output" + previous_output.mkdir() + (previous_output / "content.json").write_text(json.dumps({"source_path": str(previous_source)}), encoding="utf-8") + manifest = {"schema_version": 1, "pages": {"1": {"modality": "image_only", "note": "scan"}}} + (previous_output / "page_modalities.overrides.json").write_text(json.dumps(manifest), encoding="utf-8") + + matching_output = tmp_path / "matching-output" + matching_output.mkdir() + assert _carry_modality_overrides(previous_output, current_source, matching_output) + assert json.loads((matching_output / "page_modalities.overrides.json").read_text(encoding="utf-8")) == manifest + + changed_output = tmp_path / "changed-output" + changed_output.mkdir() + assert not _carry_modality_overrides(previous_output, changed_source, changed_output) + assert not (changed_output / "page_modalities.overrides.json").exists() + + +def test_gui_carries_hashed_corrections_after_original_upload_is_removed(tmp_path: Path): + pytest.importorskip("streamlit") + from gui.app import _carry_modality_overrides + + current_source = tmp_path / "current.pdf" + current_source.write_bytes(b"same input bytes") + previous_output = tmp_path / "previous-output" + previous_output.mkdir() + manifest = { + "schema_version": 1, + "source_sha256": hashlib.sha256(current_source.read_bytes()).hexdigest(), + "pages": {"1": {"modality": "image_only"}}, + } + (previous_output / "page_modalities.overrides.json").write_text(json.dumps(manifest), encoding="utf-8") + next_output = tmp_path / "next-output" + next_output.mkdir() + + assert _carry_modality_overrides(previous_output, current_source, next_output) + assert json.loads((next_output / "page_modalities.overrides.json").read_text(encoding="utf-8")) == manifest + + +def test_gui_preserves_stale_corrections_in_reused_output_dir(tmp_path: Path): + pytest.importorskip("streamlit") + from gui.app import _carry_modality_overrides + + old_source = tmp_path / "old.pdf" + old_source.write_bytes(b"old") + new_source = tmp_path / "new.pdf" + new_source.write_bytes(b"new") + output_dir = tmp_path / "output" + output_dir.mkdir() + (output_dir / "content.json").write_text(json.dumps({"source_path": str(old_source)}), encoding="utf-8") + manifest_path = output_dir / "page_modalities.overrides.json" + manifest_path.write_text('{"pages":{"1":{"modality":"image_only"}}}', encoding="utf-8") + + assert not _carry_modality_overrides(None, new_source, output_dir) + assert not manifest_path.exists() + backups = list(output_dir.glob("page_modalities.overrides.stale-*.json")) + assert len(backups) == 1 + assert 'image_only' in backups[0].read_text(encoding="utf-8") + + +def test_gui_saved_correction_records_source_hash(tmp_path: Path): + pytest.importorskip("streamlit") + from gui.app import _save_modality_override + + source = tmp_path / "source.pdf" + source.write_bytes(b"source bytes") + output_dir = tmp_path / "output" + output_dir.mkdir() + (output_dir / "content.json").write_text(json.dumps({"source_path": str(source)}), encoding="utf-8") + + _save_modality_override(output_dir, 2, "image_only", "scan") + manifest = json.loads((output_dir / "page_modalities.overrides.json").read_text(encoding="utf-8")) + assert manifest["source_sha256"] == hashlib.sha256(source.read_bytes()).hexdigest() + assert manifest["pages"]["2"]["modality"] == "image_only" + + +def test_gui_quiet_build_polls_progress_before_stdout(tmp_path: Path, monkeypatch): + pytest.importorskip("streamlit") + import gui.app as app + + output_dir = tmp_path / "output" + output_dir.mkdir() + marker = tmp_path / "finished.txt" + script = tmp_path / "quiet_build.py" + script.write_text( + "import json, sys, time\n" + "from pathlib import Path\n" + "progress = Path(sys.argv[1])\n" + "marker = Path(sys.argv[2])\n" + "progress.write_text(json.dumps({'status': 'running', 'message': 'working'}), encoding='utf-8')\n" + "time.sleep(1.0)\n" + "marker.write_text('done', encoding='utf-8')\n" + "print('finished', flush=True)\n", + encoding="utf-8", + ) + config = StudioConfig( + input_path=tmp_path / "source.pdf", + output_dir=output_dir, + progress_json=output_dir / "progress.json", + preset="local", + ) + monkeypatch.setattr(app, "build_slidenote_command", lambda cfg: [sys.executable, str(script), str(cfg.progress_json), str(marker)]) + monkeypatch.setattr(app, "_generate_cost_report", lambda _: None) + fake_st = MagicMock() + slots = [MagicMock() for _ in range(3)] + fake_st.empty.side_effect = slots + monkeypatch.setattr(app, "st", fake_st) + original_update = app._update_progress_ui + observed: list[tuple[dict | None, bool]] = [] + + def record_update(progress_path, progress_bar, status_box, stage_box): + original_update(progress_path, progress_bar, status_box, stage_box) + observed.append((app._read_json(progress_path), marker.exists())) + + monkeypatch.setattr(app, "_update_progress_ui", record_update) + app._run_build(config) + + assert sum(bool(progress and progress.get("status") == "running" and not finished) for progress, finished in observed) >= 2 + assert "finished" in slots[2].code.call_args.args[0] + fake_st.success.assert_called_once() + fake_st.error.assert_not_called()