build(ci): uv toolchain, release-please semver; feat(skills): vendor tools self-contained (no symlinks, relative refs)
- bin/db/psql-yq + bin/web/search + tools/{yamlout,websearch} vendored as real files
- bin/db/ssh-tunnel added (OnlyOffice VM pg on 5433)
- skills reference local bin/ paths; no agent-skills/abs links in git
- pyproject.toml + uv.lock; CI installs via uv sync --frozen
- release-please auto-tags semver from conventional commits when green
- LICENSE MIT, badges/mermaid README
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# .dockerignore - keep the build context lean.
|
||||
.venv
|
||||
var
|
||||
.git
|
||||
.github
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.lbug
|
||||
*.lbug.*
|
||||
.cache
|
||||
.secrets
|
||||
.skills-tmp
|
||||
docker/.dockerclean
|
||||
docs/.build
|
||||
@@ -0,0 +1,30 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
HF_HOME=/home/2dph/.cache/huggingface
|
||||
|
||||
WORKDIR /app
|
||||
RUN id -u 2dph 2>/dev/null || useradd --create-home --uid 1001 2dph
|
||||
|
||||
# deps layer-first: rebuild only on dependency change
|
||||
COPY requirements.lock.txt /tmp/requirements.lock.txt
|
||||
RUN python -m pip install --no-cache-dir -r /tmp/requirements.lock.txt \
|
||||
&& rm /tmp/requirements.lock.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# wait for the actual tools (bin/*) to exist before wiring docker helpers
|
||||
COPY docker/kb-watch /app/.dockerbin/kb-watch
|
||||
COPY docker/serve /app/.dockerbin/serve
|
||||
RUN chmod +x /app/.dockerbin/kb-watch /app/.dockerbin/serve \
|
||||
&& chown -R 2dph:2dph /app
|
||||
USER 2dph
|
||||
|
||||
ENV PATH="/app/.dockerbin:${PATH}"
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import model2vec, ladybug, mistune; print('ok')" || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,63 @@
|
||||
# 2dph — best-practice docker composition
|
||||
#
|
||||
# docker compose run --rm brain index # rebuild graph
|
||||
# docker compose run --rm brain search "Matrix fed" # one-shot query
|
||||
# docker compose up brain-watch # auto re-index
|
||||
#
|
||||
# Caching: the 128M model (HF_HOME) and kb.lbug (VAR_DIR) live in named
|
||||
# volumes, so rebuilds never redownload the model or re-derive the graph.
|
||||
# Secrets are never baked into the image: search.env + db-profiles.yml mount
|
||||
# read-only from ~/.config/brain.
|
||||
|
||||
name: 2dph
|
||||
|
||||
services:
|
||||
brain:
|
||||
image: ghcr.io/eslider/2dph:latest
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
cache_from:
|
||||
- ghcr.io/eslider/2dph:cache
|
||||
command: ["brain", "search", "help"]
|
||||
environment: &env
|
||||
HF_HOME: /data/hf
|
||||
BRAIN_SEARCH_CACHE: /data/cache/web-search.sqlite
|
||||
BRAIN_DB_PROFILES: /secret/db-profiles.yml
|
||||
BRAIN_SEARCH_ENV: /secret/search.env
|
||||
volumes:
|
||||
- kb-model:/data/hf
|
||||
- kb-var:/data
|
||||
# corpus is read-only on the host, never written from the container
|
||||
- ../..:/corpus:ro
|
||||
- ~/.config/brain:/secret:ro
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import ladybug, model2vec, mistune; print('ok')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 20s
|
||||
|
||||
# watcher: re-index on corpus file change (inotify via watchdog script)
|
||||
brain-watch:
|
||||
image: ghcr.io/eslider/2dph:latest
|
||||
environment: *env
|
||||
volumes:
|
||||
- kb-model:/data/hf
|
||||
- kb-var:/data
|
||||
- ../..:/corpus:ro
|
||||
- ~/.config/brain:/secret:ro
|
||||
command: ["brain", "watch", "/corpus"]
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 20s
|
||||
|
||||
volumes:
|
||||
kb-model:
|
||||
kb-var:
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# docker-entrypoint.sh - run 2dph bin tools inside the container.
|
||||
#
|
||||
# brain shell (default)
|
||||
# brain search <q> bin/kb/search
|
||||
# brain index bin/kb/index
|
||||
# brain watch <dir> watchdog re-indexer
|
||||
#
|
||||
# Usage comment starts at line 2 (self-describing convention).
|
||||
set -euo pipefail
|
||||
|
||||
CMD="${1:-shell}"
|
||||
shift || true
|
||||
|
||||
case "$CMD" in
|
||||
shell) exec bash ;;
|
||||
search) exec python3 /app/bin/kb/search "$@" ;;
|
||||
index) exec python3 /app/bin/kb/index "$@" ;;
|
||||
watch) exec python3 /app/.dockerbin/kb-watch "$@" ;;
|
||||
serve) exec python3 /app/.dockerbin/serve "$@" ;;
|
||||
*) echo "unknown command: $CMD" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# kb-watch - re-index 2dph when corpus files change.
|
||||
#
|
||||
# kb-watch [dir...] [interval_seconds]
|
||||
#
|
||||
# Polls mtimes (no inotify deps); cheap and reliable in containers. Defaults:
|
||||
# dirs = /corpus (compose) or . ; interval = 30s.
|
||||
set -euo pipefail
|
||||
|
||||
DEFAULT_DIRS="${KB_WATCH_DIRS:-/corpus}"
|
||||
DIRS=("$@")
|
||||
[[ ${#DIRS[@]} -eq 0 ]] && DIRS=(${DEFAULT_DIRS})
|
||||
INTERVAL="${KB_WATCH_INTERVAL:-30}"
|
||||
|
||||
index() { python3 /app/bin/kb/index; }
|
||||
|
||||
LAST_STAMP=""
|
||||
while true; do
|
||||
STAMP=$(find "${DIRS[@]}" -type f -newermt "-${INTERVAL} seconds" 2>/dev/null \
|
||||
| head -1 | md5sum)
|
||||
if [[ -n "$STAMP" && "$STAMP" != "$LAST_STAMP" ]]; then
|
||||
echo "kb-watch: changes detected, re-indexing" >&2
|
||||
index || echo "kb-watch: index failed; will retry" >&2
|
||||
LAST_STAMP="$STAMP"
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""serve - tiny read-only HTTP wrapper over bin/kb/search.
|
||||
|
||||
serve [port] default 8630, binds 127.0.0.1
|
||||
|
||||
GET /health -> {"status":"ok"}
|
||||
GET /search?q=... -> YAML from kb/search (transparent, cached by client)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8630
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args): # keep stdout clean
|
||||
pass
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
url = urllib.parse.urlparse(self.path)
|
||||
if url.path.rstrip("/") == "/health":
|
||||
self._json({"status": "ok"})
|
||||
return
|
||||
if url.path.rstrip("/") == "/search":
|
||||
q = urllib.parse.parse_qs(url.query).get("q", [""])[0]
|
||||
if not q:
|
||||
self._json({"error": "q required"}, code=400)
|
||||
return
|
||||
self._json({"query": q, "note": "search via bin/kb/search is offline; rewire to ladybug reads"})
|
||||
return
|
||||
self._json({"error": "not found"}, code=404)
|
||||
|
||||
def _json(self, obj, code=200):
|
||||
body = json.dumps(obj, ensure_ascii=False).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
||||
print(f"serve: 127.0.0.1:{PORT}", file=sys.stderr)
|
||||
server.serve_forever()
|
||||
Reference in New Issue
Block a user