mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
#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:
parent
27e45f9419
commit
4ff165ae49
13 changed files with 4627 additions and 1673 deletions
910
docs/design-version-history-panel.excalidraw
Normal file
910
docs/design-version-history-panel.excalidraw
Normal 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"
|
||||
}
|
||||
}
|
||||
2
docs/design-version-history-panel.svg
Normal file
2
docs/design-version-history-panel.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
77
paperforge/embedding/query_rewrite.py
Normal file
77
paperforge/embedding/query_rewrite.py
Normal 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
|
||||
|
|
@ -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
|
|
@ -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: "无法读取版本数据",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
285
paperforge/plugin/src/services/version-history.ts
Normal file
285
paperforge/plugin/src/services/version-history.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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
381
tests/test_deep_search.py
Normal 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"
|
||||
Loading…
Reference in a new issue