fix: v1.4.17rc2 — OCR quote fix, 4-stage lifecycle, .env fallback, Codex support, deep-reading anchors, auto-update pip

This commit is contained in:
Research Assistant 2026-05-08 01:26:04 +08:00
parent 6b26235f5e
commit d2205e957c
13 changed files with 213 additions and 179 deletions

View file

@ -37,8 +37,7 @@ paperforge deep-reading # 查看精读队列状态
| 参数 | 必需 | 说明 |
|------|------|------|
| `<query>` | 是queue 模式除外) | Zotero key、标题片段、DOI、PMID 或关键词 |
| `queue` | 否 | 启动批量精读队列模式 |
| `<query>` | 否 | 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
<prefix>pf-deep XGT9Z257
<prefix>pf-deep Predictive findings on magnetic resonance imaging
<prefix>pf-deep 10.1016/j.jse.2018.01.001
```
### 批量从待精读队列启动
### 无参数:自动检测队列
```bash
/pf-deep queue
<prefix>pf-deep
```
当不提供具体 key/标题时,agent 自动执行以下流程
当不提供具体 key/标题时,`<prefix>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 <key>`
5. 由用户选择篇目:
- 若只有 1 篇就绪 -> 直接执行单篇精读(等同于 `<prefix>pf-deep <key>`
- 若多篇 -> 展示清单,用户选择后批量 spawn subagent 并行处理
> **注意**`queue` 模式只扫描正式笔记 frontmatter不扫描正式卡片。只有在 Base 里勾选 `analyze=true` 的论文才会进入队列。
## Output
Agent 在正式笔记中创建或更新 `## 精读` 区域,包含:

View file

@ -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",

View file

@ -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(

View file

@ -37,8 +37,7 @@ paperforge deep-reading # 查看精读队列状态
| 参数 | 必需 | 说明 |
|------|------|------|
| `<query>` | 是queue 模式除外) | Zotero key、标题片段、DOI、PMID 或关键词 |
| `queue` | 否 | 启动批量精读队列模式 |
| `<query>` | 否 | 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
<prefix>pf-deep XGT9Z257
<prefix>pf-deep Predictive findings on magnetic resonance imaging
<prefix>pf-deep 10.1016/j.jse.2018.01.001
```
### 批量从待精读队列启动
### 无参数:自动检测队列
```bash
/pf-deep queue
<prefix>pf-deep
```
当不提供具体 key/标题时,agent 自动执行以下流程
当不提供具体 key/标题时,`<prefix>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 <key>`
5. 由用户选择篇目:
- 若只有 1 篇就绪 -> 直接执行单篇精读(等同于 `<prefix>pf-deep <key>`
- 若多篇 -> 展示清单,用户选择后批量 spawn subagent 并行处理
> **注意**`queue` 模式只扫描正式笔记 frontmatter不扫描正式卡片。只有在 Base 里勾选 `analyze=true` 的论文才会进入队列。
## Output
Agent 在正式笔记中创建或更新 `## 精读` 区域,包含:

View file

@ -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);
});
}

View file

@ -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}")

View file

@ -44,7 +44,7 @@ python .opencode/skills/pf-deep/scripts/ld_deep.py prepare --vault "<VAULT_PATH>
Agent 根据返回的 `status` 决定是否进入精读流程,不自行读取 frontmatter。
**队列模式** (`/pf-deep queue`)Agent 运行:
**队列模式**(无参数时自动检测)Agent 运行:
```bash
python .opencode/skills/pf-deep/scripts/ld_deep.py queue --vault "<VAULT_PATH>"
```
@ -75,13 +75,13 @@ python .opencode/skills/pf-deep/scripts/ld_deep.py queue --vault "<VAULT_PATH>"
<prefix>pf-deep 10.1016/j.jse.2018.01.001
```
### 批量从待精读队列启动
### 无参数:自动检测队列
```bash
<prefix>pf-deep queue
<prefix>pf-deep
```
当不提供具体 key/标题时agent 自动执行以下流程
当不提供具体 key/标题时agent 自动检测精读队列
1. 运行 `python .opencode/skills/pf-deep/scripts/ld_deep.py queue --vault "<VAULT_PATH>"` 扫描队列
2. 解析输出的 JSON按 OCR 状态分组展示

View file

@ -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,

View file

@ -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}

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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: