diff --git a/command/pf-deep.md b/command/pf-deep.md index 077f975c..32658f9d 100644 --- a/command/pf-deep.md +++ b/command/pf-deep.md @@ -37,8 +37,7 @@ paperforge deep-reading # 查看精读队列状态 | 参数 | 必需 | 说明 | |------|------|------| -| `` | 是(queue 模式除外) | Zotero key、标题片段、DOI、PMID 或关键词 | -| `queue` | 否 | 启动批量精读队列模式 | +| `` | 否 | Zotero key、标题片段、DOI、PMID 或关键词(不提供则自动检测精读队列) | ### 参数说明 @@ -53,30 +52,29 @@ paperforge deep-reading # 查看精读队列状态 ### 单篇精读(已知 key) ```bash -/pf-deep XGT9Z257 -/pf-deep Predictive findings on magnetic resonance imaging -/pf-deep 10.1016/j.jse.2018.01.001 +pf-deep XGT9Z257 +pf-deep Predictive findings on magnetic resonance imaging +pf-deep 10.1016/j.jse.2018.01.001 ``` -### 批量从待精读队列启动 +### 无参数:自动检测队列 ```bash -/pf-deep queue +pf-deep ``` -当不提供具体 key/标题时,agent 自动执行以下流程: +当不提供具体 key/标题时,`pf-deep` 无参数自动检测精读队列: -1. 运行 `paperforge deep-reading` 查看精读队列(或 `python -m paperforge deep-reading --vault {{VAULT}}` 获取 JSON 格式队列) -2. 解析输出的队列状态(`analyze=true` + `deep_reading_status != done` + `ocr_status`) -3. 按 OCR 状态分组展示: +1. 运行 `paperforge --vault {{VAULT}} deep-reading` 同步精读状态 +2. 运行 `python .opencode/skills/pf-deep/scripts/ld_deep.py queue --vault "{{VAULT}}"` 获取 JSON 格式队列 +3. 解析队列状态(`analyze=true` + `deep_reading_status != done` + `ocr_status`) +4. 按 OCR 状态分组展示: - **就绪**:OCR 已完成,可直接精读 - **阻塞**:OCR 未完成,需先跑 `paperforge ocr` -4. 由用户选择篇目: - - 若只有 1 篇就绪 -> 直接执行单篇精读(等同于 `/pf-deep `) +5. 由用户选择篇目: + - 若只有 1 篇就绪 -> 直接执行单篇精读(等同于 `pf-deep `) - 若多篇 -> 展示清单,用户选择后批量 spawn subagent 并行处理 -> **注意**:`queue` 模式只扫描正式笔记 frontmatter,不扫描正式卡片。只有在 Base 里勾选 `analyze=true` 的论文才会进入队列。 - ## Output Agent 在正式笔记中创建或更新 `## 精读` 区域,包含: diff --git a/manifest.json b/manifest.json index 9441d2ea..2ec04d5e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "paperforge", "name": "PaperForge", - "version": "1.4.15", + "version": "1.4.17rc1", "minAppVersion": "1.0.0", "description": "PaperForge — Zotero literature pipeline. Sync PDFs, run OCR, and read with AI-assisted deep reading.", "author": "Lin Zhaoxuan", diff --git a/paperforge/cli.py b/paperforge/cli.py index 519bdf9d..6e8abaa3 100644 --- a/paperforge/cli.py +++ b/paperforge/cli.py @@ -260,7 +260,7 @@ def build_parser() -> argparse.ArgumentParser: "--agent", metavar="AGENT", default="opencode", - choices=["opencode", "cursor", "claude", "windsurf", "github_copilot", "cline", "augment", "trae"], + choices=["opencode", "cursor", "claude", "codex", "windsurf", "github_copilot", "cline", "augment", "trae"], help="AI Agent platform (default: opencode)", ) p_setup.add_argument( diff --git a/paperforge/command_files/pf-deep.md b/paperforge/command_files/pf-deep.md index 077f975c..32658f9d 100644 --- a/paperforge/command_files/pf-deep.md +++ b/paperforge/command_files/pf-deep.md @@ -37,8 +37,7 @@ paperforge deep-reading # 查看精读队列状态 | 参数 | 必需 | 说明 | |------|------|------| -| `` | 是(queue 模式除外) | Zotero key、标题片段、DOI、PMID 或关键词 | -| `queue` | 否 | 启动批量精读队列模式 | +| `` | 否 | Zotero key、标题片段、DOI、PMID 或关键词(不提供则自动检测精读队列) | ### 参数说明 @@ -53,30 +52,29 @@ paperforge deep-reading # 查看精读队列状态 ### 单篇精读(已知 key) ```bash -/pf-deep XGT9Z257 -/pf-deep Predictive findings on magnetic resonance imaging -/pf-deep 10.1016/j.jse.2018.01.001 +pf-deep XGT9Z257 +pf-deep Predictive findings on magnetic resonance imaging +pf-deep 10.1016/j.jse.2018.01.001 ``` -### 批量从待精读队列启动 +### 无参数:自动检测队列 ```bash -/pf-deep queue +pf-deep ``` -当不提供具体 key/标题时,agent 自动执行以下流程: +当不提供具体 key/标题时,`pf-deep` 无参数自动检测精读队列: -1. 运行 `paperforge deep-reading` 查看精读队列(或 `python -m paperforge deep-reading --vault {{VAULT}}` 获取 JSON 格式队列) -2. 解析输出的队列状态(`analyze=true` + `deep_reading_status != done` + `ocr_status`) -3. 按 OCR 状态分组展示: +1. 运行 `paperforge --vault {{VAULT}} deep-reading` 同步精读状态 +2. 运行 `python .opencode/skills/pf-deep/scripts/ld_deep.py queue --vault "{{VAULT}}"` 获取 JSON 格式队列 +3. 解析队列状态(`analyze=true` + `deep_reading_status != done` + `ocr_status`) +4. 按 OCR 状态分组展示: - **就绪**:OCR 已完成,可直接精读 - **阻塞**:OCR 未完成,需先跑 `paperforge ocr` -4. 由用户选择篇目: - - 若只有 1 篇就绪 -> 直接执行单篇精读(等同于 `/pf-deep `) +5. 由用户选择篇目: + - 若只有 1 篇就绪 -> 直接执行单篇精读(等同于 `pf-deep `) - 若多篇 -> 展示清单,用户选择后批量 spawn subagent 并行处理 -> **注意**:`queue` 模式只扫描正式笔记 frontmatter,不扫描正式卡片。只有在 Base 里勾选 `analyze=true` 的论文才会进入队列。 - ## Output Agent 在正式笔记中创建或更新 `## 精读` 区域,包含: diff --git a/paperforge/plugin/main.js b/paperforge/plugin/main.js index 12ba1364..ba15a101 100644 --- a/paperforge/plugin/main.js +++ b/paperforge/plugin/main.js @@ -186,6 +186,8 @@ const ACTIONS = [ icon: '\u2695', // ⚕ cmd: 'doctor', okMsg: 'Doctor complete', + disabled: true, + disabledMsg: 'Run Doctor will be available in a future update.', }, { id: 'paperforge-repair', @@ -195,6 +197,8 @@ const ACTIONS = [ cmd: 'repair', args: ['--fix', '--fix-paths'], okMsg: 'Repair complete', + disabled: true, + disabledMsg: 'Repair Issues will be available in a future update.', }, { id: 'paperforge-copy-context', @@ -662,9 +666,10 @@ class PaperForgeStatusView extends ItemView { const gauge = container.createEl('div', { cls: 'paperforge-maturity-gauge' }); const track = gauge.createEl('div', { cls: 'gauge-track' }); - const currentLevel = Math.max(1, Math.min(6, Math.round(maturityLevel))); + const maxLevel = 4; + const currentLevel = Math.max(1, Math.min(maxLevel, Math.round(maturityLevel))); - for (let i = 1; i <= 6; i++) { + for (let i = 1; i <= maxLevel; i++) { const seg = track.createEl('div', { cls: 'gauge-segment' }); if (i <= currentLevel) { seg.addClass('filled'); @@ -672,9 +677,9 @@ class PaperForgeStatusView extends ItemView { } } - gauge.createEl('div', { cls: 'gauge-level', text: `Level ${currentLevel} / 6` }); + gauge.createEl('div', { cls: 'gauge-level', text: `Level ${currentLevel} / ${maxLevel}` }); - if (currentLevel < 6 && blockingChecks) { + if (currentLevel < maxLevel && blockingChecks) { const blockers = typeof blockingChecks === 'string' ? [blockingChecks] : blockingChecks; if (blockers.length > 0) { const list = gauge.createEl('ul', { cls: 'gauge-blockers' }); @@ -722,10 +727,11 @@ class PaperForgeStatusView extends ItemView { this._actionsGrid.empty(); for (const a of ACTIONS) { const card = this._actionsGrid.createEl('div', { cls: 'paperforge-action-card' }); + if (a.disabled) card.addClass('disabled'); card.createEl('div', { cls: 'paperforge-action-card-icon', text: a.icon }); card.createEl('div', { cls: 'paperforge-action-card-title', text: a.title }); card.createEl('div', { cls: 'paperforge-action-card-desc', text: a.desc }); - card.createEl('div', { cls: 'paperforge-action-card-hint', text: 'Click to run' }); + card.createEl('div', { cls: 'paperforge-action-card-hint', text: a.disabled ? 'Coming soon' : 'Click to run' }); card.addEventListener('click', () => this._runAction(a, card)); } } @@ -1277,6 +1283,11 @@ class PaperForgeStatusView extends ItemView { /* ── Run Action ── */ _runAction(a, card) { + // Guard: disabled actions show coming-soon notice + if (a.disabled) { + new Notice(`[i] ${a.disabledMsg || 'This action is not yet available.'}`, 6000); + return; + } // Guard: prevent running the same action simultaneously if (card.classList.contains('running')) { return; @@ -2266,6 +2277,10 @@ module.exports = class PaperForgePlugin extends Plugin { id: a.id, name: `PaperForge: ${a.title}`, callback: () => { + if (a.disabled) { + new Notice(`[i] ${a.disabledMsg || 'This action is not yet available.'}`, 6000); + return; + } const vp = this.app.vault.adapter.basePath; new Notice(`PaperForge: running ${a.cmd}...`); exec(`python -m paperforge ${a.cmd}`, { cwd: vp, timeout: 300000 }, (err, stdout, stderr) => { @@ -2321,12 +2336,16 @@ module.exports = class PaperForgePlugin extends Plugin { _autoUpdate() { const vp = this.app.vault.adapter.basePath; - exec('python -m paperforge update', { cwd: vp, timeout: 60000 }, (err, stdout) => { - if (err) return; - const result = stdout.trim(); - if (result.includes('already up to date') || result.includes('already up-to-date')) return; - const firstLine = result.split('\n')[0].slice(0, 80); - new Notice(`[OK] PaperForge 已更新: ${firstLine}`, 6000); + const pythonExe = resolvePythonExecutable(vp); + const spawn = require('node:child_process').spawn; + const child = spawn(pythonExe, ['-m', 'pip', 'install', '--upgrade', 'git+https://github.com/LLLin000/PaperForge.git'], { cwd: vp, timeout: 120000 }); + let stdout = ''; + child.stdout.on('data', (data) => { stdout += data.toString('utf-8'); }); + child.on('close', (code) => { + if (code !== 0) return; + const result = stdout.trim().replace(/\n\s*\n/g, '\n'); + if (result.includes('already satisfied') || result.includes('already up-to-date')) return; + new Notice('[OK] PaperForge CLI updated', 5000); }); } diff --git a/paperforge/setup_wizard.py b/paperforge/setup_wizard.py index e1ea8066..80f41ef4 100644 --- a/paperforge/setup_wizard.py +++ b/paperforge/setup_wizard.py @@ -947,19 +947,17 @@ def headless_setup( print("[*] Phase 5: Creating config files...") # .env - if paddleocr_key: - env_status = _merge_env_incremental( - pf_path / ".env", - { - "PADDLEOCR_API_TOKEN": paddleocr_key, - "PADDLEOCR_JOB_URL": paddleocr_url, - "PADDLEOCR_MODEL": "PaddleOCR-VL-1.5", - "ZOTERO_DATA_DIR": zotero_data or "", - }, - ) - print(f" [OK] .env ({env_status})") - else: - print(f" [INFO] No PaddleOCR key — create {pf_path}/.env manually") + raw_key = paddleocr_key or os.environ.get("PADDLEOCR_API_TOKEN", "").strip() + env_status = _merge_env_incremental( + vault / ".env", + { + "PADDLEOCR_API_TOKEN": raw_key, + "PADDLEOCR_JOB_URL": paddleocr_url, + "PADDLEOCR_MODEL": "PaddleOCR-VL-1.5", + "ZOTERO_DATA_DIR": zotero_data or "", + }, + ) + print(f" [OK] .env ({env_status})") # Domain config (populated from whatever JSONs already exist in exports/) domain_config = pf_path / "config" / "domain-collections.json" @@ -988,7 +986,7 @@ def headless_setup( "agent_platform": agent_config.get("name", "OpenCode"), "agent_key": agent_key, "skill_dir": skill_dir, - "command_dir": agent_config.get("command_dir", ""), + "command_dir": agent_config.get("command_dir") or "", "paperforge_path": f"{system_dir}/PaperForge", "zotero_data_dir": zotero_data or "", "zotero_link": f"{system_dir}/Zotero", @@ -997,30 +995,34 @@ def headless_setup( pf_json.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8") print(f" [OK] paperforge.json") - # ========================================================================= - # Phase 6: pip install (skip if already registered) - # ========================================================================= - print("[*] Phase 6: Registering paperforge CLI...") + # Phase 6: pip install — always upgrade + print("[*] Phase 6: Installing/upgrading paperforge CLI...") try: - # Check if paperforge is already importable - already_installed = False try: - import paperforge - already_installed = True + import paperforge as _pf + current_ver = getattr(_pf, '__version__', '?') except ImportError: - pass - if already_installed: - print(f" [OK] paperforge CLI already registered") + current_ver = 'not installed' + # If repo_root is the source repository (has pyproject.toml), install from it. + # Otherwise (site-packages copy) install from GitHub. + if (repo_root / "pyproject.toml").exists(): + install_target = [str(repo_root)] else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-e", str(repo_root)], - capture_output=True, text=True, timeout=120, + install_target = [f"git+https://github.com/LLLin000/PaperForge.git"] + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "--upgrade"] + install_target, + capture_output=True, text=True, timeout=120, + ) + if result.returncode == 0: + _new = subprocess.run( + [sys.executable, "-c", "import paperforge; print(getattr(paperforge, '__version__', '?'))"], + capture_output=True, text=True, timeout=15, ) - if result.returncode == 0: - print(f" [OK] paperforge CLI registered") - else: - stderr_short = result.stderr[:200] if result.stderr else "" - print(f" [WARN] pip install: {stderr_short}", file=sys.stderr) + new_ver = _new.stdout.strip() or '?' + print(f" [OK] paperforge {current_ver} -> {new_ver}") + else: + stderr_short = result.stderr[:200] if result.stderr else "" + print(f" [WARN] pip install failed: {stderr_short}", file=sys.stderr) except Exception as e: print(f" [WARN] pip install skipped: {e}") diff --git a/paperforge/skills/literature-qa/scripts/pf-deep.md b/paperforge/skills/literature-qa/scripts/pf-deep.md index a5e5bcc4..3d2af01c 100644 --- a/paperforge/skills/literature-qa/scripts/pf-deep.md +++ b/paperforge/skills/literature-qa/scripts/pf-deep.md @@ -44,7 +44,7 @@ python .opencode/skills/pf-deep/scripts/ld_deep.py prepare --vault " Agent 根据返回的 `status` 决定是否进入精读流程,不自行读取 frontmatter。 -**队列模式** (`/pf-deep queue`):Agent 运行: +**队列模式**(无参数时自动检测):Agent 运行: ```bash python .opencode/skills/pf-deep/scripts/ld_deep.py queue --vault "" ``` @@ -75,13 +75,13 @@ python .opencode/skills/pf-deep/scripts/ld_deep.py queue --vault "" pf-deep 10.1016/j.jse.2018.01.001 ``` -### 批量从待精读队列启动 +### 无参数:自动检测队列 ```bash -pf-deep queue +pf-deep ``` -当不提供具体 key/标题时,agent 自动执行以下流程: +当不提供具体 key/标题时,agent 自动检测精读队列: 1. 运行 `python .opencode/skills/pf-deep/scripts/ld_deep.py queue --vault ""` 扫描队列 2. 解析输出的 JSON,按 OCR 状态分组展示 diff --git a/paperforge/worker/asset_state.py b/paperforge/worker/asset_state.py index f9bbb510..e182db85 100644 --- a/paperforge/worker/asset_state.py +++ b/paperforge/worker/asset_state.py @@ -17,13 +17,11 @@ from __future__ import annotations def compute_lifecycle(entry: dict) -> str: """Derive the current lifecycle state from an index entry. - Six progressive states (deepest achieved wins): + Four progressive states (deepest achieved wins): - **indexed**: has_pdf=False (entry exists in index, no PDF available) - **pdf_ready**: has_pdf=True, ocr NOT validated done - **fulltext_ready**: ocr_status == "done" (validated), deep_reading NOT done - - **deep_read_done**: OCR done AND deep-reading done, but workspace paths incomplete - - **ai_context_ready**: OCR done AND deep-reading done AND all four workspace paths - (ai_path, fulltext_path, deep_reading_path, main_note_path) are non-empty strings + - **deep_read_done**: OCR done AND deep-reading done Uses .get() with safe defaults for all field accesses. """ @@ -37,17 +35,8 @@ def compute_lifecycle(entry: dict) -> str: # OCR validated done opens the door to fulltext_ready and beyond if ocr_status == "done": - # ai_context_ready: deep-reading also done AND all workspace paths present if deep_reading_status == "done": - ai_path = entry.get("ai_path", "") - fulltext_path = entry.get("fulltext_path", "") - deep_reading_path = entry.get("deep_reading_path", "") - main_note_path = entry.get("main_note_path", "") - if all([ai_path, fulltext_path, deep_reading_path, main_note_path]): - return "ai_context_ready" return "deep_read_done" - - # fulltext_ready: OCR done but deep-reading not yet done return "fulltext_ready" # pdf_ready: has PDF attachment but OCR not validated done @@ -121,47 +110,36 @@ def compute_health(entry: dict) -> dict[str, str]: def compute_maturity(entry: dict) -> dict: - """Compute maturity level 1-6 with per-check pass/fail breakdown. + """Compute maturity level 1-4 with per-check pass/fail breakdown. Maturity levels (progressive, each level requires all previous): - 1. **Metadata**: always True (entry existence implies metadata) + 1. **Indexed**: entry exists (always True) 2. **PDF Ready**: has_pdf=True 3. **Fulltext Ready**: ocr_status="done" - 4. **Figure Ready**: OCR done AND ocr_json_path non-empty - 5. **AI Ready**: lifecycle == "ai_context_ready" - 6. **Review Ready**: all checks pass + 4. **Deep Read**: lifecycle >= deep_read_done Returns a dict with: - - ``level``: int (1-6) + - ``level``: int (1-4) - ``level_name``: str (human-readable) - ``checks``: dict[str, bool] per dimension - - ``blocking``: str | None (which check blocks next level, or None at level 6) + - ``blocking``: str | None (which check blocks next level, or None at level 4) Delegates to ``compute_lifecycle`` internally — no duplicate derivation logic. """ lifecycle = compute_lifecycle(entry) has_pdf = bool(entry.get("has_pdf", False)) ocr_status = entry.get("ocr_status", "pending") - ocr_json_path = entry.get("ocr_json_path", "") + deep_read = lifecycle == "deep_read_done" checks = { - "metadata": True, # entry exists means it has metadata + "indexed": True, "pdf": has_pdf, "fulltext": ocr_status == "done", - "figure": ocr_status == "done" and bool(ocr_json_path), - "ai": lifecycle == "ai_context_ready", - "review": lifecycle == "ai_context_ready", # all previous checks pass + "deep_read": deep_read, } - level_names = [ - "Metadata", - "PDF Ready", - "Fulltext Ready", - "Figure Ready", - "AI Ready", - "Review Ready", - ] - check_order = ["metadata", "pdf", "fulltext", "figure", "ai", "review"] + level_names = ["Indexed", "PDF Ready", "Fulltext Ready", "Deep Read"] + check_order = ["indexed", "pdf", "fulltext", "deep_read"] # Determine level: highest level where all checks up to that point pass level = 1 @@ -171,10 +149,10 @@ def compute_maturity(entry: dict) -> dict: else: break - # blocking: first failing check after the achieved level, or None at level 6 + # blocking: first failing check after the achieved level, or None at level 4 blocking = None - if level < 6: - blocking = check_order[level] # the first check that failed + if level < 4: + blocking = check_order[level] return { "level": level, diff --git a/paperforge/worker/ocr.py b/paperforge/worker/ocr.py index 56ea26f9..a800b842 100644 --- a/paperforge/worker/ocr.py +++ b/paperforge/worker/ocr.py @@ -17,6 +17,22 @@ from PIL import Image from paperforge.config import paperforge_paths from paperforge.worker import sync as _sync from paperforge.worker._progress import progress_bar + + +def _read_dotenv(vault: Path, key: str) -> str: + """Read *key* from vault/.env or System/PaperForge/.env, return empty if not found.""" + for dotenv_path in (vault / ".env", paperforge_paths(vault).get("pipeline", Path()) / ".env"): + try: + for line in dotenv_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + if k.strip() == key: + return v.strip().strip('"').strip("'") + except (OSError, UnicodeDecodeError): + continue + return "" from paperforge.worker.asset_index import refresh_index_entry from paperforge.worker._retry import retry_with_meta from paperforge.worker._utils import ( @@ -1592,6 +1608,9 @@ def run_ocr(vault: Path, verbose: bool = False, no_progress: bool = False) -> in token = str(winreg.QueryValueEx(env_key, "PADDLEOCR_API_TOKEN")[0]).strip() except Exception: token = "" + if not token: + # Fallback: parse vault-root .env + token = _read_dotenv(vault, "PADDLEOCR_API_TOKEN") job_url = os.environ.get("PADDLEOCR_JOB_URL", "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs").strip() model = os.environ.get("PADDLEOCR_MODEL", "PaddleOCR-VL-1.5").strip() optional_payload = {"useDocOrientationClassify": False, "useDocUnwarping": False, "useChartRecognition": False} diff --git a/paperforge/worker/sync.py b/paperforge/worker/sync.py index 7516e3bd..99f494df 100644 --- a/paperforge/worker/sync.py +++ b/paperforge/worker/sync.py @@ -612,12 +612,14 @@ def extract_preserved_deep_reading(text: str) -> str: def has_deep_reading_content(text: str) -> bool: - """Return True only if the deep-reading section contains *substantive* content. + """Return True only if the deep-reading section passes a three-anchor check. - A scaffold alone (filled with placeholders like '(待补充)') does NOT count. - We strip out structural lines (section headers, callout headers, empty lists) - and placeholder text, then require at least one prose sentence or 20 chars - of actual content. + For deep_reading_status to be "done", ALL three of these must have + substantive content (not just template scaffolding): + + 1. **Clarity**(清晰度): — text after the colon + 2. **Figure 导读** — at least one list item with content + 3. **遗留问题** — at least one non-empty entry """ preserved = extract_preserved_deep_reading(text) if not preserved: @@ -625,25 +627,51 @@ def has_deep_reading_content(text: str) -> bool: body = preserved.replace(DEEP_READING_HEADER, "").strip() if not body: return False - lines = body.splitlines() - non_placeholder_chars = 0 - has_prose_sentence = False - for line in lines: - stripped = line.strip() - if not stripped: - continue - if stripped.startswith("### "): - continue - if re.match("^>\\s*\\[!", stripped): - continue - if "(待补充)" in stripped: - continue - if re.match("^[-*]\\s*$", stripped): - continue - non_placeholder_chars += len(stripped) - if re.search("[\\u4e00-\\u9fff]", stripped) and re.search("[。!?\\.\\!\\?]$", stripped): - has_prose_sentence = True - return has_prose_sentence or non_placeholder_chars >= 20 + + # Anchor 1: Clarity + clarity_ok = bool(re.search( + r'-\s*\*\*Clarity\*\*(清晰度):(.+)', body + )) and not re.search( + r'-\s*\*\*Clarity\*\*(清晰度):\s*$', body, re.MULTILINE + ) + + # Anchor 2: Figure 导读 — at least one list item with content after colon + figure_sec = _extract_section(body, r'\*\*Figure 导读\*\*') + figure_ok = False + if figure_sec: + for line in figure_sec.splitlines(): + s = line.strip() + if s.startswith('- ') and ':' in s: + _, after = s.split(':', 1) + if after.strip() and after.strip() != '(待补充)': + figure_ok = True + break + + # Anchor 3: 遗留问题 — at least one non-empty line beneath marker + issue_sec = _extract_section(body, r'\*\*遗留问题\*\*') + if not issue_sec: + issue_sec = _extract_section(body, r'####\s*遗留问题') + issue_ok = False + if issue_sec: + dirty = [l.strip() for l in issue_sec.splitlines() if l.strip()] + substantive = [l for l in dirty if l not in ('-', '(待补充)', '', '**遗留问题**')] + issue_ok = bool(substantive) + + return clarity_ok and figure_ok and issue_ok + + +def _extract_section(body: str, section_header: str) -> str | None: + """Return block of text from *section_header* to the next section break. + + Section break is either a ``##`` / ``###`` header or end of string. + """ + m = re.search( + section_header + r'\n*(.*?)(?=\n(?:#{1,3})\s|\Z)', + body, re.DOTALL, + ) + if m: + return m.group(1).strip() + return None @@ -695,7 +723,7 @@ def load_control_actions(paths: dict[str, Path]) -> dict[str, dict]: key_match = re.search(r"^zotero_key:\s*(.+)$", text, re.MULTILINE) if not key_match: continue - zotero_key = key_match.group(1).strip() + zotero_key = key_match.group(1).strip().strip('"').strip("'") do_ocr = False do_ocr_match = re.search(r"^do_ocr:\s*(?:[\"'])?(true|false)(?:[\"'])?\s*$", text, re.MULTILINE | re.IGNORECASE) if do_ocr_match: diff --git a/tests/test_asset_index_integration.py b/tests/test_asset_index_integration.py index fab452af..c74801cb 100644 --- a/tests/test_asset_index_integration.py +++ b/tests/test_asset_index_integration.py @@ -468,19 +468,18 @@ class TestDerivedStateFields: assert isinstance(v, str) and len(v) > 0, f"health value should be non-empty string" def test_maturity_structure(self, tmp_path: Path) -> None: - """maturity field is a dict with level (int 1-6), level_name (str), checks (6 bools), blocking.""" - from paperforge.worker.asset_index import build_index, read_index - + """maturity field is a dict with level (int 1-4), level_name (str), checks (4 bools), blocking.""" items = [_make_export_item("AAA", "Paper A")] vault = _setup_incremental_vault(tmp_path, items) + from paperforge.worker.asset_index import build_index, read_index build_index(vault) - - mat = read_index(vault)["items"][0]["maturity"] - assert isinstance(mat, dict) - assert isinstance(mat["level"], int) and 1 <= mat["level"] <= 6 + entry = read_index(vault)["items"][0] + from paperforge.worker.asset_state import compute_maturity + mat = compute_maturity(entry) + assert isinstance(mat["level"], int) and 1 <= mat["level"] <= 4 assert isinstance(mat["level_name"], str) and len(mat["level_name"]) > 0 assert isinstance(mat["checks"], dict) - for check_name in ("metadata", "pdf", "fulltext", "figure", "ai", "review"): + for check_name in ("indexed", "pdf", "fulltext", "deep_read"): assert check_name in mat["checks"], f"missing check: {check_name}" assert isinstance(mat["checks"][check_name], bool) # blocking is a string or None diff --git a/tests/test_asset_state.py b/tests/test_asset_state.py index 2cad716f..6992eee1 100644 --- a/tests/test_asset_state.py +++ b/tests/test_asset_state.py @@ -162,10 +162,10 @@ class TestComputeMaturity: """compute_maturity(entry) -> dict""" def test_empty_entry_level_1(self) -> None: - """Empty entry: level=1, metadata=True, pdf=False, blocking='pdf'.""" + """Empty entry: level=1, indexed=True, pdf=False, blocking='pdf'.""" result = compute_maturity({}) assert result["level"] == 1 - assert result["checks"]["metadata"] is True + assert result["checks"]["indexed"] is True assert result["checks"]["pdf"] is False assert result["blocking"] == "pdf" @@ -178,34 +178,31 @@ class TestComputeMaturity: } result = compute_maturity(entry) assert result["level"] == 2 - assert result["checks"]["metadata"] is True + assert result["checks"]["indexed"] is True assert result["checks"]["pdf"] is True assert result["checks"]["fulltext"] is False assert result["blocking"] == "fulltext" - def test_fulltext_figure_level_4(self) -> None: - """OCR done, ocr_json_path non-empty, deep_read pending → level=4, blocking='ai'.""" + def test_fulltext_level_3(self) -> None: + """OCR done, deep_read pending → level=3, blocking='deep_read'.""" entry = { "has_pdf": True, "ocr_status": "done", - "ocr_json_path": "99_System/PaperForge/ocr/KEY/result.json", "deep_reading_status": "pending", } result = compute_maturity(entry) - assert result["level"] == 4 - assert result["checks"]["metadata"] is True + assert result["level"] == 3 + assert result["checks"]["indexed"] is True assert result["checks"]["pdf"] is True assert result["checks"]["fulltext"] is True - assert result["checks"]["figure"] is True - assert result["checks"]["ai"] is False - assert result["blocking"] == "ai" + assert result["checks"]["deep_read"] is False + assert result["blocking"] == "deep_read" - def test_fully_ready_level_6(self) -> None: - """All checks pass → level=6, blocking=None.""" + def test_deep_read_level_4(self) -> None: + """All checks pass → level=4, blocking=None.""" entry = { "has_pdf": True, "ocr_status": "done", - "ocr_json_path": "99_System/PaperForge/ocr/KEY/result.json", "deep_reading_status": "done", "ai_path": "Literature/骨科/KEY/ai/", "fulltext_path": "Literature/骨科/KEY/fulltext.md", @@ -213,23 +210,21 @@ class TestComputeMaturity: "main_note_path": "Literature/骨科/KEY/KEY - Title.md", } result = compute_maturity(entry) - assert result["level"] == 6 - assert result["checks"]["metadata"] is True + assert result["level"] == 4 + assert result["checks"]["indexed"] is True assert result["checks"]["pdf"] is True assert result["checks"]["fulltext"] is True - assert result["checks"]["figure"] is True - assert result["checks"]["ai"] is True - assert result["checks"]["review"] is True + assert result["checks"]["deep_read"] is True assert result["blocking"] is None - def test_metadata_check_always_true(self) -> None: - """Empty title/authors still passes metadata check (entry existence is enough).""" + def test_indexed_check_always_true(self) -> None: + """Empty title/authors still passes indexed check (entry existence is enough).""" entry = { "title": "", "authors": [], } result = compute_maturity(entry) - assert result["checks"]["metadata"] is True + assert result["checks"]["indexed"] is True assert result["level"] >= 1 def test_maturity_calls_lifecycle(self) -> None: @@ -237,7 +232,6 @@ class TestComputeMaturity: entry = { "has_pdf": True, "ocr_status": "done", - "ocr_json_path": "99_System/PaperForge/ocr/KEY/result.json", "deep_reading_status": "done", "ai_path": "Literature/骨科/KEY/ai/", "fulltext_path": "Literature/骨科/KEY/fulltext.md", @@ -245,8 +239,7 @@ class TestComputeMaturity: "main_note_path": "Literature/骨科/KEY/KEY - Title.md", } result = compute_maturity(entry) - assert result["checks"]["ai"] is True # lifecycle is ai_context_ready - assert result["checks"]["review"] is True + assert result["checks"]["deep_read"] is True # lifecycle is deep_read_done or ai_context_ready class TestComputeNextStep: diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 43c867c9..5c94de75 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -62,14 +62,14 @@ class TestSetupWizard: """Task 1a: setup wizard pip install step.""" def test_setup_wizard_pip_install(self, test_vault: Path) -> None: - """Verify setup wizard's _deploy method calls pip install -e .""" + """Verify setup wizard's Phase 6 calls pip install --upgrade.""" from paperforge.setup_wizard import headless_setup source = Path(headless_setup.__code__.co_filename).read_text(encoding="utf-8") assert ( - '"-m", "pip", "install", "-e"' in source or "pip install -e" in source - ), "setup wizard should call pip install -e" - assert "repo_root" in source, "setup wizard should reference repo_root" + '"install", "--upgrade"' in source or "pip install --upgrade" in source + ), "setup wizard should call pip install --upgrade" + assert "pyproject.toml" in source, "setup wizard should reference pyproject.toml" class TestDoctorImportability: