docsmind 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {docsmind-0.2.0 → docsmind-0.3.0}/CHANGELOG.md +29 -3
  2. {docsmind-0.2.0 → docsmind-0.3.0}/PKG-INFO +58 -11
  3. {docsmind-0.2.0 → docsmind-0.3.0}/README.md +54 -10
  4. {docsmind-0.2.0 → docsmind-0.3.0}/pyproject.toml +13 -3
  5. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/storage/postgres/repository.py +125 -1
  6. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/storage/postgres/tables.py +17 -0
  7. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/cli/app.py +3 -0
  8. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/cli/ingest.py +6 -1
  9. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/cli/inspect.py +37 -0
  10. docsmind-0.3.0/src/docsmind/cli/serve.py +29 -0
  11. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/domain/documents.py +36 -7
  12. docsmind-0.3.0/src/docsmind/domain/errors.py +33 -0
  13. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/ingestion/chunking/page.py +53 -0
  14. docsmind-0.3.0/src/docsmind/ingestion/parsers/pdf.py +590 -0
  15. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/ingestion/pipeline.py +31 -1
  16. docsmind-0.3.0/src/docsmind/server/app.py +88 -0
  17. docsmind-0.3.0/src/docsmind/server/routes/documents.py +196 -0
  18. docsmind-0.3.0/src/docsmind/server/ui/assets/index-DSQOFo_H.css +1 -0
  19. docsmind-0.3.0/src/docsmind/server/ui/assets/index-QBP12Bcv.js +5385 -0
  20. docsmind-0.3.0/src/docsmind/server/ui/favicon.svg +1 -0
  21. docsmind-0.3.0/src/docsmind/server/ui/icons.svg +24 -0
  22. docsmind-0.3.0/src/docsmind/server/ui/index.html +14 -0
  23. docsmind-0.2.0/.env.example +0 -29
  24. docsmind-0.2.0/.github/workflows/publish.yml +0 -49
  25. docsmind-0.2.0/.github/workflows/test.yml +0 -59
  26. docsmind-0.2.0/.python-version +0 -1
  27. docsmind-0.2.0/alembic/env.py +0 -83
  28. docsmind-0.2.0/alembic/script.py.mako +0 -26
  29. docsmind-0.2.0/alembic/versions/0001_initial_schema.py +0 -173
  30. docsmind-0.2.0/alembic/versions/0002_full_text_and_stats.py +0 -36
  31. docsmind-0.2.0/alembic.ini +0 -43
  32. docsmind-0.2.0/docker-compose.yml +0 -20
  33. docsmind-0.2.0/examples/NIST.SP.800-171r2.pdf +0 -0
  34. docsmind-0.2.0/examples/NIST.SP.800-171r3.pdf +0 -0
  35. docsmind-0.2.0/examples/parse_pdf_demo.py +0 -77
  36. docsmind-0.2.0/examples/sample.pdf +0 -0
  37. docsmind-0.2.0/scripts/make_test_pdf.py +0 -118
  38. docsmind-0.2.0/src/docsmind/ingestion/parsers/pdf.py +0 -255
  39. docsmind-0.2.0/tests/unit/test_pdf_parser.py +0 -292
  40. docsmind-0.2.0/uv.lock +0 -5722
  41. {docsmind-0.2.0 → docsmind-0.3.0}/.gitignore +0 -0
  42. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/__init__.py +0 -0
  43. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/__init__.py +0 -0
  44. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/blobs/__init__.py +0 -0
  45. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/blobs/filesystem.py +0 -0
  46. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/embeddings/__init__.py +0 -0
  47. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/llm/__init__.py +0 -0
  48. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/storage/__init__.py +0 -0
  49. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/storage/postgres/__init__.py +0 -0
  50. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/storage/postgres/engine.py +0 -0
  51. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/tracing/__init__.py +0 -0
  52. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/adapters/vision/__init__.py +0 -0
  53. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/agent/__init__.py +0 -0
  54. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/agent/subagents/__init__.py +0 -0
  55. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/agent/subagents/change_analysis/__init__.py +0 -0
  56. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/agent/tools/__init__.py +0 -0
  57. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/cli/__init__.py +0 -0
  58. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/cli/chat.py +0 -0
  59. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/cli/db.py +0 -0
  60. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/cli/diff.py +0 -0
  61. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/cli/doctor.py +0 -0
  62. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/client.py +0 -0
  63. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/config.py +0 -0
  64. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/domain/__init__.py +0 -0
  65. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/domain/changes.py +0 -0
  66. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/domain/citations.py +0 -0
  67. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/domain/ports.py +0 -0
  68. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/domain/tables.py +0 -0
  69. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/ingestion/__init__.py +0 -0
  70. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/ingestion/chunking/__init__.py +0 -0
  71. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/ingestion/chunking/structural.py +0 -0
  72. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/ingestion/enrichment/__init__.py +0 -0
  73. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/ingestion/parsers/__init__.py +0 -0
  74. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/py.typed +0 -0
  75. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/retrieval/__init__.py +0 -0
  76. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/server/__init__.py +0 -0
  77. {docsmind-0.2.0 → docsmind-0.3.0}/src/docsmind/server/routes/__init__.py +0 -0
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.0] - 2026-10-03
6
+
7
+ ### Added
8
+
9
+ - Bundled frontend UI in the Python package: release UI assets are copied into
10
+ `src/docsmind/server/ui` and served by `docsmind serve` at `/`.
11
+ - `scripts/bundle_frontend.py` helper to copy `../frontend/dist` into the package
12
+ before publishing.
13
+ - `docsmind delete <version-id> [-y]` command to remove one ingested version and
14
+ its derived sections/chunks/images/tables.
15
+ - HTTP `DELETE /api/documents/{version_id}` endpoint.
16
+
17
+ ### Changed
18
+
19
+ - `docsmind serve` now serves the bundled SPA (when present), in addition to
20
+ `/api/*` and `/blobs/*`.
21
+ - Ingestion now checks `content_hash` before parsing and raises a friendly
22
+ "already ingested" error instead of surfacing a raw Postgres unique-constraint
23
+ failure.
24
+
25
+ ### Fixed
26
+
27
+ - Image extraction de-duplicates repeated embedded images (e.g. page logo reused
28
+ across many pages) using perceptual hash, preventing inflated image counts.
29
+ - API duplicate-ingest responses now return HTTP 409 with structured details.
30
+
31
+
32
+
5
33
  ## [0.2.0] - 2026-09-27
6
34
 
7
35
  First functional release: documents can now be ingested from PDF into
@@ -70,9 +98,7 @@ implemented" below).
70
98
  - Embeddings and hybrid vector/keyword search. Chunks are stored without
71
99
  embeddings, and `hybrid_search` raises `NotImplementedError`.
72
100
  - Image captioning with a vision model.
73
- - The chat agent behind `docsmind chat` and the change-analysis subagent
74
- behind `docsmind diff`. Both commands exist but have no implementation
75
- behind them yet.
101
+ - The chat agent behind `docsmind chat`.
76
102
  - DOCX and plain-text parsers, S3/Supabase blob storage, and the server/UI.
77
103
  - Multi-column reading order: PDFs with genuine multi-column layouts may have
78
104
  their text interleaved in the saved full text.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docsmind
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Chat with your documents. Version-aware retrieval, change analysis, and an editable table workspace, backed by Postgres/pgvector.
5
5
  Project-URL: Homepage, https://github.com/yauheniya-ai/docsmind
6
6
  Project-URL: Repository, https://github.com/yauheniya-ai/docsmind
@@ -29,11 +29,13 @@ Requires-Dist: pydantic>=2.6
29
29
  Requires-Dist: pymupdf>=1.24
30
30
  Requires-Dist: rich>=13.7
31
31
  Requires-Dist: sqlalchemy[asyncio]>=2.0
32
+ Requires-Dist: tiktoken>=0.7
32
33
  Requires-Dist: typer>=0.12
33
34
  Provides-Extra: all
34
35
  Requires-Dist: anthropic>=0.34; extra == 'all'
35
36
  Requires-Dist: fastapi>=0.111; extra == 'all'
36
37
  Requires-Dist: openai>=1.30; extra == 'all'
38
+ Requires-Dist: python-multipart>=0.0.9; extra == 'all'
37
39
  Requires-Dist: uvicorn[standard]>=0.30; extra == 'all'
38
40
  Provides-Extra: anthropic
39
41
  Requires-Dist: anthropic>=0.34; extra == 'anthropic'
@@ -49,6 +51,7 @@ Provides-Extra: openai
49
51
  Requires-Dist: openai>=1.30; extra == 'openai'
50
52
  Provides-Extra: server
51
53
  Requires-Dist: fastapi>=0.111; extra == 'server'
54
+ Requires-Dist: python-multipart>=0.0.9; extra == 'server'
52
55
  Requires-Dist: uvicorn[standard]>=0.30; extra == 'server'
53
56
  Description-Content-Type: text/markdown
54
57
 
@@ -58,8 +61,6 @@ Description-Content-Type: text/markdown
58
61
  [![Python versions](https://img.shields.io/pypi/pyversions/docsmind.svg)](https://pypi.org/project/docsmind/)
59
62
  [![Downloads](https://static.pepy.tech/badge/docsmind)](https://pepy.tech/project/docsmind)
60
63
  [![Downloads / month](https://static.pepy.tech/badge/docsmind/month)](https://pepy.tech/project/docsmind)
61
- [![Tests](https://github.com/yauheniya-ai/docsmind/actions/workflows/test.yml/badge.svg)](https://github.com/yauheniya-ai/docsmind/actions/workflows/test.yml)
62
- [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/yauheniya-ai/<GIST_ID>/raw/docsmind-coverage.json)](https://github.com/yauheniya-ai/docsmind/actions/workflows/test.yml)
63
64
  [![License: MIT](https://img.shields.io/pypi/l/docsmind.svg)](https://github.com/yauheniya-ai/docsmind/blob/main/LICENSE)
64
65
  [![Code style: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
65
66
  [![Status: alpha](https://img.shields.io/badge/status-alpha-orange.svg)](#status)
@@ -82,11 +83,13 @@ its compliance implications, with citations back to exact pages.
82
83
  | ✅ Postgres + pgvector storage | Local Docker, or any Postgres with pgvector (Neon, Supabase) |
83
84
  | ✅ Inspection CLI | `versions`, `fulltext`, `outline`, `chunks` read back exactly what was saved |
84
85
  | ✅ `docsmind doctor` | Checks database, blob storage and API keys, and tells you what to fix |
85
- | 🚧 Embeddings + hybrid search | Chunks are stored without embeddings for now |
86
- | 🚧 Image captioning | Images are extracted and stored; vision captioning is next |
86
+ | ✅ Table extraction | Extract and store tables skipping repeating ones (e.g. headers or footers) |
87
+ | ✅ server + UI | FastAPI backend (`docsmind serve`) + React UI (`frontend/`): add documents, browse indexed content, image and table galleries |
87
88
  | 🚧 Chat agent (the Mind) | `docsmind chat` exists as a command, without an agent behind it yet |
89
+ | 🚧 Image captioning | Images are extracted and stored; vision captioning is next |
90
+ | 🚧 Embeddings + hybrid search | Chunks are stored without embeddings for now |
88
91
  | 🚧 Change analysis (diffra) | `docsmind diff` exists as a command, without an implementation yet |
89
- | 🚧 DOCX/text parsers, S3/Supabase blob storage, server + UI | Planned |
92
+ | 🚧 DOCX/text parsers, S3/Supabase blob storage | Planned |
90
93
 
91
94
  ## Install
92
95
 
@@ -96,7 +99,7 @@ pip install docsmind
96
99
  pip install "docsmind[all]"
97
100
  ```
98
101
 
99
- Extras: `docsmind[openai]`, `docsmind[anthropic]`, `docsmind[server]` (FastAPI, planned UI),
102
+ Extras: `docsmind[openai]`, `docsmind[anthropic]`, `docsmind[server]` (FastAPI, serves the bundled UI),
100
103
  `docsmind[mlflow]` (tracing/eval export). Requires Python 3.10+.
101
104
 
102
105
  ## Quickstart
@@ -158,7 +161,33 @@ docsmind chunks <version-id> --page 12 # the chunk(s) saved for one page
158
161
 
159
162
  You can also inspect the tables directly with `docsmind db psql`.
160
163
 
161
- ### 4. Coming next
164
+ ### 4. Browse it in the UI
165
+
166
+ ```bash
167
+ pip install "docsmind[server]"
168
+ docsmind serve # FastAPI backend on http://127.0.0.1:8000
169
+ ```
170
+
171
+ Open `http://127.0.0.1:8000`.
172
+
173
+ The React UI is bundled into the Python package and served directly by `docsmind serve`
174
+ from `docsmind/src/docsmind/server/ui`.
175
+
176
+ Clicking **+ Add Document** in the UI runs the same `docsmind ingest` pipeline on the
177
+ uploaded PDF. Selecting a document shows its saved full text/outline (**Content**), and
178
+ galleries of everything extracted from it (**Images**, **Tables**).
179
+
180
+ For contributors refreshing bundled UI assets before a release:
181
+
182
+ ```bash
183
+ cd ../frontend
184
+ npm install
185
+ npm run build
186
+ cd ../docsmind
187
+ python scripts/bundle_frontend.py
188
+ ```
189
+
190
+ ### 5. Coming next
162
191
 
163
192
  ```python
164
193
  print(dm.chat("What are our data retention obligations?")) # planned
@@ -180,6 +209,9 @@ heuristics that work on one PDF break on the next.
180
209
  reconstruct structure.
181
210
  - **Images** are extracted with their page and position, skipping tiny icons, and saved
182
211
  under a readable path: `images/<collection>/<title>/<version>-<id>/`.
212
+ Visually repeated images (e.g. logo on every page) are de-duplicated by perceptual hash.
213
+ - **Tables** are detected with PyMuPDF and rendered as PNG snippets, saved under
214
+ `tables/<collection>/<title>/<version>-<id>/`.
183
215
 
184
216
  ## CLI reference
185
217
 
@@ -190,8 +222,10 @@ heuristics that work on one PDF break on the next.
190
222
  | `docsmind fulltext <id> [--full]` | Print the saved full text |
191
223
  | `docsmind outline <id>` | Print the saved outline |
192
224
  | `docsmind chunks <id> [--page N] [--full]` | Print saved chunks |
225
+ | `docsmind delete <id> [-y]` | Delete one version and its derived chunks/images/tables |
193
226
  | `docsmind db up` / `upgrade` / `psql` | Start local Postgres, apply migrations, open psql |
194
227
  | `docsmind doctor` | Check your setup |
228
+ | `docsmind serve [--host] [--port] [--reload]` | Run the FastAPI backend for the `frontend/` UI |
195
229
  | `docsmind chat`, `docsmind diff` | Planned |
196
230
 
197
231
  ## Architecture
@@ -223,11 +257,22 @@ src/docsmind/
223
257
  ├── cli/ # Typer app: ingest, versions, fulltext, outline, chunks, db, doctor
224
258
  ├── retrieval/ # hybrid vector + keyword search (planned)
225
259
  ├── agent/ # the Mind + diffra subagent (planned)
226
- └── server/ # FastAPI + UI (planned)
260
+ └── server/ # FastAPI app + routes backing frontend/ (documents: ingest, read back content/images/tables)
227
261
 
228
- alembic/ tests/ examples/ docs/ ui/
262
+ alembic/ tests/ examples/ docs/
229
263
  ```
230
264
 
265
+ The UI source lives one level up as a sibling project: `frontend/` (React + TypeScript +
266
+ TailwindCSS, see the repo-root `CLAUDE.md`). Release builds are bundled into
267
+ `src/docsmind/server/ui` and served by `server/app.py` at `/`. The UI talks to `server/`
268
+ over `/api/*`, and loads extracted images/tables straight from disk via `/blobs/*`.
269
+
270
+ ## Artwork attribution
271
+
272
+ - Workspace 3D graph artwork inspiration: Angela Galliat,
273
+ "Neural nervous system of a brain" (CodePen):
274
+ https://codepen.io/agalliat/pen/vYGXJxQ
275
+
231
276
  ### Design notes
232
277
 
233
278
  - **Ports and adapters, once.** `domain/ports.py` defines every external dependency as a
@@ -242,7 +287,9 @@ alembic/ tests/ examples/ docs/ ui/
242
287
  Outline paths (e.g. `03.13.11`) give version comparison something stable to align on
243
288
  where a document has a table of contents.
244
289
  - **Tables are shared state, not a UI feature.** `AgentTable` is a domain object that
245
- both the agent and the user write to (append-only revisions).
290
+ both the agent and the user write to (append-only revisions). This is distinct from
291
+ the `tables` storage table (extracted PDF table *regions*, rendered as images
292
+ alongside `images` — browsable read-only in the UI's **Tables** gallery).
246
293
 
247
294
  ## Development
248
295
 
@@ -4,8 +4,6 @@
4
4
  [![Python versions](https://img.shields.io/pypi/pyversions/docsmind.svg)](https://pypi.org/project/docsmind/)
5
5
  [![Downloads](https://static.pepy.tech/badge/docsmind)](https://pepy.tech/project/docsmind)
6
6
  [![Downloads / month](https://static.pepy.tech/badge/docsmind/month)](https://pepy.tech/project/docsmind)
7
- [![Tests](https://github.com/yauheniya-ai/docsmind/actions/workflows/test.yml/badge.svg)](https://github.com/yauheniya-ai/docsmind/actions/workflows/test.yml)
8
- [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/yauheniya-ai/<GIST_ID>/raw/docsmind-coverage.json)](https://github.com/yauheniya-ai/docsmind/actions/workflows/test.yml)
9
7
  [![License: MIT](https://img.shields.io/pypi/l/docsmind.svg)](https://github.com/yauheniya-ai/docsmind/blob/main/LICENSE)
10
8
  [![Code style: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
11
9
  [![Status: alpha](https://img.shields.io/badge/status-alpha-orange.svg)](#status)
@@ -28,11 +26,13 @@ its compliance implications, with citations back to exact pages.
28
26
  | ✅ Postgres + pgvector storage | Local Docker, or any Postgres with pgvector (Neon, Supabase) |
29
27
  | ✅ Inspection CLI | `versions`, `fulltext`, `outline`, `chunks` read back exactly what was saved |
30
28
  | ✅ `docsmind doctor` | Checks database, blob storage and API keys, and tells you what to fix |
31
- | 🚧 Embeddings + hybrid search | Chunks are stored without embeddings for now |
32
- | 🚧 Image captioning | Images are extracted and stored; vision captioning is next |
29
+ | ✅ Table extraction | Extract and store tables skipping repeating ones (e.g. headers or footers) |
30
+ | ✅ server + UI | FastAPI backend (`docsmind serve`) + React UI (`frontend/`): add documents, browse indexed content, image and table galleries |
33
31
  | 🚧 Chat agent (the Mind) | `docsmind chat` exists as a command, without an agent behind it yet |
32
+ | 🚧 Image captioning | Images are extracted and stored; vision captioning is next |
33
+ | 🚧 Embeddings + hybrid search | Chunks are stored without embeddings for now |
34
34
  | 🚧 Change analysis (diffra) | `docsmind diff` exists as a command, without an implementation yet |
35
- | 🚧 DOCX/text parsers, S3/Supabase blob storage, server + UI | Planned |
35
+ | 🚧 DOCX/text parsers, S3/Supabase blob storage | Planned |
36
36
 
37
37
  ## Install
38
38
 
@@ -42,7 +42,7 @@ pip install docsmind
42
42
  pip install "docsmind[all]"
43
43
  ```
44
44
 
45
- Extras: `docsmind[openai]`, `docsmind[anthropic]`, `docsmind[server]` (FastAPI, planned UI),
45
+ Extras: `docsmind[openai]`, `docsmind[anthropic]`, `docsmind[server]` (FastAPI, serves the bundled UI),
46
46
  `docsmind[mlflow]` (tracing/eval export). Requires Python 3.10+.
47
47
 
48
48
  ## Quickstart
@@ -104,7 +104,33 @@ docsmind chunks <version-id> --page 12 # the chunk(s) saved for one page
104
104
 
105
105
  You can also inspect the tables directly with `docsmind db psql`.
106
106
 
107
- ### 4. Coming next
107
+ ### 4. Browse it in the UI
108
+
109
+ ```bash
110
+ pip install "docsmind[server]"
111
+ docsmind serve # FastAPI backend on http://127.0.0.1:8000
112
+ ```
113
+
114
+ Open `http://127.0.0.1:8000`.
115
+
116
+ The React UI is bundled into the Python package and served directly by `docsmind serve`
117
+ from `docsmind/src/docsmind/server/ui`.
118
+
119
+ Clicking **+ Add Document** in the UI runs the same `docsmind ingest` pipeline on the
120
+ uploaded PDF. Selecting a document shows its saved full text/outline (**Content**), and
121
+ galleries of everything extracted from it (**Images**, **Tables**).
122
+
123
+ For contributors refreshing bundled UI assets before a release:
124
+
125
+ ```bash
126
+ cd ../frontend
127
+ npm install
128
+ npm run build
129
+ cd ../docsmind
130
+ python scripts/bundle_frontend.py
131
+ ```
132
+
133
+ ### 5. Coming next
108
134
 
109
135
  ```python
110
136
  print(dm.chat("What are our data retention obligations?")) # planned
@@ -126,6 +152,9 @@ heuristics that work on one PDF break on the next.
126
152
  reconstruct structure.
127
153
  - **Images** are extracted with their page and position, skipping tiny icons, and saved
128
154
  under a readable path: `images/<collection>/<title>/<version>-<id>/`.
155
+ Visually repeated images (e.g. logo on every page) are de-duplicated by perceptual hash.
156
+ - **Tables** are detected with PyMuPDF and rendered as PNG snippets, saved under
157
+ `tables/<collection>/<title>/<version>-<id>/`.
129
158
 
130
159
  ## CLI reference
131
160
 
@@ -136,8 +165,10 @@ heuristics that work on one PDF break on the next.
136
165
  | `docsmind fulltext <id> [--full]` | Print the saved full text |
137
166
  | `docsmind outline <id>` | Print the saved outline |
138
167
  | `docsmind chunks <id> [--page N] [--full]` | Print saved chunks |
168
+ | `docsmind delete <id> [-y]` | Delete one version and its derived chunks/images/tables |
139
169
  | `docsmind db up` / `upgrade` / `psql` | Start local Postgres, apply migrations, open psql |
140
170
  | `docsmind doctor` | Check your setup |
171
+ | `docsmind serve [--host] [--port] [--reload]` | Run the FastAPI backend for the `frontend/` UI |
141
172
  | `docsmind chat`, `docsmind diff` | Planned |
142
173
 
143
174
  ## Architecture
@@ -169,11 +200,22 @@ src/docsmind/
169
200
  ├── cli/ # Typer app: ingest, versions, fulltext, outline, chunks, db, doctor
170
201
  ├── retrieval/ # hybrid vector + keyword search (planned)
171
202
  ├── agent/ # the Mind + diffra subagent (planned)
172
- └── server/ # FastAPI + UI (planned)
203
+ └── server/ # FastAPI app + routes backing frontend/ (documents: ingest, read back content/images/tables)
173
204
 
174
- alembic/ tests/ examples/ docs/ ui/
205
+ alembic/ tests/ examples/ docs/
175
206
  ```
176
207
 
208
+ The UI source lives one level up as a sibling project: `frontend/` (React + TypeScript +
209
+ TailwindCSS, see the repo-root `CLAUDE.md`). Release builds are bundled into
210
+ `src/docsmind/server/ui` and served by `server/app.py` at `/`. The UI talks to `server/`
211
+ over `/api/*`, and loads extracted images/tables straight from disk via `/blobs/*`.
212
+
213
+ ## Artwork attribution
214
+
215
+ - Workspace 3D graph artwork inspiration: Angela Galliat,
216
+ "Neural nervous system of a brain" (CodePen):
217
+ https://codepen.io/agalliat/pen/vYGXJxQ
218
+
177
219
  ### Design notes
178
220
 
179
221
  - **Ports and adapters, once.** `domain/ports.py` defines every external dependency as a
@@ -188,7 +230,9 @@ alembic/ tests/ examples/ docs/ ui/
188
230
  Outline paths (e.g. `03.13.11`) give version comparison something stable to align on
189
231
  where a document has a table of contents.
190
232
  - **Tables are shared state, not a UI feature.** `AgentTable` is a domain object that
191
- both the agent and the user write to (append-only revisions).
233
+ both the agent and the user write to (append-only revisions). This is distinct from
234
+ the `tables` storage table (extracted PDF table *regions*, rendered as images
235
+ alongside `images` — browsable read-only in the UI's **Tables** gallery).
192
236
 
193
237
  ## Development
194
238
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "docsmind"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Chat with your documents. Version-aware retrieval, change analysis, and an editable table workspace, backed by Postgres/pgvector."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -38,12 +38,13 @@ dependencies = [
38
38
  "pillow>=10.0",
39
39
  "imagehash>=4.3",
40
40
  "rich>=13.7",
41
+ "tiktoken>=0.7",
41
42
  ]
42
43
 
43
44
  [project.optional-dependencies]
44
45
  openai = ["openai>=1.30"]
45
46
  anthropic = ["anthropic>=0.34"]
46
- server = ["fastapi>=0.111", "uvicorn[standard]>=0.30"]
47
+ server = ["fastapi>=0.111", "uvicorn[standard]>=0.30", "python-multipart>=0.0.9"]
47
48
  mlflow = ["mlflow>=2.14"]
48
49
  dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "testcontainers[postgres]>=4.4", "ruff>=0.5", "mypy>=1.10"]
49
50
  all = ["docsmind[openai,anthropic,server]"]
@@ -61,6 +62,15 @@ Issues = "https://github.com/yauheniya-ai/docsmind/issues"
61
62
 
62
63
  [tool.hatch.build.targets.wheel]
63
64
  packages = ["src/docsmind"]
65
+ include = ["src/docsmind/server/ui/**"]
66
+
67
+ [tool.hatch.build.targets.sdist]
68
+ include = [
69
+ "src/docsmind/**",
70
+ "README.md",
71
+ "CHANGELOG.md",
72
+ "scripts/bundle_frontend.py",
73
+ ]
64
74
 
65
75
  [tool.pytest.ini_options]
66
76
  asyncio_mode = "auto"
@@ -79,4 +89,4 @@ target-version = "py310"
79
89
  # image is corrupt" — and a single malformed image in an otherwise-fine PDF
80
90
  # shouldn't abort the whole ingest, so we deliberately catch broadly here and
81
91
  # skip the one image rather than propagate.
82
- "src/docsmind/ingestion/parsers/pdf.py" = ["BLE001"]
92
+ "src/docsmind/ingestion/parsers/pdf.py" = ["BLE001"]
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  from uuid import UUID
16
16
 
17
- from sqlalchemy import insert, select
17
+ from sqlalchemy import delete, insert, select
18
18
 
19
19
  from docsmind.adapters.storage.postgres.engine import get_sessionmaker
20
20
  from docsmind.adapters.storage.postgres.tables import (
@@ -32,6 +32,9 @@ from docsmind.adapters.storage.postgres.tables import (
32
32
  from docsmind.adapters.storage.postgres.tables import (
33
33
  sections as sections_table,
34
34
  )
35
+ from docsmind.adapters.storage.postgres.tables import (
36
+ tables as tables_table,
37
+ )
35
38
  from docsmind.config import DocsmindConfig
36
39
  from docsmind.domain.documents import (
37
40
  Chunk,
@@ -137,6 +140,27 @@ class PostgresRepository:
137
140
  insert(chunks_table),
138
141
  [self._chunk_row(c) for c in parsed.chunks],
139
142
  )
143
+ if parsed.tables:
144
+ await session.execute(
145
+ insert(tables_table),
146
+ [
147
+ {
148
+ "id": t.id,
149
+ "version_id": t.version_id,
150
+ "page_number": t.page_number,
151
+ "bbox_x0": t.bbox[0],
152
+ "bbox_y0": t.bbox[1],
153
+ "bbox_x1": t.bbox[2],
154
+ "bbox_y1": t.bbox[3],
155
+ "uri": t.uri,
156
+ "width": t.width,
157
+ "height": t.height,
158
+ "format": t.format,
159
+ "phash": t.phash,
160
+ }
161
+ for t in parsed.tables
162
+ ],
163
+ )
140
164
  await session.commit()
141
165
 
142
166
  @staticmethod
@@ -205,6 +229,7 @@ class PostgresRepository:
205
229
  async with self._sessionmaker() as session:
206
230
  stmt = (
207
231
  select(
232
+ documents_table.c.id.label("document_id"),
208
233
  documents_table.c.title,
209
234
  documents_table.c.collection,
210
235
  document_versions_table.c.id,
@@ -223,6 +248,80 @@ class PostgresRepository:
223
248
  result = await session.execute(stmt)
224
249
  return [dict(row) for row in result.mappings().all()]
225
250
 
251
+ async def get_version(self, version_id: UUID) -> dict | None:
252
+ """Metadata for one version, joined back to its parent `Document` —
253
+ everything the detail view needs besides full text/outline/chunks."""
254
+ async with self._sessionmaker() as session:
255
+ stmt = (
256
+ select(
257
+ documents_table.c.id.label("document_id"),
258
+ documents_table.c.title,
259
+ documents_table.c.collection,
260
+ document_versions_table.c.id,
261
+ document_versions_table.c.label,
262
+ document_versions_table.c.created_at,
263
+ document_versions_table.c.char_count,
264
+ document_versions_table.c.word_count,
265
+ document_versions_table.c.token_count_estimate,
266
+ document_versions_table.c.blob_uri,
267
+ )
268
+ .select_from(
269
+ document_versions_table.join(
270
+ documents_table, documents_table.c.id == document_versions_table.c.document_id
271
+ )
272
+ )
273
+ .where(document_versions_table.c.id == version_id)
274
+ )
275
+ result = await session.execute(stmt)
276
+ row = result.mappings().first()
277
+ return dict(row) if row else None
278
+
279
+ async def get_version_by_content_hash(self, content_hash: str) -> dict | None:
280
+ """Look up an already-ingested version by its file content hash —
281
+ used *before* parsing to detect "this exact file is already here"
282
+ without wastefully re-extracting images/tables first. Returns the
283
+ same shape as `get_version` (joined back to the parent `Document`)."""
284
+ async with self._sessionmaker() as session:
285
+ stmt = (
286
+ select(
287
+ documents_table.c.id.label("document_id"),
288
+ documents_table.c.title,
289
+ documents_table.c.collection,
290
+ document_versions_table.c.id,
291
+ document_versions_table.c.label,
292
+ document_versions_table.c.created_at,
293
+ )
294
+ .select_from(
295
+ document_versions_table.join(
296
+ documents_table, documents_table.c.id == document_versions_table.c.document_id
297
+ )
298
+ )
299
+ .where(document_versions_table.c.content_hash == content_hash)
300
+ )
301
+ result = await session.execute(stmt)
302
+ row = result.mappings().first()
303
+ return dict(row) if row else None
304
+
305
+ async def delete_version(self, version_id: UUID) -> bool:
306
+ """Delete one `DocumentVersion` and everything that hangs off it
307
+ (sections/images/tables/chunks all have `ON DELETE CASCADE` back to
308
+ `document_versions` — see migrations 0001-0003). Useful for clearing
309
+ out a version that was ingested before a pipeline fix (e.g. the
310
+ image-dedup / table-persistence fixes) so it can be cleanly
311
+ re-ingested. Does *not* delete the parent `Document` row, even if
312
+ this was its only version — `documents` rows are cheap and harmless
313
+ to leave orphaned, and deleting them too would cascade-delete any
314
+ sibling versions.
315
+
316
+ Returns True if a row was deleted, False if `version_id` didn't exist.
317
+ """
318
+ async with self._sessionmaker() as session:
319
+ result = await session.execute(
320
+ delete(document_versions_table).where(document_versions_table.c.id == version_id)
321
+ )
322
+ await session.commit()
323
+ return result.rowcount > 0
324
+
226
325
  async def get_full_text(self, version_id: UUID) -> str | None:
227
326
  async with self._sessionmaker() as session:
228
327
  result = await session.execute(
@@ -266,4 +365,29 @@ class PostgresRepository:
266
365
  if page is not None:
267
366
  stmt = stmt.where(chunks_table.c.page_start == page)
268
367
  result = await session.execute(stmt)
368
+ return [dict(row) for row in result.mappings().all()]
369
+
370
+ async def get_images(self, version_id: UUID) -> list[dict]:
371
+ """Saved images for one version, in page order — `uri` is a
372
+ `file://...` (or other `BlobStore`-specific) URI; the server layer
373
+ resolves these into servable URLs, not this repository."""
374
+ async with self._sessionmaker() as session:
375
+ stmt = (
376
+ select(images_table)
377
+ .where(images_table.c.version_id == version_id)
378
+ .order_by(images_table.c.page_number)
379
+ )
380
+ result = await session.execute(stmt)
381
+ return [dict(row) for row in result.mappings().all()]
382
+
383
+ async def get_tables(self, version_id: UUID) -> list[dict]:
384
+ """Saved extracted tables (rendered as images) for one version, in
385
+ page order."""
386
+ async with self._sessionmaker() as session:
387
+ stmt = (
388
+ select(tables_table)
389
+ .where(tables_table.c.version_id == version_id)
390
+ .order_by(tables_table.c.page_number)
391
+ )
392
+ result = await session.execute(stmt)
269
393
  return [dict(row) for row in result.mappings().all()]
@@ -96,6 +96,23 @@ images = Table(
96
96
  Column("captioned_at", DateTime(timezone=True), nullable=True),
97
97
  )
98
98
 
99
+ tables = Table(
100
+ "tables",
101
+ metadata,
102
+ Column("id", UUID(as_uuid=True), primary_key=True),
103
+ Column("version_id", UUID(as_uuid=True), ForeignKey("document_versions.id", ondelete="CASCADE"), nullable=False),
104
+ Column("page_number", Integer, nullable=False),
105
+ Column("bbox_x0", Float, nullable=False),
106
+ Column("bbox_y0", Float, nullable=False),
107
+ Column("bbox_x1", Float, nullable=False),
108
+ Column("bbox_y1", Float, nullable=False),
109
+ Column("uri", Text, nullable=False),
110
+ Column("width", Integer, nullable=False),
111
+ Column("height", Integer, nullable=False),
112
+ Column("format", Text, nullable=False),
113
+ Column("phash", Text, nullable=True),
114
+ )
115
+
99
116
  _chunks_columns = [
100
117
  Column("id", UUID(as_uuid=True), primary_key=True),
101
118
  Column("version_id", UUID(as_uuid=True), ForeignKey("document_versions.id", ondelete="CASCADE"), nullable=False),
@@ -23,6 +23,7 @@ app.command("doctor")(doctor.doctor)
23
23
  from docsmind.cli import chat as _chat
24
24
  from docsmind.cli import diff as _diff
25
25
  from docsmind.cli import inspect as _inspect
26
+ from docsmind.cli import serve as _serve
26
27
 
27
28
  app.command("chat")(_chat.chat)
28
29
  app.command("diff")(_diff.diff)
@@ -30,6 +31,8 @@ app.command("versions")(_inspect.versions)
30
31
  app.command("fulltext")(_inspect.fulltext)
31
32
  app.command("outline")(_inspect.outline)
32
33
  app.command("chunks")(_inspect.chunks)
34
+ app.command("delete")(_inspect.delete)
35
+ app.command("serve")(_serve.serve)
33
36
 
34
37
 
35
38
  def main() -> None:
@@ -16,7 +16,12 @@ def ingest(
16
16
  ),
17
17
  ) -> None:
18
18
  from docsmind import Docsmind
19
+ from docsmind.domain.errors import DocumentAlreadyExistsError
19
20
 
20
21
  dm = Docsmind()
21
- result = dm.ingest(path, collection=collection, version=version)
22
+ try:
23
+ result = dm.ingest(path, collection=collection, version=version)
24
+ except DocumentAlreadyExistsError as exc:
25
+ console.print(f"[yellow]Already ingested.[/yellow] {exc}")
26
+ raise typer.Exit(code=1) from None
22
27
  console.print(f"[green]Ingested[/green] {path.name} -> version {result.id} ({result.label})")
@@ -169,4 +169,41 @@ def chunks(
169
169
  suffix = "..." if len(content) > 200 else ""
170
170
  console.print(f"[{label}] TEXT ({word_count} words): {preview}{suffix}")
171
171
 
172
+ asyncio.run(_run())
173
+
174
+
175
+ def delete(
176
+ version_id: UUID = typer.Argument(..., help="A version id from `docsmind versions`."),
177
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
178
+ ) -> None:
179
+ """Delete one ingested version (sections/images/tables/chunks cascade with it).
180
+
181
+ Useful for clearing out a version that was ingested before a pipeline
182
+ fix landed (e.g. the image-dedup or table-persistence fixes) so you can
183
+ re-ingest the same file cleanly afterwards — re-ingesting identical bytes
184
+ is otherwise a no-op (see `DocumentAlreadyExistsError`).
185
+ """
186
+
187
+ async def _run() -> None:
188
+ from docsmind.adapters.storage.postgres.repository import PostgresRepository
189
+ from docsmind.config import DocsmindConfig
190
+
191
+ repo = PostgresRepository(DocsmindConfig())
192
+ existing = await repo.get_version(version_id)
193
+ if existing is None:
194
+ console.print(f"[yellow]No version found with id {version_id}.[/yellow]")
195
+ return
196
+
197
+ if not yes:
198
+ confirm = typer.confirm(
199
+ f"Delete '{existing['title']}' ({existing['collection']} / {existing['label']}, "
200
+ f"version {version_id})? This also deletes its chunks, images and tables."
201
+ )
202
+ if not confirm:
203
+ console.print("Aborted.")
204
+ return
205
+
206
+ await repo.delete_version(version_id)
207
+ console.print(f"[green]Deleted[/green] version {version_id}.")
208
+
172
209
  asyncio.run(_run())
@@ -0,0 +1,29 @@
1
+ """`docsmind serve` — runs the FastAPI app (`server/app.py`) behind uvicorn,
2
+ for the `frontend/` UI to talk to. Requires the `server` extra:
3
+ `pip install "docsmind[server]"`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ console = Console()
12
+
13
+
14
+ def serve(
15
+ host: str = typer.Option("127.0.0.1", "--host", help="Bind address."),
16
+ port: int = typer.Option(8000, "--port", "-p", help="Bind port."),
17
+ reload: bool = typer.Option(False, "--reload", help="Auto-reload on code changes (dev only)."),
18
+ ) -> None:
19
+ """Start the docsmind API server used by the frontend UI."""
20
+ try:
21
+ import uvicorn
22
+ except ImportError as exc: # pragma: no cover - import guard
23
+ console.print(
24
+ "[red]uvicorn isn't installed.[/red] Run `pip install \"docsmind[server]\"` first."
25
+ )
26
+ raise typer.Exit(1) from exc
27
+
28
+ console.print(f"[green]Starting docsmind server[/green] on http://{host}:{port}")
29
+ uvicorn.run("docsmind.server.app:app", host=host, port=port, reload=reload)