Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.git
.github
.venv
.cache
.env
**/__pycache__
.pytest_cache
.ruff_cache
tests
deploy
dist
build
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,12 @@ CHAPTERWISE_CACHE_DIR=.cache/chapterwise

# Sections longer than this (approx. tokens) are summarized in parts, then merged
CHAPTERWISE_MAX_CHUNK_TOKENS=24000

# --- Service (web UI + worker) ------------------------------------------------
DATABASE_URL=postgresql+psycopg://chapterwise:chapterwise@localhost:5432/chapterwise
CHAPTERWISE_BOOKS_DIR=./books
CHAPTERWISE_SECRET_KEY=change-me-to-a-long-random-string
CHAPTERWISE_ADMIN_PASSWORD=change-me
CHAPTERWISE_PUBLIC_URL=http://localhost:8100
# Seconds between automatic scans of the books folder
CHAPTERWISE_SCAN_INTERVAL=600
134 changes: 131 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
name: CI
name: CI/CD

# Every push and PR: lint + tests (with a real Postgres).
# Pushes to main: build the image, push it to GHCR, deploy it to the VPS.

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

env:
IMAGE: ghcr.io/${{ github.repository }}

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.12", "3.13"]
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: chapterwise_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
env:
TEST_DATABASE_URL: postgresql+psycopg://postgres:postgres@localhost:5432/chapterwise_test
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

Expand All @@ -34,4 +52,114 @@ jobs:
ruff format --check .

- name: Test
run: pytest
run: pytest -rs

build:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0

- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: true
platforms: linux/amd64
build-args: VERSION=${{ github.sha }}
tags: |
${{ env.IMAGE }}:sha-${{ github.sha }}
${{ env.IMAGE }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max

deploy:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
packages: read
concurrency:
group: deploy-chapterwise
cancel-in-progress: false
env:
REMOTE: root@${{ secrets.DEPLOY_HOST }}
REMOTE_DIR: /root/chapterwise
TAG: sha-${{ github.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Check deploy secrets
env:
HOST: ${{ secrets.DEPLOY_HOST }}
KNOWN: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
if [ -z "$HOST" ] || [ -z "$KNOWN" ]; then
echo "::error::Set the DEPLOY_HOST, DEPLOY_KNOWN_HOSTS and DEPLOY_SSH_KEY secrets (see README)."
exit 1
fi

- uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}

# Pinned host key instead of ssh-keyscan, which would trust whoever answers on that address.
- name: Trust the server's host key
env:
KNOWN: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: mkdir -p ~/.ssh && echo "$KNOWN" >> ~/.ssh/known_hosts

- name: Check the server is bootstrapped
run: |
if ! ssh "$REMOTE" "test -f $REMOTE_DIR/.env"; then
echo "::error::$REMOTE_DIR/.env missing: run deploy/server-bootstrap.sh on the VPS first."
exit 1
fi

- name: Upload config
run: rsync -az --chmod=F644 deploy/docker-compose.yml "$REMOTE:$REMOTE_DIR/" &&
rsync -az --chmod=F755 deploy/cw.sh "$REMOTE:$REMOTE_DIR/"

- name: Back up the database
run: ssh "$REMOTE" "cd $REMOTE_DIR && touch image.env && ./cw.sh backup pre-$TAG"

- name: Pull and start
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# The token goes through stdin, never onto a command line, and is logged out right after the pull.
echo "$GH_TOKEN" | ssh "$REMOTE" "docker login ghcr.io -u ${{ github.actor }} --password-stdin"
ssh "$REMOTE" "cd $REMOTE_DIR \
&& echo IMAGE=$IMAGE:$TAG > image.env \
&& (./cw.sh pull; status=\$?; docker logout ghcr.io; exit \$status) \
&& ./cw.sh up -d --remove-orphans"

- name: Verify health
run: |
for _ in $(seq 1 30); do
if ssh "$REMOTE" 'curl -sf http://127.0.0.1:8100/healthz >/dev/null'; then
echo "Healthy on $TAG"
ssh "$REMOTE" "echo $TAG > $REMOTE_DIR/.deployed && docker image prune -f >/dev/null"
exit 0
fi
sleep 5
done
echo "::error::Health check failed. Recent logs:"
ssh "$REMOTE" "cd $REMOTE_DIR && ./cw.sh ps && ./cw.sh logs --tail 80 migrate web worker" || true
exit 1
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# One image for every role: `chapterwise serve` (default), `chapterwise worker`, `chapterwise migrate`.

FROM python:3.12-slim AS build
WORKDIR /src
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
RUN pip wheel --no-cache-dir --wheel-dir /wheels .

FROM python:3.12-slim
ARG VERSION=dev
LABEL org.opencontainers.image.source="https://github.com/aminghuf/chapterwise" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.licenses="MIT"
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
CHAPTERWISE_BOOKS_DIR=/books
RUN useradd --uid 1000 --create-home app
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels
USER app
WORKDIR /home/app
EXPOSE 8100
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8100/healthz', timeout=4)"]
CMD ["chapterwise", "serve", "--host", "0.0.0.0", "--port", "8100"]
70 changes: 67 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ chapterwise splits a book into sections using its own table of contents, summari
same request is never paid for twice. It's built to run next to a self-hosted library such as
[BookOrbit](https://github.com/bookorbit/bookorbit), reading the same books folder read-only.

**Status:** Phase 1, a command-line tool. The web UI, Telegram delivery and scheduling come next
(see [Roadmap](#roadmap)).
**Status:** Phase 2. A command-line tool plus a self-hosted web app (library, chapters, summaries)
with a background worker, deployed by CI/CD. Telegram delivery comes next (see [Roadmap](#roadmap)).

```
books folder (read-only) ──► worker: scan · parse · summarize ──► DeepSeek API
│
Postgres (books, sections, job queue, LLM cache)
│
browser ──► Caddy ──► web: login · library · book · section
```

## Supported formats

Expand Down Expand Up @@ -52,6 +60,53 @@ Summaries are written in the same language as the book.
are free, an interrupted run resumes where it stopped, and changing the model or a prompt
regenerates only what's affected.

## The service

Three processes from one image or install, sharing one Postgres database:

| Command | Role |
| --- | --- |
| `chapterwise migrate` | Create or upgrade the schema (Alembic) |
| `chapterwise serve` | Web UI on port 8100 |
| `chapterwise worker` | Scans the books folder every `CHAPTERWISE_SCAN_INTERVAL` seconds, parses new or changed files, runs summaries |

- **Job queue:** a Postgres table claimed with `FOR UPDATE SKIP LOCKED`, so there's no Redis to run.
- **Retries:** failed jobs retry with exponential backoff, up to 3 attempts. Jobs interrupted by a
restart are requeued.
- **Summaries are cached** in Postgres, keyed like the CLI cache.
- **Web UI:** server-rendered with no JavaScript. It refreshes itself while work is queued.
- **Security:** single password with login throttling, `SameSite` session cookie plus an Origin
check on every form post, strict Content-Security-Policy. Model output is rendered as Markdown
with raw HTML escaped.

Run it locally against any Postgres:

```bash
cp .env.example .env # set DATABASE_URL, CHAPTERWISE_BOOKS_DIR, secrets
chapterwise migrate
chapterwise serve & chapterwise worker
```

## Deployment

Pushes to `main` run the tests, build the image to `ghcr.io/aminghuf/chapterwise:sha-<commit>`, then
deploy over SSH to the VPS. The deploy writes the image tag, backs up the database, runs
`docker compose up` (migrations run first) and waits for `/healthz`. Files are in [`deploy/`](deploy).

**One-time setup:**

1. **DNS:** `A read → <server IP>`.
2. **Server:** copy `deploy/server-bootstrap.sh` to the VPS and run `bash server-bootstrap.sh`. It
creates `/root/chapterwise/.env` with generated secrets and asks for the web password and API key.
3. **Caddy:** add `deploy/read.aminghuf.com.caddy` to the Caddyfile and reload Caddy.
4. **GitHub secrets** (Settings → Secrets and variables → Actions):
- `DEPLOY_SSH_KEY`: a private key allowed to SSH in as root
- `DEPLOY_HOST`: the server's IP
- `DEPLOY_KNOWN_HOSTS`: the server's host-key line, from `ssh-keyscan -t ed25519 <ip>` checked
against the server

**On the server:** `cd /root/chapterwise && ./cw.sh ps | logs -f worker | backup`.

## Configuration

Set these in `.env` or the environment:
Expand All @@ -63,18 +118,27 @@ Set these in `.env` or the environment:
| `CHAPTERWISE_MODEL` | `deepseek-flash` | |
| `CHAPTERWISE_CACHE_DIR` | `.cache/chapterwise` | |
| `CHAPTERWISE_MAX_CHUNK_TOKENS` | `24000` | Approximate tokens per call |
| `DATABASE_URL` | | Service only; any `postgresql://` URL |
| `CHAPTERWISE_BOOKS_DIR` | `/books` | Folder scanned for books (mounted read-only in Docker) |
| `CHAPTERWISE_SECRET_KEY` | | Signs the login cookie |
| `CHAPTERWISE_ADMIN_PASSWORD` | | The web UI password |
| `CHAPTERWISE_PUBLIC_URL` | `http://localhost:8100` | With `https://`, cookies are marked Secure |
| `CHAPTERWISE_SCAN_INTERVAL` | `600` | Seconds between library scans |

## Development

```bash
pytest # tests build their own EPUB/PDF/Markdown fixtures, no network or API key needed

# Service tests need a Postgres they may wipe (skipped otherwise):
TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/chapterwise_test pytest
ruff check . && ruff format .
```

## Roadmap

- [x] **Phase 1:** parsers, chunking, cached map-reduce summaries, CLI
- [ ] **Phase 2:** Postgres, background worker, books-folder sync, web UI at read.aminghuf.com, CI/CD to the VPS
- [x] **Phase 2:** Postgres, background worker, books-folder sync, web UI at read.aminghuf.com, CI/CD to the VPS
- [ ] **Phase 3:** personal Telegram bot with scheduled delivery
- [ ] **Phase 4:** whole-book summaries, micro-doses (original text plus recall questions), Markdown playbooks as a source

Expand Down
25 changes: 25 additions & 0 deletions deploy/cw.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# docker compose wrapper on the server, so .env and the pinned image are always used.
# ./cw.sh ps | ./cw.sh logs -f worker | ./cw.sh up -d | ./cw.sh backup [label]
set -euo pipefail
cd "$(dirname "$0")"

compose() {
docker compose --env-file .env --env-file image.env -f docker-compose.yml "$@"
}

if [ "${1:-}" = "backup" ]; then
# Database dump into ./backups, keeping the newest 7. Skipped when the database isn't running yet.
if ! compose ps --status running --services 2>/dev/null | grep -qx db; then
echo "Database not running; nothing to back up."
exit 0
fi
mkdir -p backups
out="backups/chapterwise-$(date +%Y%m%d-%H%M%S)${2:+-$2}.sql.gz"
compose exec -T db pg_dump -U chapterwise -d chapterwise | gzip > "$out"
ls -1t backups/*.sql.gz | tail -n +8 | xargs -r rm --
echo "Backup written: $out"
exit 0
fi

compose "$@"
Loading
Loading