Textus
당신이 펜을 잡으면 AI가 잉크를 이어줍니다
저장소 정보는 아직 수집하지 않았습니다.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
DocMind: chat with your PDFs and notes, with answers that cite the page. RAG on Gemini embeddings + TiDB vector search, FastAPI + React, live on Vercel.
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Live demo: https://docmind-kohl-pi.vercel.app (free, no card; sign up with any email. It's 18+ only, and uploads go to Google's free Gemini tier, so don't upload anything confidential.) The API is at https://docmind-api-kappa.vercel.app/health.
A multi-user AI knowledge workspace: upload documents, organize them into
collections, and chat against one document, several, or a whole collection —
with every answer exposing exactly which chunks (and, for PDFs, which real
page numbers) it was grounded in, and an explicit refusal when nothing in
your documents supports the question. Started as a small single-file RAG
demo (chat-with-your-documents); this is the full-stack, production-shaped
version: JWT auth (including password reset), a relational data model,
per-user document CRUD, collections, persisted conversations, page-accurate
citations, a command palette, real usage analytics, and a small honest RAG
evaluation framework — all on top of an embedding retrieval pipeline that runs
entirely inside TiDB Cloud (native vector search).
| Landing page | Documents |
|---|---|
The screenshots are from the live deployment, using a demo account and a fictional sample handbook (both deleted afterwards).
Upload → Organize → Search → Chat → Cite → Save
Public pages (Landing, Features, Login, Signup, Forgot/Reset Password) sit in front of the authenticated workspace (Dashboard, Documents, Collections, Chat, Conversations, Sources, Analytics, Settings) — a "Liquid Glass" design system (blue/cyan/white/soft-neutral glass, restrained — no neon) used consistently across both.
backend/ FastAPI service
main.py app wiring, CORS, lifespan (DB init)
config.py env var loading
db.py SQLAlchemy engine/session
models.py User, Document, Collection, Conversation, Message, Source,
PasswordResetToken, EvalCase
schemas.py Pydantic request/response models
auth.py password hashing (bcrypt) + JWT + reset-token hashing
ratelimit.py hand-rolled in-memory per-IP rate limiter
deps.py current-user dependency, ownership-check helper, injectable Gemini client
ingestion.py file validation + per-page text extraction (page numbers survive here)
rag.py chunking, Gemini embeddings, vector retrieval in TiDB, scoped by user_id/document_id
chat_logic.py Gemini prompting + streaming (shared by /conversations/{id}/messages);
distinguishes "no documents in scope" (casual chat) from "documents in
scope but nothing relevant retrieved" (must refuse, not free-answer)
routers/ one router per resource: auth, documents, collections, conversations,
dashboard, sources, evaluation
tests/ pytest suite (73 tests) — see "Testing" below
frontend/ React 19 + Vite + React Router + Tailwind CSS v4
src/api/ fetch wrapper + one module per resource
src/context/ AuthContext, ToastContext, ThemeContext
src/hooks/useChatStream.js streaming fetch + citation parsing, shared by the chat page
src/components/glass/ Glass* component library (Card, Button, Input, Modal, Toast wrapper,
Skeleton, EmptyState, DocumentCard, ChatBubble/SourceCard re-exports,
Navbar, Sidebar)
src/components/ layout/, chat/, documents/, collections/, dashboard/, common/
src/pages/ Landing, Features, Login, Signup, ForgotPassword, ResetPassword,
Dashboard, Documents, DocumentDetail, Collections, CollectionDetail,
NewChat, Chat, Conversations, Sources, Analytics (Usage + Evaluation
tabs), Settings, NotFound
Data model. One database holds everything. In production that's TiDB
Cloud (MySQL-compatible, free tier): relational rows, the original uploaded
bytes (documents.file_data), and chunk embeddings in a native
VECTOR(768) column (chunks.embedding). Locally and in tests it's SQLite,
with vectors stored as JSON and cosine distance computed in Python. The
structure: Document.collection_id is a nullable one-to-one FK (a
document lives in at most one collection, matching the "folder" metaphor —
University/{Database.pdf, DSA.pdf, Networks.pdf}). Conversation records
its retrieval scope as either a collection_id or a many-to-many set of
documents (conversation_documents). Source is its own table: one row
per citation on a specific assistant Message (which document, which
chunk, which real page number if any, the retrieved snippet, the retrieval
distance, and its rank) — not a duplicate of Document, since it's
per-message citation facts, not document identity. PasswordResetToken
follows the same "never store the secret itself" discipline as password
hashing — only a SHA-256 hash of the token is persisted.
flowchart LR
subgraph Ingestion
U["Upload"] --> V["Validate<br/>type / size / hash"]
V --> X["Extract text<br/>per PDF page"]
X --> C["Chunk per page<br/>(800 chars, 150 overlap)"]
C --> EMB1["Embed<br/>(Gemini gemini-embedding-001, 768-d)"]
EMB1 --> CH[("TiDB chunks table<br/>VECTOR(768) + user_id, document_id,<br/>page_number?")]
end
subgraph "Question time"
Q["Question"] --> EMB2["Embed query"]
EMB2 --> SIM["VEC_COSINE_DISTANCE search<br/>scoped to user + selection"]
CH --> SIM
SIM --> G{"Relevant chunks<br/>found?"}
G -- yes --> GEN["Gemini<br/>answer strictly from excerpts"]
G -- no --> REF["Explicit refusal<br/>never falls back to free knowledge"]
GEN --> OUT["Streamed answer<br/>+ citations (doc, page, chunk)"]
end
classDef stage fill:#6C5CE7,stroke:#4834B0,color:#fff
class C,EMB1,EMB2,GEN stage
Chunking. RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=150),
run per PDF page, not on the whole document joined into one string. This
is what makes real page numbers possible: ingestion.py:extract_pages()
returns [(page_number, text), ...] per page via pdfplumber, and each
page's chunks carry that page number into their chunks row. TXT/MD files
have no concept of a page — their citations simply omit page_number
rather than fabricating one.
Retrieval scoping. Every vector query always filters on user_id first
(defense-in-depth alongside the relational ownership checks — a chunk query
can never cross users even if an authorization check elsewhere had a bug).
A conversation scoped to specific documents filters on document_id IN (...); a conversation scoped to a collection resolves the collection's
current ready-document membership via SQL first, then runs the vector
query with that document-id list — so removing a document from a collection
immediately stops it being searched.
Grounding, precisely. The system prompt (chat_logic.py) distinguishes
three cases, not two: (1) no documents selected at all — casual chat,
general-knowledge answers are fine; (2) documents selected and relevant
excerpts retrieved — answer strictly from them; (3) documents selected
but nothing relevant was retrieved for this specific question — say
plainly that the information couldn't be found in the selected documents,
never fall back to free-knowledge. Case (3) previously produced the exact
same bare-question prompt as case (1), so the model couldn't tell a
genuine grounding failure apart from a greeting — build_prompt()'s
has_scope flag fixes that; verified live: "What is the boiling point of
liquid nitrogen in Kelvin?", asked in a conversation scoped to an unrelated
document, produces "I couldn't find that information in the selected
documents" rather than the (correct, but out-of-scope) real answer.
Semantic document search. GET /documents/semantic-search?q= reuses
rag.retrieve_chunks() with no document filter (only the user-id scope),
groups hits by document, and returns each document's best-ranked chunk as
a snippet — the same retrieval path chat uses, exposed as a standalone
"search your documents' content" feature rather than a new pipeline.
| Table | Key columns |
|---|---|
users | email (unique), hashed_password, is_active |
documents | user_id, collection_id?, display_name, content_hash (unique per user), file_data (original bytes), status (pending/processing/ready/failed), status_error, page_count?, chunk_count |
chunks | user_id, document_id, chunk_index, page_number?, text, embedding (VECTOR(768) on TiDB) |
collections | user_id, name, description? |
conversations | user_id, scope_type (document_set/collection), scope_collection_id? |
conversation_documents | conversation_id, document_id (association table) |
messages | conversation_id, role, content, think_longer? |
sources | message_id, document_id, chunk_id, chunk_index, page_number?, snippet, distance?, rank |
password_reset_tokens | user_id, token_hash (unique), expires_at, used_at? |
eval_cases | user_id, question, expected_answer?, expected_source_document_id?, expected_page? |
No migration framework is used or needed — db.py:init_db() calls
SQLAlchemy's Base.metadata.create_all(), which safely adds new tables
(like password_reset_tokens) without touching existing ones.
All routes except /health, /auth/register, /auth/login, and
/auth/forgot-password//auth/reset-password require
Authorization: Bearer <token>. Every owned-resource route (documents,
collections, conversations, sources, eval cases) is scoped to the current
user; accessing another user's resource returns 404, not 403, so a
request can't even confirm the resource exists. login, register, and
forgot-password are additionally rate-limited per client IP.
| Method & path | Purpose |
|---|---|
POST /auth/register | Create an account. accept_terms: true is required; the accepted TERMS_VERSION and time are recorded |
POST /auth/login | Get a JWT |
POST /auth/logout | No-op server-side (stateless JWT) — client discards the token |
GET /auth/me | Current user |
POST /auth/forgot-password | {email} → generates a reset token if the email exists; see Password reset below |
POST /auth/reset-password | {token, new_password} → consumes the token, sets the new password |
PATCH /auth/change-password | {current_password, new_password} |
DELETE /auth/me | {password} → delete own account (cascades documents/collections/conversations + their chunks). The password is required so a stolen token alone can't destroy an account |
GET /auth/me/export | Everything stored about the current user as JSON (right of access / portability) |
POST /documents | Upload (multipart file, optional ?collection_id=) — dedup-aware |
GET /documents | List (?collection_id=&status_filter=&q= — q filters by filename) |
GET /documents/semantic-search | ?q=&limit= — searches document content, grouped by document |
GET /documents/{id} | Detail |
GET /documents/{id}/file | Download the original file |
PATCH /documents/{id} | Rename / move (display_name?, collection_id?) |
DELETE /documents/{id} | Deletes the document, its stored bytes, and its chunks |
POST /collections | Create |
GET /collections | List (with document_count) |
The flow is a hashed, single-use token that expires after 30 minutes and is
generated with secrets.token_urlsafe. How the link reaches the user
depends on the deployment, and POST /auth/forgot-password reports it as
delivery:
email: SMTP_* is set, so backend/mailer.py emails the link.unavailable: no SMTP (current production). Nothing is sent, and the
Forgot Password page points to /contact.demo: local development only, when PASSWORD_RESET_DEMO_MODE=true.
The link comes back in the response and shows in a labelled banner.The response is identical for registered and unregistered emails, so the endpoint can't be used to find out who has an account. (An earlier version returned the link to anyone, which allowed account takeover. That was fixed and deployed on 11 October 2026.)
Backend:
cd backend
pip install -r requirements-dev.txt
cp .env.example .env # add your GEMINI_API_KEY (free at aistudio.google.com/apikey)
uvicorn main:app --reload
Frontend:
cd frontend
npm install
cp .env.example .env # only needed if the API isn't on localhost:8000
npm run dev
| Variable | Default | Notes |
|---|---|---|
GEMINI_API_KEY | — | required for chat/eval to work |
GEMINI_MODEL | gemini-3.5-flash-lite | the -lite tier has a much higher free-tier daily quota |
JWT_SECRET | random, generated at startup | set this in production — without it, all tokens are invalidated on every restart (a clear startup warning is printed either way) |
JWT_EXPIRE_MINUTES | 120 | |
DATABASE_URL | sqlite:///./app.db | production: mysql+pymysql://USER:PASS@HOST:4000/docmind (TiDB Cloud; TLS is automatic) |
EMBEDDING_MODEL | gemini-embedding-001 | |
EMBEDDING_DIM | 768 | must match the chunks.embedding column — changing it means re-ingesting |
MAX_RELEVANT_DISTANCE | 0.40 | cosine-distance cutoff, calibrated on Gemini embeddings (relevant ≈0.22–0.27, greetings ≈0.41, off-topic ≥0.46) |
MAX_FILE_SIZE_MB | 20 | set to 4 on Vercel — its request-body limit is 4.5 MB |
CORS_ORIGINS | http://localhost:5173 | comma-separated |
FRONTEND_URL | http://localhost:5173 | used only to build the password-reset link (see above) |
TERMS_VERSION | 2026-10-11 | recorded against each signup; bump together with POLICY.termsVersion in frontend/src/config/site.js |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM | unset (port 587) | enables password-reset email; unset = delivery: "unavailable" |
PASSWORD_RESET_DEMO_MODE |
The Analytics page has two tabs:
GET /dashboard/analytics: documents by
processing status, total questions asked, and a 30-day daily activity
chart. Computed straight from SQL, nothing simulated.GET/POST /eval/cases + POST /eval/run. Two signals,
both honest about their limits:
expected_source_document_id actually among the retrieved
chunks' document ids? No LLM involved.expected_answer and returning a
0–1 score. Every result labels this explicitly as
"gemini-self-judge, not a rigorous eval" — a heuristic, not a
benchmark, and a score is only ever shown when the judge's reply parses
cleanly as a number.Runs are computed on demand and returned in the response, not persisted as run history — matches "a small, manually-defined test set," not a production eval pipeline.
cd backend
pytest
81 tests across tests/test_auth.py, test_password_reset.py,
test_documents.py, test_search.py, test_collections.py,
test_conversations.py, test_dashboard.py, test_sources.py,
test_evaluation.py, test_authorization.py. Each test gets an isolated
temp SQLite file, and the Gemini embedding call is swapped for a
deterministic offline bag-of-words fake (tests/conftest.py).
Gemini is replaced by a deterministic fake by default (FakeGeminiClient
in conftest.py) via app.dependency_overrides — no test hits the real
API or burns quota. Real PDFs for page-number tests are generated on the
fly with reportlab (tests/pdf_helpers.py). The auth rate limiter is
disabled for the whole suite (many tests register users back-to-back)
except in test_password_reset.py, where one test re-enables it via
monkeypatch to prove it actually triggers.
Authorization is tested at two levels: each resource's own test file
checks ownership at the point of testing that resource, and
test_authorization.py is a consolidated sweep asserting the blanket rule
holds everywhere (every read/write/delete on every resource type — now
including sources, semantic search, change-password, and delete-account —
cross-user, returns 404 or 401) plus a path-traversal filename test and a
tampered-JWT test.
Frontend: cd frontend && npm run build (production build),
npm run lint (oxlint) and npm run test:e2e (Playwright, 65 tests).
The e2e suite runs against a production build. Every API call is mocked in
the browser, so it needs no backend or Gemini key, and an unmocked call
fails the test instead of reaching production. It covers:
e2e/csp.spec.js, which replays the production CSP from vercel.json
and fails on any violation.Run npx playwright install chromium once before the first run. The full golden path (landing → signup → upload
→ chat with real grounded answers and citations → verify the grounding
refusal on an unsupported question → collections → conversations
rename/search → sources → analytics (both tabs) → settings
change-password/delete-account → forgot/reset password → command palette
→ mobile responsive check) was exercised end to end in a real browser
against a real GEMINI_API_KEY. Two real bugs were found and fixed this
way, not by inspection: (1) the grounding gap described above, and (2) the
Analytics usage chart's bars used percentage heights inside a flex column
with no sized ancestor (an items-end flex container doesn't stretch
children to fill it), so every bar silently collapsed to zero height —
fixed by giving each day's column an explicit h-full. The Chat page's
left panel (conversation switcher + scope) also had no responsive
collapse at all on mobile before this pass; it now collapses into a
toggleable drawer below the md breakpoint, matching the right-hand
sources panel's existing lg: collapse.
Live on Vercel + TiDB Cloud, all on free tiers with no card:
docmind, root frontend/)/health; Vercel project docmind-api, root backend/, FastAPI via index.py)docmind (Singapore)Deploy with vercel deploy --prod from frontend/ and then
backend/, always together (frontend first: the new frontend works with
the old API, but not the other way round): the API's signup and account-deletion
contracts changed with the terms-acceptance work, so a backend deployed on
its own breaks the live frontend. See docs/LAUNCH_READINESS.md for the
checklist, smoke test and rollback. The API function is pinned to sin1
(Singapore) in backend/vercel.json, next to the database. Backend
env vars (production): DATABASE_URL, GEMINI_API_KEY, GEMINI_MODEL,
JWT_SECRET, MAX_FILE_SIZE_MB=4, CORS_ORIGINS and FRONTEND_URL (the
app URL). The frontend needs VITE_API_URL, which is baked in at build time.
Tables are created on startup by init_db().
The original deployment was Azure (App Service + Static Web Apps, with
SQLite, Chroma and uploads on the /home disk). Moving to serverless meant
removing every dependency on a local disk and on heavy local models:
ORDER BY VEC_COSINE_DISTANCE(embedding, :q)).documents.file_data, LONGBLOB),
served by the ownership-gated /documents/{id}/file route.DATETIME drops tzinfo, so a UTCDateTime column
type stores UTC and re-attaches it on load.resource.user_id == current_user.id and returns 404 on mismatch.login, register, and forgot-password are rate-limited per client IP
(backend/ratelimit.py, a small hand-rolled in-memory fixed-window
limiter — no Redis/slowapi dependency). It resets on process restart and
isn't shared across workers or instances — a real, disclosed limitation.GEMINI_API_KEY is read server-side only (config.py), never exposed
to the frontend.connect-src limited to the API), plus HSTS,
X-Frame-Options: DENY, nosniff, a referrer policy and a permissions
policy (frontend/vercel.json). The API sets its own security headers.GET /auth/me/export, and
account deletion needs the password. Third parties and what they
receive: docs/THIRD_PARTY_AUDIT.md..env files (both backend/.env and frontend/.env) are gitignored;
.env.example/.env.production stay tracked since they hold no real
secrets.Beyond the original project's RAG fundamentals (chunking, embeddings, vector retrieval, grounded generation, streaming, citation UX), this version demonstrates:
user_id scoping, not a separate vector store
per user.npm run build) that
caught two real bugs — a CSS layout bug (percentage heights need a
sized ancestor) and a missing responsive breakpoint — that unit tests
and a code read-through would not have caught.GET /collections/{id} | Detail + documents |
PATCH /collections/{id} | Rename / edit description |
DELETE /collections/{id} | Un-files its documents (doesn't delete them) |
POST /collections/{id}/documents?document_id= | Add a document |
DELETE /collections/{id}/documents/{document_id} | Remove a document |
POST /conversations | Create (scope_type, document_ids[] or collection_id) |
GET /conversations | List, most recent first |
GET /conversations/{id} | Detail + full message history |
PATCH /conversations/{id} | Rename / change scope |
DELETE /conversations/{id} | Cascades messages + sources |
POST /conversations/{id}/messages | Ask a question — streams: one JSON {citations} line, then raw answer tokens |
GET /conversations/{id}/messages | Full message history |
GET /sources | ?document_id=&limit=&offset= — every citation across all your conversations, most recent first |
GET /dashboard/summary | Counts |
GET /dashboard/recent | Recent documents + conversations |
GET /dashboard/analytics | Real usage: documents by status, questions asked, 30-day daily activity |
POST /eval/cases, GET /eval/cases, DELETE /eval/cases/{id} | Manage test cases |
POST /eval/run | Run cases: retrieval hit/miss + optional faithfulness score |
| unset |
true returns the reset link in the response — local development only, never in production |
VITE_MAX_UPLOAD_MB (frontend) | 4 | client-side fast-fail limit shown in the upload box; keep equal to MAX_FILE_SIZE_MB |
RATE_LIMIT_DISABLED | unset | set to true to disable the auth rate limiter (used by the test suite) |
VITE_API_URL (frontend) | http://localhost:8000 |