feat(#38, #40): version history panel + @ Deep Search

#38: File Recovery-style version history panel with i18n (en/zh).
- _renderVersionMode() with paper list + timeline + restore/compare
- version-history.ts service for scan/restore/compare
- Paper mode [版本历史] button
- i18n strings for all panel text

#40: @ Deep Search with query rewrite + hybrid retrieval.
- query_rewrite.py: abbreviation expansion (28 terms) + synonyms (12)
- hybrid_search(): BM25 FTS5 + vec0 k-NN + score fusion (0.3/0.7)
- retrieve --deep flag routes to hybrid_search
- Graceful vec0 fallback (FTS5-only)
- 17 new tests

Co-authored-by: VersionPanel <agent>
Co-authored-by: DeepSearch <agent>
This commit is contained in:
LLLin000 2026-07-10 01:01:48 +08:00
parent 27e45f9419
commit 4ff165ae49
13 changed files with 4627 additions and 1673 deletions

View file

@ -0,0 +1,910 @@
{
"type": "excalidraw",
"version": 2,
"source": "claude-code",
"elements": [
{
"id": "panel_bg",
"type": "rectangle",
"x": 0, "y": 0,
"width": 700, "height": 520,
"angle": 0,
"strokeColor": "#475569",
"backgroundColor": "#f1f5f9",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100001,
"boundElements": null
},
{
"id": "title",
"type": "text",
"x": 20, "y": 16,
"width": 280,
"height": 30,
"angle": 0,
"strokeColor": "#1e293b",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100002,
"fontSize": 24,
"fontFamily": 2,
"text": "Version History",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "paper_count",
"type": "text",
"x": 280, "y": 24,
"width": 100,
"height": 20,
"angle": 0,
"strokeColor": "#64748b",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100003,
"fontSize": 14,
"fontFamily": 2,
"text": "(12 papers)",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "back_btn",
"type": "rectangle",
"x": 620, "y": 12,
"width": 60, "height": 28,
"angle": 0,
"strokeColor": "#475569",
"backgroundColor": "#e0f2fe",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100004,
"boundElements": [
{ "id": "back_label", "type": "text" }
]
},
{
"id": "back_label",
"type": "text",
"x": 632, "y": 16,
"width": 36,
"height": 20,
"angle": 0,
"strokeColor": "#334155",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100005,
"fontSize": 14,
"fontFamily": 2,
"text": "Back",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "back_btn"
},
{
"id": "left_panel_bg",
"type": "rectangle",
"x": 12, "y": 52,
"width": 200, "height": 420,
"angle": 0,
"strokeColor": "#475569",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100010,
"boundElements": null
},
{
"id": "filter_label",
"type": "text",
"x": 24, "y": 60,
"width": 60,
"height": 18,
"angle": 0,
"strokeColor": "#64748b",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100011,
"fontSize": 12,
"fontFamily": 2,
"text": "Filter",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "filter_box",
"type": "rectangle",
"x": 24, "y": 82,
"width": 176, "height": 24,
"angle": 0,
"strokeColor": "#cbd5e1",
"backgroundColor": "#f8fafc",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100012,
"boundElements": null
},
{
"id": "filter_placeholder",
"type": "text",
"x": 30, "y": 85,
"width": 120,
"height": 16,
"angle": 0,
"strokeColor": "#94a3b8",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100013,
"fontSize": 12,
"fontFamily": 2,
"text": "Search papers...",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "paper_item_1_bg",
"type": "rectangle",
"x": 14, "y": 116,
"width": 196, "height": 60,
"angle": 0,
"strokeColor": "#dbeafe",
"backgroundColor": "#eff6ff",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 0,
"opacity": 100,
"seed": 100020,
"boundElements": null
},
{
"id": "paper_item_1_title",
"type": "text",
"x": 20, "y": 120,
"width": 180,
"height": 16,
"angle": 0,
"strokeColor": "#1e293b",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100021,
"fontSize": 12,
"fontFamily": 2,
"text": "Glueck_2005_Osteolysis",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "paper_item_1_versions",
"type": "text",
"x": 20, "y": 140,
"width": 180,
"height": 14,
"angle": 0,
"strokeColor": "#64748b",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100022,
"fontSize": 11,
"fontFamily": 2,
"text": "v1 \u2192 v2 \u2192 v3 \u25cf 2.1 MB",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "paper_item_1_badge",
"type": "ellipse",
"x": 190, "y": 122,
"width": 16, "height": 16,
"angle": 0,
"strokeColor": "#dc2626",
"backgroundColor": "#fecaca",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 0,
"opacity": 100,
"seed": 100023,
"boundElements": null
},
{
"id": "paper_item_2_bg",
"type": "rectangle",
"x": 14, "y": 180,
"width": 196, "height": 60,
"angle": 0,
"strokeColor": "#e2e8f0",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100024,
"boundElements": null
},
{
"id": "paper_item_2_title",
"type": "text",
"x": 20, "y": 184,
"width": 180,
"height": 16,
"angle": 0,
"strokeColor": "#334155",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100025,
"fontSize": 12,
"fontFamily": 2,
"text": "Gao_2020_Bioelectronic",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "paper_item_2_versions",
"type": "text",
"x": 20, "y": 204,
"width": 180,
"height": 14,
"angle": 0,
"strokeColor": "#64748b",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100026,
"fontSize": 11,
"fontFamily": 2,
"text": "v1 \u25cf 0.8 MB",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "paper_item_3_bg",
"type": "rectangle",
"x": 14, "y": 244,
"width": 196, "height": 60,
"angle": 0,
"strokeColor": "#e2e8f0",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100027,
"boundElements": null
},
{
"id": "paper_item_3_title",
"type": "text",
"x": 20, "y": 248,
"width": 180,
"height": 16,
"angle": 0,
"strokeColor": "#334155",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100028,
"fontSize": 12,
"fontFamily": 2,
"text": "Lyu_AI_VTE_Prevention",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "paper_item_3_versions",
"type": "text",
"x": 20, "y": 268,
"width": 180,
"height": 14,
"angle": 0,
"strokeColor": "#64748b",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100029,
"fontSize": 11,
"fontFamily": 2,
"text": "v1 \u2192 v2 \u25cf 1.5 MB",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "right_panel_bg",
"type": "rectangle",
"x": 220, "y": 52,
"width": 468, "height": 420,
"angle": 0,
"strokeColor": "#475569",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100030,
"boundElements": null
},
{
"id": "detail_title",
"type": "text",
"x": 232, "y": 60,
"width": 440,
"height": 20,
"angle": 0,
"strokeColor": "#1e293b",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100031,
"fontSize": 16,
"fontFamily": 2,
"text": "Glueck_2005_Osteolysis_CaseReport",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "timeline_line",
"type": "line",
"x": 252, "y": 108,
"width": 0, "height": 240,
"angle": 0,
"strokeColor": "#cbd5e1",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 0,
"opacity": 100,
"seed": 100032,
"points": [[0, 0], [0, 240]],
"boundElements": null
},
{
"id": "timeline_dot_v3",
"type": "ellipse",
"x": 246, "y": 108,
"width": 12, "height": 12,
"angle": 0,
"strokeColor": "#166534",
"backgroundColor": "#bbf7d0",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 0,
"opacity": 100,
"seed": 100040,
"boundElements": null
},
{
"id": "timeline_label_v3",
"type": "text",
"x": 272, "y": 106,
"width": 400,
"height": 16,
"angle": 0,
"strokeColor": "#166534",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100041,
"fontSize": 13,
"fontFamily": 2,
"text": "v3 (current) \u2014 2026-07-10 10:00 \u2014 43KB \u2014 renderer 2.1.0",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "timeline_dot_v2",
"type": "ellipse",
"x": 246, "y": 170,
"width": 12, "height": 12,
"angle": 0,
"strokeColor": "#475569",
"backgroundColor": "#e2e8f0",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 0,
"opacity": 100,
"seed": 100042,
"boundElements": null
},
{
"id": "timeline_label_v2",
"type": "text",
"x": 272, "y": 168,
"width": 400,
"height": 16,
"angle": 0,
"strokeColor": "#334155",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100043,
"fontSize": 13,
"fontFamily": 2,
"text": "v2 (rebuild) \u2014 2026-07-09 14:30 \u2014 42KB \u2014 renderer 2.0.0",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "restore_v2_btn",
"type": "rectangle",
"x": 280, "y": 190,
"width": 64, "height": 22,
"angle": 0,
"strokeColor": "#1e40af",
"backgroundColor": "#dbeafe",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100044,
"boundElements": [
{ "id": "restore_v2_label", "type": "text" }
]
},
{
"id": "restore_v2_label",
"type": "text",
"x": 290, "y": 192,
"width": 44,
"height": 16,
"angle": 0,
"strokeColor": "#334155",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100045,
"fontSize": 11,
"fontFamily": 2,
"text": "Restore",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "restore_v2_btn"
},
{
"id": "compare_v2_btn",
"type": "rectangle",
"x": 350, "y": 190,
"width": 64, "height": 22,
"angle": 0,
"strokeColor": "#475569",
"backgroundColor": "#f1f5f9",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100046,
"boundElements": [
{ "id": "compare_v2_label", "type": "text" }
]
},
{
"id": "compare_v2_label",
"type": "text",
"x": 358, "y": 192,
"width": 48,
"height": 16,
"angle": 0,
"strokeColor": "#334155",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100047,
"fontSize": 11,
"fontFamily": 2,
"text": "Compare",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "compare_v2_btn"
},
{
"id": "timeline_dot_v1",
"type": "ellipse",
"x": 246, "y": 232,
"width": 12, "height": 12,
"angle": 0,
"strokeColor": "#475569",
"backgroundColor": "#e2e8f0",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 0,
"opacity": 100,
"seed": 100048,
"boundElements": null
},
{
"id": "timeline_label_v1",
"type": "text",
"x": 272, "y": 230,
"width": 400,
"height": 16,
"angle": 0,
"strokeColor": "#64748b",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100049,
"fontSize": 13,
"fontFamily": 2,
"text": "v1 (original OCR) \u2014 2026-06-01 09:00 \u2014 38KB",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "restore_v1_btn",
"type": "rectangle",
"x": 280, "y": 252,
"width": 64, "height": 22,
"angle": 0,
"strokeColor": "#1e40af",
"backgroundColor": "#dbeafe",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100050,
"boundElements": [
{ "id": "restore_v1_label", "type": "text" }
]
},
{
"id": "restore_v1_label",
"type": "text",
"x": 290, "y": 254,
"width": 44,
"height": 16,
"angle": 0,
"strokeColor": "#334155",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100051,
"fontSize": 11,
"fontFamily": 2,
"text": "Restore",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "restore_v1_btn"
},
{
"id": "compare_v1_btn",
"type": "rectangle",
"x": 350, "y": 252,
"width": 64, "height": 22,
"angle": 0,
"strokeColor": "#475569",
"backgroundColor": "#f1f5f9",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100052,
"boundElements": [
{ "id": "compare_v1_label", "type": "text" }
]
},
{
"id": "compare_v1_label",
"type": "text",
"x": 358, "y": 254,
"width": 48,
"height": 16,
"angle": 0,
"strokeColor": "#334155",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100053,
"fontSize": 11,
"fontFamily": 2,
"text": "Compare",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "compare_v1_btn"
},
{
"id": "compare_preview_section",
"type": "rectangle",
"x": 232, "y": 290,
"width": 444, "height": 120,
"angle": 0,
"strokeColor": "#e2e8f0",
"backgroundColor": "#f8fafc",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100060,
"boundElements": null
},
{
"id": "compare_preview_title",
"type": "text",
"x": 240, "y": 296,
"width": 200,
"height": 16,
"angle": 0,
"strokeColor": "#334155",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100061,
"fontSize": 12,
"fontFamily": 2,
"text": "v2 vs current \u2014 2 paragraphs changed",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "diff_item_1",
"type": "rectangle",
"x": 240, "y": 318,
"width": 428, "height": 18,
"angle": 0,
"strokeColor": "#fef9c3",
"backgroundColor": "#fefce8",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100062,
"boundElements": null
},
{
"id": "diff_item_1_text",
"type": "text",
"x": 246, "y": 319,
"width": 400,
"height": 14,
"angle": 0,
"strokeColor": "#854d0e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100063,
"fontSize": 11,
"fontFamily": 2,
"text": "Introduction: background paragraph restructured",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "diff_item_2",
"type": "rectangle",
"x": 240, "y": 340,
"width": 428, "height": 18,
"angle": 0,
"strokeColor": "#dcfce7",
"backgroundColor": "#f0fdf4",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100064,
"boundElements": null
},
{
"id": "diff_item_2_text",
"type": "text",
"x": 246, "y": 341,
"width": 400,
"height": 14,
"angle": 0,
"strokeColor": "#166534",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100065,
"fontSize": 11,
"fontFamily": 2,
"text": "Methods: statistical paragraph added",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "diff_item_3",
"type": "rectangle",
"x": 240, "y": 362,
"width": 428, "height": 18,
"angle": 0,
"strokeColor": "#e2e8f0",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100066,
"boundElements": null
},
{
"id": "diff_item_3_text",
"type": "text",
"x": 246, "y": 363,
"width": 400,
"height": 14,
"angle": 0,
"strokeColor": "#64748b",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100067,
"fontSize": 11,
"fontFamily": 2,
"text": "Results: unchanged (2/3 tables reformatted)",
"textAlign": "left",
"verticalAlign": "top",
"containerId": null
},
{
"id": "action_bar_bg",
"type": "rectangle",
"x": 12, "y": 478,
"width": 676, "height": 36,
"angle": 0,
"strokeColor": "#e2e8f0",
"backgroundColor": "#f8fafc",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100070,
"boundElements": null
},
{
"id": "restore_all_btn",
"type": "rectangle",
"x": 22, "y": 482,
"width": 120, "height": 28,
"angle": 0,
"strokeColor": "#1e40af",
"backgroundColor": "#dbeafe",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100071,
"boundElements": [
{ "id": "restore_all_label", "type": "text" }
]
},
{
"id": "restore_all_label",
"type": "text",
"x": 28, "y": 488,
"width": 108,
"height": 16,
"angle": 0,
"strokeColor": "#334155",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100072,
"fontSize": 11,
"fontFamily": 2,
"text": "Restore selected",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "restore_all_btn"
},
{
"id": "clear_btn",
"type": "rectangle",
"x": 150, "y": 482,
"width": 180, "height": 28,
"angle": 0,
"strokeColor": "#475569",
"backgroundColor": "#f1f5f9",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100073,
"boundElements": [
{ "id": "clear_label", "type": "text" }
]
},
{
"id": "clear_label",
"type": "text",
"x": 160, "y": 488,
"width": 160,
"height": 16,
"angle": 0,
"strokeColor": "#334155",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 0,
"opacity": 100,
"seed": 100074,
"fontSize": 11,
"fontFamily": 2,
"text": "Clear old versions (free 4.4 MB)",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "clear_btn"
}
],
"appState": {
"viewBackgroundColor": "#ffffff"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -302,6 +302,7 @@ def build_parser() -> argparse.ArgumentParser:
p_retrieve.add_argument("query", help="Search query")
p_retrieve.add_argument("--json", action="store_true")
p_retrieve.add_argument("--limit", type=int, default=5)
p_retrieve.add_argument("--deep", action="store_true", help="Enable @ Deep Search mode with query rewrite + hybrid retrieval (BM25 + vec0)")
p_retrieve.add_argument("--expand", action="store_true", default=True)
p_qp = sub.add_parser("query-plan", help="Classify a literature query and recommend the first retrieval command")

View file

@ -6,7 +6,7 @@ import sys
from paperforge import __version__ as PF_VERSION
from paperforge.core.errors import ErrorCode
from paperforge.core.result import PFError, PFResult
from paperforge.embedding import merge_retrieve, retrieve_chunks
from paperforge.embedding import hybrid_search, merge_retrieve, retrieve_chunks
from paperforge.memory.db import get_connection, get_memory_db_path
from paperforge.query_planning import build_query_plan, enrich_query_plan_with_runtime
@ -43,8 +43,49 @@ def run(args: argparse.Namespace) -> int:
vault = args.vault_path
query = args.query
limit = args.limit or 5
deep = getattr(args, "deep", False)
# Check if vector index exists
# ── @ Deep Search mode: query rewrite + hybrid retrieval ──────
if deep:
try:
chunks = hybrid_search(vault, query, limit=limit)
except Exception as e:
result = PFResult(
ok=False,
command="retrieve",
version=PF_VERSION,
error=PFError(code=ErrorCode.INTERNAL_ERROR, message=str(e)),
)
print(result.to_json() if args.json else result.error.message, file=sys.stderr if not args.json else sys.stdout)
return 1
data = {
"query": query,
"chunks": chunks,
"count": len(chunks),
"deep": True,
"route_explanation": {
"primary_arm": "deep_search",
"query_rewrite": True,
"hybrid": True,
},
}
warnings: list[str] = []
next_actions: list[dict] = []
if len(chunks) == 0:
warnings.append("Deep search returned no results for the query.")
result = PFResult(
ok=True, command="retrieve", version=PF_VERSION, data=data, warnings=warnings, next_actions=next_actions
)
if args.json:
print(result.to_json())
else:
print(f"{len(chunks)} deep search results for: {query}")
for c in chunks:
print(f" [{c.get('source', '?')}] {c.get('title', c.get('paper_id', '?'))} ({c.get('year', '?')}): {c.get('text', '')[:80]}...")
return 0
# ── Standard vector retrieve ──────────────────────────────────
from paperforge.embedding import get_embed_status
status = get_embed_status(vault)

View file

@ -19,7 +19,7 @@ from paperforge.embedding.build_state import (
)
from paperforge.embedding.builder import embed_body_units, embed_paper, get_body_units_for_embedding, get_object_units_for_embedding, embed_object_units
from paperforge.embedding.preflight import _preflight_check
from paperforge.embedding.search import merge_retrieve, retrieve_chunks
from paperforge.embedding.search import hybrid_search, merge_retrieve, retrieve_chunks
from paperforge.embedding.status import get_embed_status
__all__ = [
@ -37,8 +37,8 @@ __all__ = [
"get_vector_build_state_path",
"get_vector_db_path",
"mark_vector_build_state",
"hybrid_search",
"merge_retrieve",
"migrate_chroma_to_vec0",
"read_vector_build_state",
"retrieve_chunks",
"write_vector_build_state",

View file

@ -0,0 +1,77 @@
"""Query expansion for @ Deep Search — abbreviation expansion and synonym mapping."""
from __future__ import annotations
import re
# Common orthopedic/medical abbreviations used in literature
ABBREVIATIONS: dict[str, str] = {
"ACL": "anterior cruciate ligament",
"PCL": "posterior cruciate ligament",
"MCL": "medial collateral ligament",
"LCL": "lateral collateral ligament",
"VTE": "venous thromboembolism",
"DVT": "deep vein thrombosis",
"RCT": "rotator cuff",
"OA": "osteoarthritis",
"THA": "total hip arthroplasty",
"TKA": "total knee arthroplasty",
"ROM": "range of motion",
"BMI": "body mass index",
"NSAID": "nonsteroidal anti-inflammatory drug",
"RSA": "reverse shoulder arthroplasty",
"TSA": "total shoulder arthroplasty",
"ORIF": "open reduction internal fixation",
"TFCC": "triangular fibrocartilage complex",
"CCI": "charlson comorbidity index",
"ASA": "american society of anesthesiologists",
"PROM": "patient reported outcome measure",
"PROMIS": "patient reported outcomes measurement information system",
}
MEDICAL_SYNONYMS: dict[str, list[str]] = {
"knee": ["knee joint", "genu"],
"shoulder": ["glenohumeral joint", "shoulder joint"],
"hip": ["hip joint", "acetabulofemoral joint"],
"elbow": ["elbow joint"],
"ankle": ["ankle joint", "talocrural joint"],
"wrist": ["wrist joint", "radiocarpal joint"],
"spine": ["vertebral column", "spinal column"],
"fracture": ["fracture", "break"],
"repair": ["repair", "reconstruction", "fixation"],
"replacement": ["arthroplasty", "replacement"],
"complication": ["complication", "adverse event", "comorbidity"],
"pain": ["pain", "analgesia", "pain management"],
}
def expand_query(query: str) -> list[str]:
"""Expand query by replacing known abbreviations with full forms.
Returns a list of query variants with the original query first,
followed by expanded versions (one per abbreviation expanded).
"""
variants = [query]
seen: set[str] = set()
# Try expanding all abbreviations
expanded = query
for abbr, full in ABBREVIATIONS.items():
pattern = re.compile(r"\b" + re.escape(abbr) + r"\b", re.IGNORECASE)
if pattern.search(query):
candidate = pattern.sub(full, expanded)
if candidate != expanded and candidate not in seen:
seen.add(candidate)
variants.append(candidate)
# If no abbreviation was expanded, try synonym expansion
if len(variants) == 1:
for term, synonyms in MEDICAL_SYNONYMS.items():
pattern = re.compile(r"\b" + re.escape(term) + r"\b", re.IGNORECASE)
if pattern.search(query):
for syn in synonyms:
candidate = pattern.sub(syn, query)
if candidate != query and candidate not in seen:
seen.add(candidate)
variants.append(candidate)
break # one synonym expansion is enough
return variants

View file

@ -125,3 +125,232 @@ def merge_retrieve(vault: Path, query: str, limit: int = 5, expand: bool = True)
return merged
finally:
conn.close()
def hybrid_search(vault: Path, query: str, limit: int = 10) -> list[dict]:
"""Hybrid search: BM25 FTS5 + vec0 k-NN with query rewrite.
Runs on body_units_fts (BM25) and vec_body/vec_objects (k-NN),
fuses scores (0.3 BM25 + 0.7 vec), and returns deduplicated results
with text snippets and source types.
Falls back to BM25-only when vec0 tables are unavailable or empty.
"""
logger = logging.getLogger(__name__)
from paperforge.embedding.query_rewrite import expand_query as do_expand
query_variants = do_expand(query)
db_path = get_memory_db_path(vault)
conn = get_connection(db_path, read_only=True)
try:
ensure_vec_extension(conn)
# ── Step 2: BM25 FTS5 ──────────────────────────────────────────
bm25_results: list[dict] = _bm25_search(conn, query_variants, limit * 2)
# ── Step 3: vec0 k-NN ─────────────────────────────────────────
vec_results: list[dict] = _vec_search(conn, vault, query, limit * 2)
# ── Step 4: Score fusion ────────────────────────────────────────
fused = _fuse_results(bm25_results, vec_results, limit)
return fused
finally:
conn.close()
def _bm25_search(
conn: sqlite3.Connection, query_variants: list[str], limit: int
) -> list[dict]:
"""Run BM25 (FTS5) search across body_units_fts for each query variant.
Returns results with BM25 scores normalized to [0, 1].
"""
seen: set[str] = set()
results: list[dict] = []
for qv in query_variants:
try:
rows = conn.execute(
"""SELECT
bu.unit_id,
bu.paper_id,
bu.section_path,
bu.section_title,
bu.unit_text,
p.title,
p.first_author,
p.year,
p.journal,
p.domain,
rank as bm25_raw
FROM body_units_fts bu_fts
JOIN body_units bu ON bu_fts.unit_id = bu.unit_id
JOIN papers p ON bu.paper_id = p.zotero_key
WHERE body_units_fts MATCH ?
ORDER BY rank
LIMIT ?""",
(qv, limit),
).fetchall()
except Exception as exc:
logger = logging.getLogger(__name__)
logger.warning("BM25 query failed for %r: %s", qv, exc)
continue
for row in rows:
key = (row["unit_id"], row["paper_id"])
if key in seen:
continue
seen.add(key)
bm25_raw = float(row["bm25_raw"])
# Normalize: invert sign so positive = better, then squash to [0, 1]
bm25_norm = 1.0 - (1.0 / (1.0 + abs(bm25_raw))) if bm25_raw != 0 else 0.0
results.append(
{
"unit_id": row["unit_id"],
"paper_id": row["paper_id"],
"title": row["title"],
"first_author": row["first_author"],
"year": row["year"],
"journal": row["journal"],
"domain": row["domain"],
"source": "body_unit",
"text": row["unit_text"],
"heading": row["section_title"],
"bm25_score": round(bm25_norm, 4),
"vec_score": 0.0,
}
)
results.sort(key=lambda r: r["bm25_score"], reverse=True)
return results[:limit]
def _vec_search(
conn: sqlite3.Connection, vault: Path, query: str, limit: int
) -> list[dict]:
"""Run vec0 k-NN on vec_body and vec_objects.
Gracefully returns empty list when vec0 extension or tables are missing.
"""
logger = logging.getLogger(__name__)
provider = OpenAICompatibleProvider(vault)
q_emb = provider.encode_single(query)
q_emb_json = json.dumps(q_emb)
results: list[dict] = []
for vec_table, source in _VEC_SOURCE_MAP.items():
if vec_table not in ("vec_body", "vec_objects"):
continue
meta_table = _VEC_META_MAP[vec_table]
try:
rows = conn.execute(
f"""SELECT m.paper_id, m.chunk_index, m.text, v.distance
FROM {vec_table} v
JOIN {meta_table} m ON v.rowid = m.rowid
WHERE v.embedding MATCH ? AND v.k = ?""",
(q_emb_json, limit),
).fetchall()
except Exception as exc:
logger.warning("vec0 query failed for %s: %s", vec_table, exc)
continue
for row in rows:
vec_sim = round(1.0 - row[3], 4) # convert distance to similarity
results.append(
{
"paper_id": row[0],
"source": source,
"text": row[2] or "",
"vec_score": vec_sim,
}
)
return results
def _fuse_results(
bm25_results: list[dict], vec_results: list[dict], limit: int
) -> list[dict]:
"""Fuse BM25 and vec0 results with combined score.
Fusion formula: combined = 0.3 * bm25_norm + 0.7 * vec_norm.
When vec0 is unavailable, uses BM25 scores directly.
Returns list deduplicated by (paper_id, text) with combined score.
"""
# Build vec lookup: (paper_id, text) -> vec_score
vec_lookup: dict[tuple[str, str], float] = {}
for vr in vec_results:
key = (vr["paper_id"], vr["text"])
# Keep the highest vec score per unique paper_id+text
if key not in vec_lookup or vr["vec_score"] > vec_lookup[key]:
vec_lookup[key] = vr["vec_score"]
has_vec = bool(vec_results)
for br in bm25_results:
bm25_norm = br["bm25_score"]
key = (br["paper_id"], br.get("text", ""))
if has_vec and key in vec_lookup:
vec_raw = vec_lookup[key]
# Normalize vec score similarly
vec_norm = 1.0 - (1.0 / (1.0 + vec_raw)) if vec_raw > 0 else 0.0
br["vec_score"] = round(vec_raw, 4)
combined = 0.3 * bm25_norm + 0.7 * vec_norm
br["score"] = round(combined, 4)
else:
# No vec data for this item — use BM25 alone
br["vec_score"] = 0.0
br["score"] = round(bm25_norm, 4)
# Sort by combined score descending
bm25_results.sort(key=lambda r: r["score"], reverse=True)
# Final dedup and cap
seen: set[tuple[str, str]] = set()
out: list[dict] = []
for r in bm25_results:
key = (r["paper_id"], r.get("text", ""))
if key in seen:
continue
seen.add(key)
# Include matched terms hint
r["matched_terms"] = ""
out.append(r)
if len(out) >= limit:
break
# If BM25 was empty but vec had results, synthesize entries
if not out and has_vec:
seen_text: set[tuple[str, str]] = set()
for vr in vec_results:
key = (vr["paper_id"], vr.get("text", ""))
if key in seen_text:
continue
seen_text.add(key)
vec_raw = vr["vec_score"]
vec_norm = 1.0 - (1.0 / (1.0 + vec_raw)) if vec_raw > 0 else 0.0
out.append(
{
"paper_id": vr["paper_id"],
"source": vr["source"],
"text": vr.get("text", ""),
"vec_score": round(vec_raw, 4),
"score": round(vec_norm, 4),
"matched_terms": "",
"heading": "",
"title": "",
"first_author": "",
"year": "",
"journal": "",
"domain": "",
}
)
if len(out) >= limit:
break
return out

File diff suppressed because it is too large Load diff

View file

@ -268,6 +268,21 @@ const LANG: Record<string, Record<string, string>> = {
maintenance_refresh_spinning: "Updating…",
maintenance_all_good: "✅ All good — no action needed",
maintenance_n_pending: "{n} need attention",
version_panel_title: "Version History",
version_panel_back: "Back",
version_filter_placeholder: "Filter papers...",
version_papers_count: "{n} papers",
version_current: "current",
version_restore_btn: "Restore",
version_compare_btn: "Compare",
version_restore_selected: "Restore selected",
version_clear_old: "Clear old versions (free {size})",
version_no_backups: "No version history available",
version_restore_confirm: "Restore {label} for {paper}?",
version_restore_done: "Restored {label}",
version_compare_title: "{vA} vs {vB}",
version_compare_paragraphs: "{n} paragraphs changed",
version_error_read: "Cannot read version data",
},
zh: {
@ -513,6 +528,21 @@ const LANG: Record<string, Record<string, string>> = {
maintenance_refresh_spinning: "正在更新…",
maintenance_all_good: "✅ 全部正常",
maintenance_n_pending: "{n} 篇需要处理",
version_panel_title: "版本历史",
version_panel_back: "返回",
version_filter_placeholder: "搜索论文...",
version_papers_count: "{n} 篇论文",
version_current: "当前",
version_restore_btn: "恢复",
version_compare_btn: "对比",
version_restore_selected: "恢复选中版本",
version_clear_old: "清除旧版本 (释放 {size})",
version_no_backups: "没有可恢复的历史版本",
version_restore_confirm: "确认将 {paper} 恢复到 {label}",
version_restore_done: "已恢复到 {label}",
version_compare_title: "{vA} vs {vB}",
version_compare_paragraphs: "{n} 段有变化",
version_error_read: "无法读取版本数据",
},
};

View file

@ -0,0 +1,285 @@
import * as fs from "fs";
import * as path from "path";
import { resolveVaultPaths } from "./memory-state";
// ── Types ──
export interface VersionEntry {
label: string;
created_at: string;
source: string;
renderer_version?: string;
structured_content_hash?: string;
fulltext_size: number;
}
export interface PaperVersionInfo {
key: string;
title: string;
versions: VersionEntry[];
currentLabel: string;
totalSize: number;
}
export interface DiffResult {
paragraphIndex: number;
heading: string;
type: "unchanged" | "added" | "removed" | "changed";
oldText?: string;
newText?: string;
}
// ── Helpers ──
function ocrRoot(vaultPath: string): string {
const paths = resolveVaultPaths(vaultPath);
return paths.ocrDir;
}
/** Read and parse manifest.json for a paper key. Returns null on any failure. */
function readManifest(
vaultPath: string,
paperKey: string
): { versions: VersionEntry[]; current: { label: string } } | null {
const manifestPath = path.join(
ocrRoot(vaultPath),
paperKey,
"versions",
"manifest.json"
);
try {
if (!fs.existsSync(manifestPath)) return null;
const raw = fs.readFileSync(manifestPath, "utf-8");
const parsed: unknown = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
"versions" in parsed &&
"current" in parsed
) {
const obj = parsed as Record<string, unknown>;
const versions = obj["versions"];
const current = obj["current"];
if (
Array.isArray(versions) &&
current &&
typeof current === "object" &&
"label" in current
) {
return parsed as {
versions: VersionEntry[];
current: { label: string };
};
}
}
return null;
} catch {
return null;
}
}
/** List all directories under the OCR root (each is a paper key). */
function listOcrPaperDirs(vaultPath: string): string[] {
const root = ocrRoot(vaultPath);
try {
if (!fs.existsSync(root)) return [];
return fs
.readdirSync(root, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
} catch {
return [];
}
}
// ── Exported API ──
/**
* Read version manifest for a single paper key.
* Returns the version list plus the current label, or null.
*/
export function scanVersions(
vaultPath: string,
paperKey: string
): { versions: VersionEntry[]; currentLabel: string } | null {
const manifest = readManifest(vaultPath, paperKey);
if (!manifest) return null;
return {
versions: manifest.versions,
currentLabel: manifest.current.label,
};
}
/**
* Scan all OCR paper directories for version manifests.
* Returns an array of PaperVersionInfo, one per paper that has backups.
*/
export function listPapersWithBackups(vaultPath: string): PaperVersionInfo[] {
const dirs = listOcrPaperDirs(vaultPath);
const results: PaperVersionInfo[] = [];
for (const key of dirs) {
const manifest = readManifest(vaultPath, key);
if (!manifest) continue;
const labels = manifest.versions.map((v) => v.label);
// Inline size estimate
let totalSize = 0;
for (const label of labels) {
const ftPath = path.join(
ocrRoot(vaultPath),
key,
"versions",
label,
"fulltext.md"
);
try {
if (fs.existsSync(ftPath)) {
totalSize += fs.statSync(ftPath).size;
}
} catch {
// skip
}
}
results.push({
key,
title: key.replace(/_/g, " "),
versions: manifest.versions,
currentLabel: manifest.current.label,
totalSize,
});
}
results.sort((a, b) => a.title.localeCompare(b.title));
return results;
}
/**
* Restore a specific version's fulltext.md to the render/ directory.
* Returns true on success, false on failure.
*/
export function restoreVersion(
vaultPath: string,
paperKey: string,
label: string
): boolean {
const root = ocrRoot(vaultPath);
const sourcePath = path.join(
root,
paperKey,
"versions",
label,
"fulltext.md"
);
const targetDir = path.join(root, paperKey, "render");
const targetPath = path.join(targetDir, "fulltext.md");
try {
if (!fs.existsSync(sourcePath)) return false;
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
fs.copyFileSync(sourcePath, targetPath);
return true;
} catch {
return false;
}
}
/**
* Paragraph-level diff between two version fulltext files.
* Paragraphs are split by double-newline or `## `-prefixed headings.
* Returns a list of changed paragraphs.
*/
export function compareVersions(
vaultPath: string,
paperKey: string,
labelA: string,
labelB: string
): DiffResult[] {
const root = ocrRoot(vaultPath);
const pathA = path.join(root, paperKey, "versions", labelA, "fulltext.md");
const pathB = path.join(root, paperKey, "versions", labelB, "fulltext.md");
let textA = "";
let textB = "";
try {
if (fs.existsSync(pathA)) textA = fs.readFileSync(pathA, "utf-8");
} catch {
/* empty */
}
try {
if (fs.existsSync(pathB)) textB = fs.readFileSync(pathB, "utf-8");
} catch {
/* empty */
}
const paragraphsA = splitParagraphs(textA);
const paragraphsB = splitParagraphs(textB);
const maxLen = Math.max(paragraphsA.length, paragraphsB.length);
const results: DiffResult[] = [];
for (let i = 0; i < maxLen; i++) {
const oldText = i < paragraphsA.length ? paragraphsA[i] : "";
const newText = i < paragraphsB.length ? paragraphsB[i] : "";
const firstHdrLine = (oldText || newText).split("\n")[0] ?? "";
const heading = firstHdrLine.startsWith("## ")
? firstHdrLine.replace(/^##\s+/, "")
: "";
let type: DiffResult["type"] = "unchanged";
if (!oldText && newText) {
type = "added";
} else if (oldText && !newText) {
type = "removed";
} else if (oldText !== newText) {
type = "changed";
}
if (type !== "unchanged") {
results.push({
paragraphIndex: i,
heading,
type,
oldText: oldText || undefined,
newText: newText || undefined,
});
}
}
return results;
}
// ── Internal diff helpers ──
function splitParagraphs(text: string): string[] {
// Split by `## `-prefixed headings or double-newline blocks
const lines = text.split("\n");
const blocks: string[] = [];
let current: string[] = [];
for (const line of lines) {
if (line.startsWith("## ") && current.length > 0) {
blocks.push(current.join("\n").trim());
current = [line];
} else if (line.trim() === "" && current.length > 0) {
// Double-newline boundary: commit if the last committed is not empty
const joined = current.join("\n").trim();
if (joined) {
blocks.push(joined);
current = [];
}
} else {
current.push(line);
}
}
if (current.length > 0) {
const joined = current.join("\n").trim();
if (joined) blocks.push(joined);
}
return blocks;
}

View file

@ -44,6 +44,13 @@ import {
import { getDisclosureState, toggleDisclosureState } from "../utils/disclosure";
import { extractZoteroKeyFromPath } from "../utils/zotero-path";
import { checkOrphanState } from "./modals";
import {
type PaperVersionInfo,
listPapersWithBackups,
scanVersions,
restoreVersion,
compareVersions,
} from "../services/version-history";
// ── Interface for plugin ref used by static open ──
@ -55,7 +62,7 @@ interface IPluginRef {
}
export class PaperForgeStatusView extends ItemView {
_currentMode: "global" | "paper" | "collection" | null = null;
_currentMode: "global" | "paper" | "collection" | "versions" | null = null;
_currentDomain: string | null = null;
_currentPaperKey: string | null = null;
_currentPaperEntry: Record<string, any> | null = null;
@ -81,6 +88,10 @@ export class PaperForgeStatusView extends ItemView {
_ocrCounts: HTMLElement | null = null;
_driftBannerEl: HTMLElement | null = null;
// ── Search state ──
// ── Version state ──
_versionPapers: PaperVersionInfo[] | null = null;
_versionFilter: string = "";
// ── Search state ──
_searchContainer: HTMLElement | null = null;
_searchInput: HTMLInputElement | null = null;
_searchResultsEl: HTMLElement | null = null;
@ -919,6 +930,9 @@ export class PaperForgeStatusView extends ItemView {
case "collection":
this._renderCollectionMode();
break;
case "versions":
this._renderVersionMode();
break;
}
}
@ -1353,6 +1367,14 @@ export class PaperForgeStatusView extends ItemView {
this._openFulltext(entry.fulltext_path)
);
}
// Version history button — always visible, versions mode handles empty state
const verBtn = stripRight.createEl("button", {
cls: "paperforge-contextual-btn",
});
verBtn.createEl("span", { text: t("version_panel_title") });
verBtn.addEventListener("click", () => {
this._switchToVersionMode(key!);
});
this._renderPaperOverviewCard(view, entry);
if (entry.next_step === "ready" && entry.deep_reading_status === "done") {
const complete = view.createEl("div", { cls: "paperforge-complete-row" });
@ -2028,6 +2050,9 @@ export class PaperForgeStatusView extends ItemView {
case "collection":
this._renderCollectionMode();
break;
case "versions":
this._renderVersionMode();
break;
}
} finally {
setTimeout(() => {
@ -2036,6 +2061,303 @@ export class PaperForgeStatusView extends ItemView {
}
}
/* ── Switch to Version Mode ── */
_switchToVersionMode(paperKey: string) {
const adapter = this.app.vault.adapter as unknown as Record<
string,
unknown
>;
const vp = adapter.basePath;
const vaultPath = typeof vp === "string" ? vp : "";
if (!vaultPath) {
new Notice("Cannot determine vault path");
return;
}
this._versionPapers = listPapersWithBackups(vaultPath);
this._versionFilter = "";
this._currentMode = "versions";
this._currentFilePath = null;
this._techDetailsExpanded = false;
if (!this._contentEl) return;
this._contentEl.empty();
this._contentEl.removeClass("switching");
this._renderModeHeader("versions");
this._renderVersionMode();
}
/* ── Version Mode Render: File Recovery-style Panel ── */
_renderVersionMode() {
if (!this._contentEl) return;
const view = this._contentEl.createEl("div", {
cls: "paperforge-version-panel",
});
const adapter = this.app.vault.adapter as unknown as Record<
string,
unknown
>;
const vp = adapter.basePath;
const vaultPath = typeof vp === "string" ? vp : "";
if (!vaultPath) {
view.createEl("div", {
cls: "paperforge-status-error",
text: "Could not determine vault path",
});
return;
}
// Re-scan if null
if (!this._versionPapers || this._versionPapers.length === 0) {
this._versionPapers = listPapersWithBackups(vaultPath);
}
// ── Left Panel: Filter + Paper List ──
const left = view.createEl("div", { cls: "paperforge-version-left" });
const right = view.createEl("div", { cls: "paperforge-version-right" });
// Filter input
const filterInput = left.createEl("input", {
cls: "paperforge-version-filter",
attr: { type: "text", placeholder: t("version_filter_placeholder") },
}) as HTMLInputElement;
filterInput.value = this._versionFilter;
// Paper list container
const paperList = left.createEl("div", {
cls: "paperforge-version-paper-list",
});
const renderPaperList = () => {
paperList.empty();
const filter = this._versionFilter.toLowerCase();
const filtered = this._versionPapers
? this._versionPapers.filter(
(p) =>
!filter ||
p.key.toLowerCase().includes(filter) ||
p.title.toLowerCase().includes(filter)
)
: [];
if (filtered.length === 0) {
paperList.createEl("div", {
cls: "paperforge-meta",
text: t("version_no_backups"),
});
return;
}
const countLabel = paperList.createEl("div", {
cls: "paperforge-meta",
text: t("version_papers_count").replace("{n}", String(filtered.length)),
});
for (const paper of filtered) {
const row = paperList.createEl("div", {
cls: "paperforge-version-paper-item",
});
const titleEl = row.createEl("span", {
cls: "paperforge-version-paper-title",
text: paper.title,
});
const badge = row.createEl("span", {
cls: "paperforge-version-paper-versions",
text: paper.versions.map((v) => v.label).join(" "),
});
row.addEventListener("click", () => {
// Highlight selected, show version timeline in right panel
paperList
.querySelectorAll(".paperforge-version-paper-item.selected")
.forEach((el) => el.removeClass("selected"));
row.addClass("selected");
renderTimeline(paper);
});
}
};
// Filter on input
filterInput.addEventListener("input", () => {
this._versionFilter = filterInput.value;
renderPaperList();
});
// ── Right Panel: Timeline ──
const timelineArea = right.createEl("div", {
cls: "paperforge-version-timeline-area",
});
const renderTimeline = (paper: PaperVersionInfo) => {
timelineArea.empty();
const header = timelineArea.createEl("div", {
cls: "paperforge-version-timeline-header",
});
header.createEl("span", { cls: "pf-title", text: paper.title });
if (paper.versions.length === 0) {
timelineArea.createEl("div", {
cls: "paperforge-meta",
text: t("version_no_backups"),
});
return;
}
// Version list as timeline
const timeline = timelineArea.createEl("div", {
cls: "paperforge-version-timeline",
});
for (const ver of paper.versions) {
const isCurrent = ver.label === paper.currentLabel;
const entry = timeline.createEl("div", {
cls:
"paperforge-version-entry" +
(isCurrent ? " paperforge-version-current" : ""),
});
const dot = entry.createEl("div", { cls: "paperforge-version-dot" });
const content = entry.createEl("div", {
cls: "paperforge-version-content",
});
const labelRow = content.createEl("div", {
cls: "paperforge-version-label-row",
});
labelRow.createEl("span", {
cls: "paperforge-version-label",
text: ver.label,
});
if (isCurrent) {
labelRow.createEl("span", {
cls: "paperforge-version-current-tag",
text: t("version_current"),
});
}
const dateStr = ver.created_at ? ver.created_at.slice(0, 10) : "";
content.createEl("div", {
cls: "paperforge-meta",
text: dateStr + " \u2014 " + ver.source,
});
const sizeStr = ver.fulltext_size
? ver.fulltext_size > 1024
? (ver.fulltext_size / 1024).toFixed(0) + "KB"
: ver.fulltext_size + "B"
: "";
if (sizeStr) {
content.createEl("div", { cls: "paperforge-meta", text: sizeStr });
}
// Action buttons
const actions = content.createEl("div", {
cls: "paperforge-version-actions",
});
const restoreBtn = actions.createEl("button", {
cls: "pf-btn-primary",
text: t("version_restore_btn"),
});
restoreBtn.addEventListener("click", () => {
const ok = restoreVersion(vaultPath, paper.key, ver.label);
if (ok) {
new Notice(t("version_restore_done").replace("{label}", ver.label));
} else {
new Notice("Restore failed", 6000);
}
});
if (paper.versions.length > 1 && !isCurrent) {
const compareBtn = actions.createEl("button", {
cls: "pf-btn-secondary",
text: t("version_compare_btn"),
});
compareBtn.addEventListener("click", () => {
renderComparison(paper, ver.label, paper.currentLabel);
});
}
}
};
// ── Comparison Area ──
const compareArea = right.createEl("div", {
cls: "paperforge-version-compare",
});
compareArea.style.display = "none";
const renderComparison = (
paper: PaperVersionInfo,
vA: string,
vB: string
) => {
const diffs = compareVersions(vaultPath, paper.key, vA, vB);
compareArea.style.display = "block";
compareArea.empty();
const header = compareArea.createEl("div", {
cls: "paperforge-version-compare-header",
});
header.createEl("span", {
cls: "pf-title",
text: t("version_compare_title")
.replace("{vA}", vA)
.replace("{vB}", vB),
});
header.createEl("span", {
cls: "paperforge-meta",
text: t("version_compare_paragraphs").replace(
"{n}",
String(diffs.length)
),
});
if (diffs.length === 0) {
compareArea.createEl("div", {
cls: "paperforge-meta",
text: "No changes",
});
return;
}
const diffList = compareArea.createEl("div", {
cls: "paperforge-version-diff-list",
});
for (const d of diffs) {
const diffRow = diffList.createEl("div", {
cls: "paperforge-version-diff-row",
});
const typeLabel =
d.type === "added" ? "[+]" : d.type === "removed" ? "[-]" : "[~]";
const headingLabel = d.heading || "paragraph " + (d.paragraphIndex + 1);
diffRow.createEl("span", {
cls: "paperforge-version-diff-label",
text: typeLabel + " " + headingLabel,
});
if (d.oldText) {
diffRow.createEl("pre", {
cls: "paperforge-version-diff-old",
text: d.oldText.slice(0, 200),
});
}
if (d.newText) {
diffRow.createEl("pre", {
cls: "paperforge-version-diff-new",
text: d.newText.slice(0, 200),
});
}
}
};
// ── Bottom Action Bar ──
const actionBar = view.createEl("div", {
cls: "paperforge-version-actions-bar",
});
const restoreSelectedBtn = actionBar.createEl("button", {
cls: "pf-btn-primary",
text: t("version_restore_selected"),
});
const clearOldBtn = actionBar.createEl("button", {
cls: "pf-btn-secondary",
text: t("version_clear_old").replace("{size}", ""),
});
// Initial render
renderPaperList();
}
/* ── Search Section ── */
renderSearchSection(view: HTMLElement) {
@ -2557,6 +2879,12 @@ export class PaperForgeStatusView extends ItemView {
if (this._headerTitle) this._headerTitle.setText("Collection");
modeName = this._currentDomain || "Unknown Domain";
break;
case "versions":
badge.addClass("versions");
badge.setText(t("version_panel_title"));
if (this._headerTitle)
this._headerTitle.setText(t("version_panel_title"));
break;
}
if (modeName) {
this._modeContextEl.createEl("span", {

View file

@ -4001,3 +4001,255 @@
color: var(--text-muted);
font-size: 13px;
}
/* ==========================================================================
SECTION 9 Version History Panel (File Recovery-style)
========================================================================== */
.paperforge-version-panel {
display: flex;
flex-direction: row;
gap: 0;
height: 100%;
min-height: 300px;
border: 1px solid var(--pf-border);
border-radius: var(--pf-radius);
overflow: hidden;
background: var(--pf-surface);
}
/* ── Left Panel ── */
.paperforge-version-left {
width: 240px;
min-width: 200px;
border-right: 1px solid var(--pf-border);
display: flex;
flex-direction: column;
background: var(--background-primary);
overflow: hidden;
}
.paperforge-version-filter {
margin: 8px;
padding: 6px 10px;
border: 1px solid var(--pf-border);
border-radius: var(--radius-s);
background: var(--background-primary);
color: var(--text-normal);
font-size: 13px;
outline: none;
}
.paperforge-version-filter:focus {
border-color: var(--pf-accent);
}
.paperforge-version-paper-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.paperforge-version-paper-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
transition: background 0.12s;
border-left: 3px solid transparent;
}
.paperforge-version-paper-item:hover {
background: var(--background-modifier-hover);
}
.paperforge-version-paper-item.selected {
background: var(--background-modifier-hover);
border-left-color: var(--pf-accent);
}
.paperforge-version-paper-title {
font-size: 13px;
font-weight: 500;
color: var(--text-normal);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.paperforge-version-paper-versions {
font-size: 11px;
color: var(--text-muted);
margin-left: 6px;
white-space: nowrap;
}
/* ── Right Panel ── */
.paperforge-version-right {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 12px;
gap: 12px;
}
.paperforge-version-timeline-area {
flex: 1;
}
.paperforge-version-timeline-header {
margin-bottom: 12px;
}
/* ── Timeline ── */
.paperforge-version-timeline {
display: flex;
flex-direction: column;
gap: 0;
position: relative;
padding-left: 20px;
}
.paperforge-version-timeline::before {
content: "";
position: absolute;
left: 6px;
top: 10px;
bottom: 10px;
width: 2px;
background: var(--pf-border);
border-radius: 1px;
}
.paperforge-version-entry {
display: flex;
gap: 10px;
padding: 8px 0;
position: relative;
}
.paperforge-version-dot {
position: relative;
z-index: 1;
width: 14px;
height: 14px;
min-width: 14px;
border-radius: 50%;
background: var(--pf-border);
border: 2px solid var(--pf-surface);
margin-top: 3px;
}
.paperforge-version-current .paperforge-version-dot {
background: var(--text-success);
border-color: var(--pf-surface);
}
.paperforge-version-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.paperforge-version-label-row {
display: flex;
align-items: center;
gap: 6px;
}
.paperforge-version-label {
font-size: 14px;
font-weight: 600;
color: var(--text-normal);
}
.paperforge-version-current-tag {
font-size: 11px;
font-weight: 500;
color: var(--text-success);
background: color-mix(in srgb, var(--text-success) 12%, transparent);
padding: 1px 6px;
border-radius: 4px;
}
.paperforge-version-actions {
display: flex;
gap: 6px;
margin-top: 4px;
}
/* ── Bottom Action Bar ── */
.paperforge-version-actions-bar {
display: flex;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--pf-border);
background: var(--pf-surface);
}
/* ── Comparison / Diff Preview ── */
.paperforge-version-compare {
border: 1px solid var(--pf-border);
border-radius: var(--pf-radius);
padding: 10px;
background: var(--pf-surface-alt);
max-height: 300px;
overflow-y: auto;
}
.paperforge-version-compare-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.paperforge-version-diff-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.paperforge-version-diff-row {
padding: 6px 8px;
border-radius: var(--radius-s);
background: var(--background-primary);
}
.paperforge-version-diff-label {
font-size: 12px;
font-weight: 600;
color: var(--text-normal);
display: block;
margin-bottom: 2px;
}
.paperforge-version-diff-old {
font-size: 12px;
color: var(--text-error);
margin: 2px 0;
padding: 2px 4px;
background: color-mix(in srgb, var(--text-error) 6%, transparent);
border-radius: 2px;
white-space: pre-wrap;
word-break: break-word;
max-height: 60px;
overflow-y: auto;
}
.paperforge-version-diff-new {
font-size: 12px;
color: var(--text-success);
margin: 2px 0;
padding: 2px 4px;
background: color-mix(in srgb, var(--text-success) 6%, transparent);
border-radius: 2px;
white-space: pre-wrap;
word-break: break-word;
max-height: 60px;
overflow-y: auto;
}

381
tests/test_deep_search.py Normal file
View file

@ -0,0 +1,381 @@
"""Tests for @ Deep Search: query rewrite + hybrid retrieval."""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from unittest.mock import patch
import pytest
import paperforge.config
from paperforge.worker._utils import pipeline_paths as _pp
paperforge.config.pipeline_paths = _pp
from paperforge.embedding.query_rewrite import expand_query, ABBREVIATIONS, MEDICAL_SYNONYMS
from paperforge.embedding.search import hybrid_search, _bm25_search, _fuse_results
from paperforge.memory.db import get_connection, get_memory_db_path
from paperforge.memory.schema import (
CREATE_BODY_UNITS,
CREATE_BODY_UNITS_FTS,
CREATE_PAPERS,
CREATE_OBJECT_UNITS,
CREATE_VEC_BODY,
CREATE_VEC_BODY_META,
CREATE_VEC_OBJECTS,
CREATE_VEC_OBJECTS_META,
ensure_schema,
)
EMBEDDING_DIM = 1536
# ---------------------------------------------------------------------------
# Mock provider — deterministic fixed embeddings
# ---------------------------------------------------------------------------
class FixedProvider:
"""Provider that returns deterministic embeddings for any text."""
def __init__(self, vault: Path | None = None):
self.vault = vault
def encode(self, texts: list[str]) -> list[list[float]]:
import hashlib
result = []
for t in texts:
h = hashlib.sha256(t.encode()).digest()
vec = [(h[i % 32] / 255.0) for i in range(EMBEDDING_DIM)]
result.append(vec)
return result
def encode_single(self, text: str) -> list[float]:
return self.encode([text])[0]
@pytest.fixture
def mock_provider():
return FixedProvider()
@pytest.fixture(autouse=True)
def _patch_providers(mock_provider):
"""Replace OpenAICompatibleProvider with the fixed mock."""
with patch("paperforge.embedding.search.OpenAICompatibleProvider", return_value=mock_provider):
yield
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_tables(conn: sqlite3.Connection) -> None:
"""Create tables needed for deep search tests."""
conn.executescript(CREATE_PAPERS)
conn.executescript(CREATE_BODY_UNITS)
conn.executescript(CREATE_BODY_UNITS_FTS)
conn.executescript(CREATE_OBJECT_UNITS)
conn.commit()
def _seed_paper(conn: sqlite3.Connection, paper_id: str, title: str) -> None:
"""Insert a paper row."""
conn.execute(
"""INSERT OR IGNORE INTO papers (zotero_key, title, year, first_author, journal, domain)
VALUES (?, ?, ?, ?, ?, ?)""",
(paper_id, title, "2023", "Author", "Test Journal", "ortho"),
)
conn.commit()
def _seed_body_unit(
conn: sqlite3.Connection,
unit_id: str,
paper_id: str,
text: str,
section_title: str = "Methods",
section_path: str = "methods",
indexable: int = 1,
) -> None:
"""Insert a body unit and its FTS index entry."""
conn.execute(
"""INSERT OR REPLACE INTO body_units
(unit_id, paper_id, section_path, section_path_json, section_level, section_title,
unit_text, unit_kind, part_ordinal, page_span_json, block_span_json,
token_estimate, indexable, veto_reason, quality_hints_json)
VALUES (?, ?, ?, '[]', 1, ?, ?, 'body', 0, '[]', '[]', 10, ?, '', '[]')""",
(unit_id, paper_id, section_path, section_title, text, indexable),
)
# FTS5 external content table may raise on DELETE when index is empty
try:
conn.execute(
"DELETE FROM body_units_fts WHERE unit_id = ?",
(unit_id,),
)
except sqlite3.DatabaseError:
pass
conn.execute(
"""INSERT INTO body_units_fts(rowid, unit_id, paper_id, section_path, unit_text)
SELECT rowid, unit_id, paper_id, section_path, unit_text
FROM body_units WHERE unit_id = ?""",
(unit_id,),
)
conn.commit()
def _create_vec_tables(conn: sqlite3.Connection) -> None:
"""Create vec0 tables (if sqlite-vec is available)."""
try:
from paperforge.memory.db import ensure_vec_extension
ensure_vec_extension(conn)
conn.executescript(CREATE_VEC_BODY)
conn.executescript(CREATE_VEC_BODY_META)
conn.executescript(CREATE_VEC_OBJECTS)
conn.executescript(CREATE_VEC_OBJECTS_META)
conn.commit()
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# Tests: query rewrite
# ---------------------------------------------------------------------------
class TestQueryRewrite:
def test_expand_abbreviation(self):
"""VTE -> venous thromboembolism."""
variants = expand_query("VTE prevention")
assert "VTE prevention" in variants
assert "venous thromboembolism prevention" in variants
def test_expand_multiple_abbreviations(self):
"""Each abbreviation produces a separate variant."""
variants = expand_query("ACL VTE")
assert len(variants) >= 2
def test_no_abbreviation_no_expansion(self):
"""No expansion when no abbreviations or synonyms are present."""
variants = expand_query("randomized controlled trial design")
assert len(variants) == 1
def test_synonym_expansion_fallback(self):
"""Synonym expansion when no abbreviations found."""
variants = expand_query("knee fracture")
assert len(variants) >= 2 # original + at least one expanded form
def test_abbreviations_dict_populated(self):
"""The abbreviations dictionary has entries."""
assert len(ABBREVIATIONS) > 5
def test_synonyms_dict_populated(self):
"""The synonyms dictionary has entries."""
assert len(MEDICAL_SYNONYMS) > 3
# ---------------------------------------------------------------------------
# Tests: hybrid search
# ---------------------------------------------------------------------------
class TestHybridSearch:
def test_bm25_only_fallback(self, tmp_path):
"""hybrid_search returns BM25 results when vec0 is unavailable."""
vault = tmp_path
db_path = get_memory_db_path(vault)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = get_connection(db_path)
_create_tables(conn)
_seed_paper(conn, "p1", "ACL Reconstruction Study")
_seed_body_unit(conn, "p1:u1", "p1", "The anterior cruciate ligament reconstruction technique used patellar tendon graft.", section_title="Methods")
_seed_body_unit(conn, "p1:u2", "p1", "Patients were followed for 2 years post-operatively.", section_title="Results")
conn.close()
results = hybrid_search(vault, "ACL reconstruction", limit=5)
assert isinstance(results, list)
# Should find BM25 content
assert len(results) >= 1
assert results[0]["paper_id"] == "p1"
assert "source" in results[0]
def test_hybrid_with_vec0(self, tmp_path, mock_provider):
"""hybrid_search runs BM25 + vec0 when vec0 tables exist."""
vault = tmp_path
db_path = get_memory_db_path(vault)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = get_connection(db_path)
_create_tables(conn)
vec_ok = _create_vec_tables(conn)
_seed_paper(conn, "p1", "VTE Prophylaxis Study")
_seed_body_unit(conn, "p1:u1", "p1", "Venous thromboembolism prophylaxis after total knee arthroplasty.", section_title="Methods")
conn.close()
results = hybrid_search(vault, "VTE prophylaxis", limit=5)
assert isinstance(results, list)
def test_empty_results(self, tmp_path):
"""hybrid_search returns empty list when nothing matches."""
vault = tmp_path
db_path = get_memory_db_path(vault)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = get_connection(db_path)
_create_tables(conn)
_seed_paper(conn, "p1", "Some Study")
_seed_body_unit(conn, "p1:u1", "p1", "Completely unrelated content about plants.", section_title="Intro")
conn.close()
results = hybrid_search(vault, "zzzznotfoundzzzz", limit=5)
assert isinstance(results, list)
assert len(results) == 0
def test_result_fields(self, tmp_path):
"""Each result has the expected fields."""
vault = tmp_path
db_path = get_memory_db_path(vault)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = get_connection(db_path)
_create_tables(conn)
_seed_paper(conn, "p1", "Knee OA Study")
_seed_body_unit(conn, "p1:u1", "p1", "Osteoarthritis of the knee joint is common.", section_title="Introduction")
conn.close()
results = hybrid_search(vault, "knee osteoarthritis", limit=5)
if results:
r = results[0]
assert "paper_id" in r
assert "title" in r
assert "source" in r
assert "text" in r
assert "score" in r
assert "heading" in r
assert r["source"] == "body_unit"
def test_query_rewrite_broadens_bm25(self, tmp_path):
"""Query with abbreviation finds content via expanded form."""
vault = tmp_path
db_path = get_memory_db_path(vault)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = get_connection(db_path)
_create_tables(conn)
_seed_paper(conn, "p1", "ACL Study")
# Only contains the full form, not the abbreviation
_seed_body_unit(conn, "p1:u1", "p1", "We reviewed anterior cruciate ligament reconstruction outcomes.", section_title="Methods")
# Contains neither
_seed_body_unit(conn, "p1:u2", "p1", "Patients were evaluated at 12 months.", section_title="Results")
conn.close()
results = hybrid_search(vault, "ACL reconstruction", limit=5)
# Should find the expanded form
matched = [r for r in results if "anterior cruciate ligament" in (r.get("text", "") or "")]
assert len(matched) >= 1
# ---------------------------------------------------------------------------
# Tests: score fusion logic
# ---------------------------------------------------------------------------
class TestScoreFusion:
def test_fuse_bm25_only(self):
"""Fusion returns BM25 scores unchanged when vec results are empty."""
bm25 = [
{"paper_id": "p1", "text": "text1", "bm25_score": 0.8, "vec_score": 0.0, "source": "body_unit",
"title": "T1", "first_author": "A", "year": "2023", "journal": "J", "domain": "ortho",
"heading": "Methods", "unit_id": "p1:u1", "matched_terms": ""},
]
vec: list[dict] = []
fused = _fuse_results(bm25, vec, limit=5)
assert len(fused) == 1
assert fused[0]["score"] == 0.8
assert fused[0]["vec_score"] == 0.0
def test_fuse_hybrid(self):
"""Fusion combines BM25 and vec scores."""
bm25 = [
{"paper_id": "p1", "text": "text1", "bm25_score": 0.5, "vec_score": 0.0, "source": "body_unit",
"title": "T1", "first_author": "A", "year": "2023", "journal": "J", "domain": "ortho",
"heading": "Methods", "unit_id": "p1:u1", "matched_terms": ""},
]
vec = [
{"paper_id": "p1", "text": "text1", "source": "body_unit", "vec_score": 0.8},
]
fused = _fuse_results(bm25, vec, limit=5)
assert len(fused) == 1
# combined = 0.3 * bm25_norm + 0.7 * vec_norm
# bm25_norm = 0.5 (already normalized)
# vec_norm = 1 - 1/(1+0.8) = 0.4444
# combined = 0.3*0.5 + 0.7*0.4444 = 0.15 + 0.3111 = 0.4611
assert fused[0]["score"] > 0.4
assert fused[0]["score"] < 0.5
def test_fuse_deduplicates(self):
"""Fusion deduplicates by (paper_id, text)."""
bm25 = [
{"paper_id": "p1", "text": "same text", "bm25_score": 0.8, "vec_score": 0.0, "source": "body_unit",
"title": "T1", "first_author": "A", "year": "2023", "journal": "J", "domain": "ortho",
"heading": "Methods", "unit_id": "p1:u1", "matched_terms": ""},
{"paper_id": "p1", "text": "same text", "bm25_score": 0.6, "vec_score": 0.0, "source": "body_unit",
"title": "T1", "first_author": "A", "year": "2023", "journal": "J", "domain": "ortho",
"heading": "Results", "unit_id": "p1:u2", "matched_terms": ""},
]
vec: list[dict] = []
fused = _fuse_results(bm25, vec, limit=5)
assert len(fused) == 1 # deduplicated by text
def test_vec_only_fallback(self):
"""When BM25 is empty, fusion falls back to vec results."""
bm25: list[dict] = []
vec = [
{"paper_id": "p1", "text": "vec result text", "source": "body_unit", "vec_score": 0.75},
]
fused = _fuse_results(bm25, vec, limit=5)
assert len(fused) == 1
assert fused[0]["paper_id"] == "p1"
assert fused[0]["vec_score"] > 0.7
# ---------------------------------------------------------------------------
# Tests: BM25 search internals
# ---------------------------------------------------------------------------
class TestBM25Search:
def test_bm25_finds_matching_content(self, tmp_path):
"""_bm25_search finds matching body units."""
db_path = get_memory_db_path(tmp_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = get_connection(db_path)
_create_tables(conn)
_seed_paper(conn, "p1", "Test Paper")
_seed_body_unit(conn, "p1:u1", "p1", "This text discusses knee replacement outcomes and complications.", section_title="Methods")
_seed_body_unit(conn, "p1:u2", "p1", "Introduction paragraph with general background.", section_title="Introduction")
conn.close()
conn = get_connection(db_path, read_only=True)
try:
results = _bm25_search(conn, ["knee replacement"], limit=10)
assert len(results) >= 1
assert results[0]["paper_id"] == "p1"
finally:
conn.close()
# ---------------------------------------------------------------------------
# Test CLI integration
# ---------------------------------------------------------------------------
class TestDeepFlag:
def test_deep_flag_parses(self):
"""--deep flag is accepted by the CLI parser."""
import argparse
from paperforge.cli import build_parser
parser = build_parser()
args = parser.parse_args(["retrieve", "VTE prevention", "--deep", "--json"])
assert args.deep is True
assert args.command == "retrieve"