mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
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:
parent
53e76b871d
commit
dd71bb0f79
13 changed files with 213 additions and 179 deletions
|
|
@ -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 在正式笔记中创建或更新 `## 精读` 区域,包含:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 在正式笔记中创建或更新 `## 精读` 区域,包含:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 状态分组展示
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue