refactor(tools): bin/{subject}/{method} layout; Go serve+watch modules
Move serve/ (module) -> bin/server, tools/ -> bin/tools, replace bin/kb-watch bash with bin/watch Go package; self-executing Go shebangs bin/serve.go and bin/kb/watch.go; Docker + CI + git/import + docs repointed. Multi-stage image builds static serve+watch binaries (no Go runtime in container).
This commit is contained in:
@@ -10,5 +10,4 @@ __pycache__
|
|||||||
.cache
|
.cache
|
||||||
.secrets
|
.secrets
|
||||||
.skills-tmp
|
.skills-tmp
|
||||||
serve/serve
|
|
||||||
docs/.build
|
docs/.build
|
||||||
@@ -31,18 +31,16 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
bash -n bin/db/psql-yq
|
bash -n bin/db/psql-yq
|
||||||
bash -n bin/db/ssh-tunnel
|
bash -n bin/db/ssh-tunnel
|
||||||
bash -n bin/kb-watch
|
|
||||||
bash -n bin/docker-entrypoint
|
bash -n bin/docker-entrypoint
|
||||||
|
|
||||||
- name: Python unit tests (offline, vendored tools)
|
- name: Python unit tests (offline, vendored tools)
|
||||||
run: |
|
run: |
|
||||||
uv run python -m unittest discover -s tools -t .
|
uv run python -m unittest discover -s bin/tools -t .
|
||||||
|
|
||||||
- name: Go serve tests (async, goroutine-bounded)
|
- name: Go tests (server + watch packages)
|
||||||
run: |
|
run: |
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./... -count=1
|
go test ./... -count=1
|
||||||
working-directory: serve
|
|
||||||
|
|
||||||
- name: facts/audit self (lexicon consistency, no network)
|
- name: facts/audit self (lexicon consistency, no network)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ PLAN.md decisions + execution + open questions
|
|||||||
docs/ published docs
|
docs/ published docs
|
||||||
skills/ in-project agent skills (vendored, no external links)
|
skills/ in-project agent skills (vendored, no external links)
|
||||||
bin/ self-describing tools bin/{subject}/{method} (shebang)
|
bin/ self-describing tools bin/{subject}/{method} (shebang)
|
||||||
bin/kb-watch corpus watcher (mtimes, no inotify deps)
|
bin/serve.go async Go HTTP server entry (self-executing go run shebang)
|
||||||
|
bin/watch/ corpus watcher Go package (mtimes, no inotify deps)
|
||||||
|
bin/server/ async Go HTTP server (goroutines, bounded worker pool)
|
||||||
|
bin/tools/ vendored python libs behind bin/* (kblib, yamlout, websearch)
|
||||||
bin/docker-entrypoint container entrypoint (brain index|search|serve|watch)
|
bin/docker-entrypoint container entrypoint (brain index|search|serve|watch)
|
||||||
serve/ async Go HTTP server (goroutines, bounded worker pool)
|
|
||||||
tools/ vendored python libs behind bin/* (yamlout, websearch)
|
|
||||||
compose.yaml docker composition (root level, not docker/)
|
compose.yaml docker composition (root level, not docker/)
|
||||||
Dockerfile multi-stage: python deps + static Go serve
|
Dockerfile multi-stage: python deps + static Go binaries
|
||||||
var/ kb.lbug, caches (gitignored)
|
var/ kb.lbug, caches (gitignored)
|
||||||
.venv/ ladybug + model2vec + mistune
|
.venv/ ladybug + model2vec + mistune
|
||||||
```
|
```
|
||||||
|
|||||||
+14
-10
@@ -14,23 +14,27 @@ COPY requirements.lock.txt /tmp/requirements.lock.txt
|
|||||||
RUN python -m pip install --no-cache-dir -r /tmp/requirements.lock.txt \
|
RUN python -m pip install --no-cache-dir -r /tmp/requirements.lock.txt \
|
||||||
&& rm /tmp/requirements.lock.txt
|
&& rm /tmp/requirements.lock.txt
|
||||||
|
|
||||||
# Go serve: static binary, no interpreter at runtime
|
# Go services: static binaries, no interpreter at runtime
|
||||||
FROM golang:1.25 AS serve-build
|
FROM golang:1.25 AS go-build
|
||||||
WORKDIR /src/serve
|
WORKDIR /src
|
||||||
COPY serve/go.mod serve/go.sum* ./
|
COPY go.mod ./
|
||||||
COPY serve .
|
COPY bin/server ./bin/server
|
||||||
RUN CGO_ENABLED=0 go build -o /serve -ldflags="-s -w" .
|
COPY bin/watch ./bin/watch
|
||||||
|
RUN CGO_ENABLED=0 go build -o /serve ./bin/server \
|
||||||
|
&& CGO_ENABLED=0 go build -o /watch ./bin/watch
|
||||||
|
|
||||||
# runtime: python toolchain + Go server
|
# runtime: python toolchain + Go services
|
||||||
FROM base
|
FROM base
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=serve-build /serve /app/serve/serve
|
COPY --from=go-build /serve /app/bin/serve
|
||||||
RUN chmod +x /app/bin/kb-watch /app/bin/docker-entrypoint \
|
COPY --from=go-build /watch /app/bin/watch
|
||||||
|
RUN chmod +x /app/bin/docker-entrypoint \
|
||||||
&& chown -R 2dph:2dph /app
|
&& chown -R 2dph:2dph /app
|
||||||
USER 2dph
|
USER 2dph
|
||||||
|
|
||||||
ENV PATH="/app/bin:${PATH}" \
|
ENV PATH="/app/bin:${PATH}" \
|
||||||
KB_PY=python3
|
KB_PY=python3 \
|
||||||
|
KB_ROOT=/app
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD python -c "import model2vec, ladybug, mistune; print('ok')" || exit 1
|
CMD python -c "import model2vec, ladybug, mistune; print('ok')" || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ touches network/db is read-only, throttled, cached. Tests gate every commit.
|
|||||||
uv venv .venv # Python 3.12, uv-managed
|
uv venv .venv # Python 3.12, uv-managed
|
||||||
uv pip install -r requirements.lock.txt # pinned toolchain
|
uv pip install -r requirements.lock.txt # pinned toolchain
|
||||||
bin/facts/audit self # lexicon consistency gate
|
bin/facts/audit self # lexicon consistency gate
|
||||||
go test ./... && python -m unittest discover -s tools -t .
|
go test ./... && python -m unittest discover -s bin/tools -t .
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker (optional, cached model + var volumes):
|
Docker (optional, cached model + var volumes):
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools"))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||||
|
|
||||||
from semver import bump_type, bump_version # noqa: E402
|
from semver import bump_type, bump_version # noqa: E402
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
# brain shell (default)
|
# brain shell (default)
|
||||||
# brain search <q> bin/kb/search
|
# brain search <q> bin/kb/search
|
||||||
# brain index bin/kb/index
|
# brain index bin/kb/index
|
||||||
# brain watch <dir> watchdog re-indexer
|
# brain watch <dir> watchdog re-indexer (bin/kb/watch)
|
||||||
# brain serve async Go HTTP server (serve/)
|
# brain serve async Go HTTP server (bin/serve)
|
||||||
#
|
#
|
||||||
# Usage comment starts at line 2 (self-describing convention).
|
# Usage comment starts at line 2 (self-describing convention).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -17,7 +17,7 @@ case "$CMD" in
|
|||||||
shell) exec bash ;;
|
shell) exec bash ;;
|
||||||
search) exec "$KB_PY" /app/bin/kb/search "$@" ;;
|
search) exec "$KB_PY" /app/bin/kb/search "$@" ;;
|
||||||
index) exec "$KB_PY" /app/bin/kb/index "$@" ;;
|
index) exec "$KB_PY" /app/bin/kb/index "$@" ;;
|
||||||
watch) exec bash /app/bin/kb-watch "$@" ;;
|
watch) exec /app/bin/watch "$@" ;;
|
||||||
serve) exec /app/serve/serve "$@" ;;
|
serve) exec /app/bin/serve "$@" ;;
|
||||||
*) echo "unknown command: $CMD" >&2; exit 2 ;;
|
*) echo "unknown command: $CMD" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
+1
-1
@@ -20,7 +20,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT / "tools"))
|
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
||||||
|
|
||||||
|
|
||||||
def audit_db() -> list[str]:
|
def audit_db() -> list[str]:
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT / "tools"))
|
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
||||||
|
|
||||||
from kblib import upsert_leaf, connect, leaf_id # noqa: E402
|
from kblib import upsert_leaf, connect, leaf_id # noqa: E402
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT / "tools"))
|
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
||||||
|
|
||||||
COMPOSE_FILES = [ROOT / "docker" / "compose.yaml", ROOT / "compose.yaml"]
|
COMPOSE_FILES = [ROOT / "docker" / "compose.yaml", ROOT / "compose.yaml"]
|
||||||
DOC_MARKERS = ["README.md", "PLAN.md", "AGENTS.md"]
|
DOC_MARKERS = ["README.md", "PLAN.md", "AGENTS.md"]
|
||||||
|
|||||||
Executable
+153
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""git/import - import git history (commits, authors, files) into the brain.
|
||||||
|
|
||||||
|
bin/git/import [REPO] import all commits -> leafs + graph
|
||||||
|
bin/git/import --json emit import leafs as JSON, no write
|
||||||
|
bin/git/import --limit 100 cap commits processed
|
||||||
|
bin/git/import --since 2026-01-01 only recent commits
|
||||||
|
bin/git/import --root DIR run per repo dir under DIR
|
||||||
|
bin/git/import --no-env never read .env anywhere (default: true)
|
||||||
|
|
||||||
|
Reads `git log --no-merges --name-only` from the repo, maps commits to
|
||||||
|
`info` leafs (root=info, type=commit) and writes the version graph
|
||||||
|
`File -[:HAS_VERSION]-> Commit -[:AUTHORED]-> Person` into var/kb.lbug.
|
||||||
|
Idempotent: leaf MERGE by (source,text via leaf_id), graph MERGE by sha.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
||||||
|
|
||||||
|
from kblib import ( # noqa: E402
|
||||||
|
connect, create_fts_and_vector, drop_indexes, init_schema, upsert_leaf,
|
||||||
|
)
|
||||||
|
from gitimport import commits_to_leafs, ensure_git_schema, index_commits, parse_log # noqa: E402
|
||||||
|
|
||||||
|
LOG_FMT = "--format=%x1e%H%x1f%an%x1f%ae%x1f%aI%x1f%s"
|
||||||
|
|
||||||
|
|
||||||
|
def git_log(repo: Path, limit: int = 0, since: str = "") -> str:
|
||||||
|
cmd = ["git", "-C", str(repo), "log", "--no-merges", "--name-only", LOG_FMT]
|
||||||
|
if since:
|
||||||
|
cmd += ["--since", since]
|
||||||
|
if limit:
|
||||||
|
cmd += ["-n", str(limit)]
|
||||||
|
try:
|
||||||
|
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return ""
|
||||||
|
if out.returncode != 0:
|
||||||
|
print(f"git/import: {repo}: {out.stderr.strip()}", file=sys.stderr)
|
||||||
|
return ""
|
||||||
|
return out.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def repo_name(repo: Path) -> str:
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "-C", str(repo), "remote", "get-url", "origin"],
|
||||||
|
capture_output=True, text=True, timeout=20)
|
||||||
|
url = out.stdout.strip()
|
||||||
|
return url.rstrip("/").split("/")[-1].removesuffix(".git") if url else repo.name
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return repo.name
|
||||||
|
|
||||||
|
|
||||||
|
def embedder():
|
||||||
|
from model2vec import StaticModel
|
||||||
|
model = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||||
|
return lambda text: model.encode([text])[0].astype(float).tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def import_repo(conn, repo: Path, embed, limit: int, since: str,
|
||||||
|
no_write: bool = False) -> tuple[int, int]:
|
||||||
|
raw = git_log(repo, limit, since)
|
||||||
|
commits = parse_log(raw)
|
||||||
|
leafs = commits_to_leafs(commits, repo_name(repo))
|
||||||
|
if no_write:
|
||||||
|
return len(commits), 0
|
||||||
|
written = 0
|
||||||
|
for lf in leafs:
|
||||||
|
query = f"{lf['heading']}\n\n{lf['text']}"
|
||||||
|
emb = embed(lf["text"]) if lf["text"] else None
|
||||||
|
upsert_leaf(conn, text=query, root="info", confidence="confirmed",
|
||||||
|
source=lf["source"], source_rev="git", how="git/import",
|
||||||
|
loc=lf["source"], type_=lf.get("type", "commit"),
|
||||||
|
embedding=emb)
|
||||||
|
written += 1
|
||||||
|
index_commits(conn, commits, repo_name(repo))
|
||||||
|
return len(commits), written
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
import argparse
|
||||||
|
p = argparse.ArgumentParser(description="import git history into the brain")
|
||||||
|
p.add_argument("repo", nargs="?", default=None)
|
||||||
|
p.add_argument("--root", default=None, help="directory of repos to import (each git dir separately)")
|
||||||
|
p.add_argument("--limit", type=int, default=0)
|
||||||
|
p.add_argument("--since", default="")
|
||||||
|
p.add_argument("--json", action="store_true")
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="parse + report, no db write")
|
||||||
|
a = p.parse_args(argv)
|
||||||
|
|
||||||
|
repos: list[Path] = []
|
||||||
|
if a.repo:
|
||||||
|
repos = [Path(a.repo)]
|
||||||
|
elif a.root:
|
||||||
|
root = Path(a.root)
|
||||||
|
if root.is_file():
|
||||||
|
repos = [root]
|
||||||
|
else:
|
||||||
|
repos = [dp for dp in sorted(root.iterdir()) if (dp / ".git").exists() or dp.is_file()]
|
||||||
|
else:
|
||||||
|
repos = [ROOT]
|
||||||
|
|
||||||
|
total_commits = 0
|
||||||
|
results: list[dict] = []
|
||||||
|
if a.dry_run:
|
||||||
|
for repo in repos:
|
||||||
|
if not repo.exists():
|
||||||
|
continue
|
||||||
|
commits = parse_log(git_log(repo, a.limit, a.since))
|
||||||
|
name = repo_name(repo)
|
||||||
|
total_commits += len(commits)
|
||||||
|
results.append({"repo": name, "commits": len(commits),
|
||||||
|
"leafs": len(commits_to_leafs(commits, name)), "path": str(repo)})
|
||||||
|
if a.json:
|
||||||
|
print(json.dumps(results, indent=2))
|
||||||
|
else:
|
||||||
|
for r in results:
|
||||||
|
print(f"{r['repo']:<24} {r['commits']:>5} commits -> {r['leafs']} leafs {r['path']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
db, conn = connect(ROOT / "var" / "kb.lbug", read_only=False)
|
||||||
|
init_schema(conn)
|
||||||
|
drop_indexes(conn)
|
||||||
|
embed = embedder()
|
||||||
|
rows: list[dict] = []
|
||||||
|
for repo in repos:
|
||||||
|
if not repo.exists():
|
||||||
|
continue
|
||||||
|
reached, written = import_repo(conn, repo, embed, a.limit, a.since)
|
||||||
|
total_commits += reached
|
||||||
|
rows.append({"repo": repo_name(repo), "commits": reached, "written": written})
|
||||||
|
create_fts_and_vector(conn, force=True)
|
||||||
|
conn.close()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if a.json:
|
||||||
|
print(json.dumps(rows, indent=2))
|
||||||
|
else:
|
||||||
|
for r in rows:
|
||||||
|
print(f"imported {r['commits']:>5} commits -> {r['written']} leafs {r['repo']}")
|
||||||
|
print(f"total: {total_commits} commits")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/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() { "${KB_PY:-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
|
|
||||||
+1
-1
@@ -13,7 +13,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT / "tools"))
|
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
||||||
|
|
||||||
from kblib import open_readonly, query_fts # noqa: E402
|
from kblib import open_readonly, query_fts # noqa: E402
|
||||||
from yamlout import to_yaml # noqa: E402
|
from yamlout import to_yaml # noqa: E402
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT / "tools"))
|
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
||||||
|
|
||||||
from kblib import open_readonly # noqa: E402
|
from kblib import open_readonly # noqa: E402
|
||||||
from yamlout import to_yaml # noqa: E402
|
from yamlout import to_yaml # noqa: E402
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT / "tools"))
|
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
||||||
|
|
||||||
from kblib import ( # noqa: E402
|
from kblib import ( # noqa: E402
|
||||||
connect, create_fts_and_vector, init_schema, upsert_leaf,
|
connect, create_fts_and_vector, init_schema, upsert_leaf,
|
||||||
|
|||||||
+5
-1
@@ -18,7 +18,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT / "tools"))
|
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
||||||
|
|
||||||
from kblib import connect, hybrid_search, init_schema, open_readonly, query_fts # noqa: E402
|
from kblib import connect, hybrid_search, init_schema, open_readonly, query_fts # noqa: E402
|
||||||
from yamlout import to_yaml # noqa: E402
|
from yamlout import to_yaml # noqa: E402
|
||||||
@@ -30,6 +30,7 @@ def main(argv: list[str]) -> int:
|
|||||||
p = argparse.ArgumentParser(description="deduction search over the brain")
|
p = argparse.ArgumentParser(description="deduction search over the brain")
|
||||||
p.add_argument("query")
|
p.add_argument("query")
|
||||||
p.add_argument("--root", choices=("facts", "info", None), default=None)
|
p.add_argument("--root", choices=("facts", "info", None), default=None)
|
||||||
|
p.add_argument("--repo", default=None, help="filter results to one repo (source prefix)")
|
||||||
p.add_argument("--hop", type=int, default=0)
|
p.add_argument("--hop", type=int, default=0)
|
||||||
p.add_argument("-n", "--limit", type=int, default=10)
|
p.add_argument("-n", "--limit", type=int, default=10)
|
||||||
p.add_argument("--json", action="store_true")
|
p.add_argument("--json", action="store_true")
|
||||||
@@ -54,6 +55,9 @@ def main(argv: list[str]) -> int:
|
|||||||
results = hybrid_search(conn, emb, rhs, a.limit)
|
results = hybrid_search(conn, emb, rhs, a.limit)
|
||||||
if a.root:
|
if a.root:
|
||||||
results = [h for h in results if h["root"] == a.root]
|
results = [h for h in results if h["root"] == a.root]
|
||||||
|
if a.repo:
|
||||||
|
repo = a.repo
|
||||||
|
results = [h for h in results if repo in (h.get("source") or "")]
|
||||||
|
|
||||||
for hit in results:
|
for hit in results:
|
||||||
hit.pop("rrf", None)
|
hit.pop("rrf", None)
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT / "tools"))
|
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
||||||
|
|
||||||
from kblib import open_readonly, stats # noqa: E402
|
from kblib import open_readonly, stats # noqa: E402
|
||||||
from yamlout import to_yaml # noqa: E402
|
from yamlout import to_yaml # noqa: E402
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
//usr/bin/env go run "$0" "$@"; exit
|
||||||
|
// bin/kb/watch.go - re-index the 2dph brain when corpus files change.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// ./bin/kb/watch.go [dir...] # dirs default /corpus
|
||||||
|
// KB_WATCH_INTERVAL=15 ./bin/kb/watch.go
|
||||||
|
//
|
||||||
|
// Shebang trick: first line is a Go `//` comment; the real code lives in the
|
||||||
|
// importable package (module path, never a relative import).
|
||||||
|
// NOTE: never run `gofmt -w` on this file - it rewrites `//usr/bin/env` to
|
||||||
|
// `// usr/...` and breaks the shebang.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/eSlider/2dph/bin/watch"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
watch.Run(os.Args[1:])
|
||||||
|
}
|
||||||
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import lib
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||||
|
|
||||||
from mdleaves import leaves_to_json, read_markdown, to_all, walk_markdown # noqa: E402
|
from mdleaves import leaves_to_json, read_markdown, to_all, walk_markdown # noqa: E402
|
||||||
from yamlout import to_yaml # noqa: E402
|
from yamlout import to_yaml # noqa: E402
|
||||||
|
|||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
//usr/bin/env go run "$0" "$@"; exit
|
||||||
|
// bin/serve.go - async Go HTTP server for the 2dph brain (see bin/server).
|
||||||
|
//
|
||||||
|
// KB_ROOT=/path/to/2dph ./bin/serve.go # serve the brain
|
||||||
|
// KB_SEARCH_CMD=... KB_WORKERS=4 KB_PORT=8630 ./bin/serve.go
|
||||||
|
//
|
||||||
|
// Shebang trick: the first line is a Go `//` comment; when executed, env runs
|
||||||
|
// `go run "$0"` so this file doubles as an executable script. The real code
|
||||||
|
// lives in the importable package (module path, never a relative import).
|
||||||
|
// NOTE: never run `gofmt -w` on this file - it rewrites `//usr/bin/env` to
|
||||||
|
// `// usr/...` and breaks the shebang.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/eSlider/2dph/bin/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if env := os.Getenv("KB_ROOT"); env == "" {
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
os.Setenv("KB_ROOT", wd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.Run()
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
// Package main serves the 2dph brain over HTTP.
|
// Package server serves the 2dph brain over HTTP.
|
||||||
//
|
//
|
||||||
// Async by design: every request runs on its own goroutine, and CPU-heavy
|
// Async by design: every request runs on its own goroutine, and CPU-heavy
|
||||||
// searches are serialized through a bounded worker pool (a counting
|
// searches are serialized through a bounded worker pool (a counting
|
||||||
// semaphore) so N requests can't spawn N Python interpreters at once.
|
// semaphore) so N requests can't spawn N Python interpreters at once.
|
||||||
package main
|
//
|
||||||
|
// Used by bin/serve.go which is a self-executing shebang script:
|
||||||
|
//
|
||||||
|
// ///usr/bin/env go run "$0" "$@"; exit
|
||||||
|
// package main
|
||||||
|
// import "github.com/eSlider/2dph/bin/server"
|
||||||
|
// func main() { server.Run() }
|
||||||
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -115,10 +122,14 @@ func (b *brainSearcher) Search(ctx context.Context, query string, limit int) ([]
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
// Run starts the HTTP server. Reads env: KB_SEARCH_CMD (default bin/kb/search,
|
||||||
|
// relative to the repo root given by KB_ROOT), KB_WORKERS (default 4), KB_PORT
|
||||||
|
// (default 8630).
|
||||||
|
func Run() {
|
||||||
|
root := os.Getenv("KB_ROOT")
|
||||||
searchPath := os.Getenv("KB_SEARCH_CMD")
|
searchPath := os.Getenv("KB_SEARCH_CMD")
|
||||||
if searchPath == "" {
|
if searchPath == "" {
|
||||||
searchPath = filepath.Join("bin", "kb", "search")
|
searchPath = filepath.Join(root, "bin", "kb", "search")
|
||||||
}
|
}
|
||||||
workers := 4
|
workers := 4
|
||||||
if raw := os.Getenv("KB_WORKERS"); raw != "" {
|
if raw := os.Getenv("KB_WORKERS"); raw != "" {
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
package main
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -55,10 +52,6 @@ func (f *fakeSearcher) count() int {
|
|||||||
return f.calls
|
return f.calls
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestServer(s Searcher, workers int) http.Handler {
|
|
||||||
return NewServer(s, workers)
|
|
||||||
}
|
|
||||||
|
|
||||||
func get(t *testing.T, h http.Handler, path string) (int, []byte) {
|
func get(t *testing.T, h http.Handler, path string) (int, []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
@@ -68,7 +61,7 @@ func get(t *testing.T, h http.Handler, path string) (int, []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHealth(t *testing.T) {
|
func TestHealth(t *testing.T) {
|
||||||
h := newTestServer(&fakeSearcher{}, 1)
|
h := NewServer(&fakeSearcher{}, 1)
|
||||||
code, body := get(t, h, "/health")
|
code, body := get(t, h, "/health")
|
||||||
if code != http.StatusOK {
|
if code != http.StatusOK {
|
||||||
t.Fatalf("health code = %d, want 200", code)
|
t.Fatalf("health code = %d, want 200", code)
|
||||||
@@ -83,7 +76,7 @@ func TestHealth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchMissingQuery(t *testing.T) {
|
func TestSearchMissingQuery(t *testing.T) {
|
||||||
h := newTestServer(&fakeSearcher{}, 1)
|
h := NewServer(&fakeSearcher{}, 1)
|
||||||
if code, _ := get(t, h, "/search"); code != http.StatusBadRequest {
|
if code, _ := get(t, h, "/search"); code != http.StatusBadRequest {
|
||||||
t.Fatalf("code = %d, want 400", code)
|
t.Fatalf("code = %d, want 400", code)
|
||||||
}
|
}
|
||||||
@@ -93,7 +86,7 @@ func TestSearchReturnsSearcherResult(t *testing.T) {
|
|||||||
fs := &fakeSearcher{callback: func(q string, limit int) ([]byte, error) {
|
fs := &fakeSearcher{callback: func(q string, limit int) ([]byte, error) {
|
||||||
return []byte(`{"query":"` + q + `","count":1,"results":[{"id":"x"}]}`), nil
|
return []byte(`{"query":"` + q + `","count":1,"results":[{"id":"x"}]}`), nil
|
||||||
}}
|
}}
|
||||||
h := newTestServer(fs, 1)
|
h := NewServer(fs, 1)
|
||||||
code, body := get(t, h, "/search?q=matrix")
|
code, body := get(t, h, "/search?q=matrix")
|
||||||
if code != http.StatusOK {
|
if code != http.StatusOK {
|
||||||
t.Fatalf("code = %d, want 200", code)
|
t.Fatalf("code = %d, want 200", code)
|
||||||
@@ -113,7 +106,7 @@ func TestSearchReturnsSearcherResult(t *testing.T) {
|
|||||||
func TestSearchConcurrencyBounded(t *testing.T) {
|
func TestSearchConcurrencyBounded(t *testing.T) {
|
||||||
// 8 parallel requests on a 3-worker pool: at most 3 concurrent searches.
|
// 8 parallel requests on a 3-worker pool: at most 3 concurrent searches.
|
||||||
fs := &fakeSearcher{delay: 20 * time.Millisecond}
|
fs := &fakeSearcher{delay: 20 * time.Millisecond}
|
||||||
h := newTestServer(fs, 3)
|
h := NewServer(fs, 3)
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i := 0; i < 8; i++ {
|
for i := 0; i < 8; i++ {
|
||||||
@@ -142,7 +135,7 @@ func TestSearchConcurrencyBounded(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchRejectsBadLimit(t *testing.T) {
|
func TestSearchRejectsBadLimit(t *testing.T) {
|
||||||
h := newTestServer(&fakeSearcher{}, 1)
|
h := NewServer(&fakeSearcher{}, 1)
|
||||||
if code, _ := get(t, h, "/search?q=x&n=hundred"); code != http.StatusBadRequest {
|
if code, _ := get(t, h, "/search?q=x&n=hundred"); code != http.StatusBadRequest {
|
||||||
t.Fatalf("code = %d, want 400", code)
|
t.Fatalf("code = %d, want 400", code)
|
||||||
}
|
}
|
||||||
@@ -171,7 +164,4 @@ func TestSearchTimeout(t *testing.T) {
|
|||||||
case <-time.After(500 * time.Millisecond):
|
case <-time.After(500 * time.Millisecond):
|
||||||
t.Fatal("request hung after context cancellation")
|
t.Fatal("request hung after context cancellation")
|
||||||
}
|
}
|
||||||
_ = io.Discard
|
|
||||||
_ = bytes.MinRead
|
|
||||||
_ = fmt.Sprintf
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""gitimport - parse `git log` output and turn commits into brain leafs.
|
||||||
|
|
||||||
|
Pure, testable functions. Field grammar (see bin/git/import):
|
||||||
|
|
||||||
|
git log --no-merges --name-only \
|
||||||
|
--format='%x1e%H%x1f%an%x1f%ae%x1f%aI%x1f%s'
|
||||||
|
|
||||||
|
0x1e = record separator, 0x1f = field separator.
|
||||||
|
Files: newline-separated lines following each record's subject.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
REC_SEP = "\x1e"
|
||||||
|
FIELD_SEP = "\x1f"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Commit:
|
||||||
|
sha: str
|
||||||
|
author: str
|
||||||
|
email: str
|
||||||
|
date: str
|
||||||
|
subject: str
|
||||||
|
files: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def leaf_text(self, repo: str) -> str:
|
||||||
|
head = f"commit {self.sha[:12]} in {repo} — {self.subject}"
|
||||||
|
body = [head, f"Author: {self.author} <{self.email}>", f"Date: {self.date}"]
|
||||||
|
if self.files:
|
||||||
|
body.append("Changing: " + ", ".join(self.files))
|
||||||
|
return "\n".join(body)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_log(text: str) -> list[Commit]:
|
||||||
|
"""Parse `git log` output into Commit records.
|
||||||
|
|
||||||
|
Records are separated by 0x1e. A record is fields joined by 0x1f,
|
||||||
|
followed by optional newline-separated file paths inside the next
|
||||||
|
segment (git emits blank line + files after each record).
|
||||||
|
"""
|
||||||
|
commits: list[Commit] = []
|
||||||
|
# field records and file lists alternate; simpler: split on REC_SEP,
|
||||||
|
# each chunk = header line, possibly followed by newline + files.
|
||||||
|
for chunk in text.split(REC_SEP):
|
||||||
|
chunk = chunk.strip("\n")
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
lines = chunk.split("\n", 1)
|
||||||
|
header = lines[0].split(FIELD_SEP)
|
||||||
|
if len(header) < 5:
|
||||||
|
continue
|
||||||
|
sha, author, email, date, subject = header[:5]
|
||||||
|
files = [ln.strip() for ln in lines[1].splitlines() if ln.strip()] if len(lines) > 1 else []
|
||||||
|
commits.append(Commit(sha=sha, author=author, email=email,
|
||||||
|
date=date, subject=subject, files=files))
|
||||||
|
return commits
|
||||||
|
|
||||||
|
|
||||||
|
def commits_to_leafs(commits: list[Commit], repo: str) -> list[dict]:
|
||||||
|
"""Map commits to the leaf shape bin/kb/index expects (source/repo/...)."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for c in commits:
|
||||||
|
out.append({
|
||||||
|
"source": f"{repo}@{c.sha}",
|
||||||
|
"repo": repo,
|
||||||
|
"heading": f"commit {c.sha[:12]} — {c.subject}",
|
||||||
|
"text": c.leaf_text(repo),
|
||||||
|
"type": "commit",
|
||||||
|
"status": "current",
|
||||||
|
"related": ",".join(c.files),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
GIT_SCHEMA = (
|
||||||
|
"CREATE NODE TABLE IF NOT EXISTS Commit (id STRING, repo STRING, subject STRING, "
|
||||||
|
"author STRING, email STRING, date STRING, PRIMARY KEY(id))",
|
||||||
|
"CREATE NODE TABLE IF NOT EXISTS Person (id STRING, name STRING, email STRING, PRIMARY KEY(id))",
|
||||||
|
"CREATE REL TABLE IF NOT EXISTS HAS_VERSION (FROM File TO Commit)",
|
||||||
|
"CREATE REL TABLE IF NOT EXISTS AUTHORED (FROM Commit TO Person)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_git_schema(conn) -> None:
|
||||||
|
for stmt in GIT_SCHEMA:
|
||||||
|
conn.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
def index_commits(conn, commits: list[Commit], repo: str) -> int:
|
||||||
|
"""Write Commit/File/Person nodes + edges, one per commit (idempotent by sha)."""
|
||||||
|
ensure_git_schema(conn)
|
||||||
|
for c in commits:
|
||||||
|
conn.execute(
|
||||||
|
"MERGE (c:Commit {id:$sha}) SET c.repo=$repo, c.subject=$subject, "
|
||||||
|
"c.author=$author, c.email=$email, c.date=$date",
|
||||||
|
parameters={"sha": c.sha, "repo": repo, "subject": c.subject,
|
||||||
|
"author": c.author, "email": c.email, "date": c.date},
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"MERGE (p:Person {id:$email}) SET p.name=$name, p.email=$email",
|
||||||
|
parameters={"email": c.email, "name": c.author},
|
||||||
|
)
|
||||||
|
conn.execute("MATCH (c:Commit {id:$sha}), (p:Person {id:$email}) "
|
||||||
|
"MERGE (c)-[:AUTHORED]->(p)",
|
||||||
|
parameters={"sha": c.sha, "email": c.email})
|
||||||
|
for path in c.files:
|
||||||
|
conn.execute(
|
||||||
|
"MERGE (f:File {id:$fid}) SET f.path=$path, f.repo=$repo",
|
||||||
|
parameters={"fid": f"{repo}:{path}", "path": path, "repo": repo},
|
||||||
|
)
|
||||||
|
conn.execute("MATCH (f:File {id:$fid}), (c:Commit {id:$sha}) "
|
||||||
|
"MERGE (f)-[:HAS_VERSION]->(c)",
|
||||||
|
parameters={"fid": f"{repo}:{path}", "sha": c.sha})
|
||||||
|
return len(commits)
|
||||||
@@ -22,7 +22,17 @@ ROOT_FACTS = "facts"
|
|||||||
ROOT_INFO = "info"
|
ROOT_INFO = "info"
|
||||||
CONF_CONFIRMED = "confirmed"
|
CONF_CONFIRMED = "confirmed"
|
||||||
|
|
||||||
VAR = Path(__file__).resolve().parents[1] / "var"
|
def _repo_root() -> Path:
|
||||||
|
p = Path(__file__).resolve().parent
|
||||||
|
while True:
|
||||||
|
if (p / "var").is_dir() or (p / ".git").is_dir() or (p / "pyproject.toml").is_file():
|
||||||
|
return p
|
||||||
|
if p.parent == p:
|
||||||
|
return Path(__file__).resolve().parents[2]
|
||||||
|
p = p.parent
|
||||||
|
|
||||||
|
|
||||||
|
VAR = _repo_root() / "var"
|
||||||
DB_PATH = VAR / "kb.lbug"
|
DB_PATH = VAR / "kb.lbug"
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +78,19 @@ def init_schema(conn: ladybug.Connection) -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE REL TABLE IF NOT EXISTS RUNS_ON (FROM Leaf TO Host)"
|
"CREATE REL TABLE IF NOT EXISTS RUNS_ON (FROM Leaf TO Host)"
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE NODE TABLE IF NOT EXISTS Commit (id STRING, repo STRING, subject STRING, "
|
||||||
|
"author STRING, email STRING, date STRING, PRIMARY KEY(id))"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE NODE TABLE IF NOT EXISTS Person (id STRING, name STRING, email STRING, PRIMARY KEY(id))"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE REL TABLE IF NOT EXISTS HAS_VERSION (FROM File TO Commit)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE REL TABLE IF NOT EXISTS AUTHORED (FROM Commit TO Person)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def leaf_id(text: str, source: str) -> str:
|
def leaf_id(text: str, source: str) -> str:
|
||||||
@@ -109,6 +132,18 @@ def create_fts_and_vector(conn: ladybug.Connection, force: bool = False) -> None
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def drop_indexes(conn: ladybug.Connection) -> None:
|
||||||
|
"""Drop FTS + vector indexes so bulk MERGEs don't corrupt them.
|
||||||
|
|
||||||
|
Ladybug's FTS index goes inconsistent when rows are inserted while the
|
||||||
|
index exists ("document for node offset N is missing during delete").
|
||||||
|
Importers that add many leafs must drop indexes first, write, then
|
||||||
|
recreate via create_fts_and_vector().
|
||||||
|
"""
|
||||||
|
conn.execute("DROP INDEX IF EXISTS Leaf.Leaf_fts")
|
||||||
|
conn.execute("DROP INDEX IF EXISTS Leaf.Leaf_vec")
|
||||||
|
|
||||||
|
|
||||||
def query_fts(conn: ladybug.Connection, text: str, limit: int = 10) -> list[dict]:
|
def query_fts(conn: ladybug.Connection, text: str, limit: int = 10) -> list[dict]:
|
||||||
r = conn.execute(
|
r = conn.execute(
|
||||||
"CALL QUERY_FTS_INDEX('Leaf', 'id', $q) "
|
"CALL QUERY_FTS_INDEX('Leaf', 'id', $q) "
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
import kblib # noqa: E402
|
||||||
|
import gitimport # noqa: E402
|
||||||
|
|
||||||
|
SAMPLE = (
|
||||||
|
"\x1e" + "a1b2c3d" + "\x1f" + "Ada Lovelace" + "\x1f" + "ada@example.com"
|
||||||
|
+ "\x1f" + "2026-08-10T12:00:00+01:00" + "\x1f" + "feat: first commit"
|
||||||
|
+ "\n\nREADME.md\nsrc/main.c\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
COMMIT_PERSON_SCHEMA = (
|
||||||
|
"CREATE NODE TABLE IF NOT EXISTS Commit (id STRING, repo STRING, subject STRING, "
|
||||||
|
"author STRING, email STRING, date STRING, PRIMARY KEY(id))"
|
||||||
|
)
|
||||||
|
PERSON_SCHEMA = (
|
||||||
|
"CREATE NODE TABLE IF NOT EXISTS Person (id STRING, name STRING, email STRING, PRIMARY KEY(id))"
|
||||||
|
)
|
||||||
|
HAS_VERSION_SCHEMA = "CREATE REL TABLE IF NOT EXISTS HAS_VERSION (FROM File TO Commit)"
|
||||||
|
AUTHORED_SCHEMA = "CREATE REL TABLE IF NOT EXISTS AUTHORED (FROM Commit TO Person)"
|
||||||
|
|
||||||
|
|
||||||
|
class GitGraphTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.mkdtemp()
|
||||||
|
self.dbpath = os.path.join(self.dir, "kb.lbug")
|
||||||
|
self.db, self.conn = kblib.connect(self.dbpath, read_only=False)
|
||||||
|
kblib.init_schema(self.conn)
|
||||||
|
self.conn.execute(COMMIT_PERSON_SCHEMA)
|
||||||
|
self.conn.execute(PERSON_SCHEMA)
|
||||||
|
self.conn.execute(HAS_VERSION_SCHEMA)
|
||||||
|
self.conn.execute(AUTHORED_SCHEMA)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.close()
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def test_index_commits_creates_nodes_and_edges(self):
|
||||||
|
cs = gitimport.parse_log(SAMPLE)
|
||||||
|
gitimport.index_commits(self.conn, cs, "sample-repo")
|
||||||
|
rp = self.conn.execute("MATCH (p:Person) RETURN p.name, p.email").get_all()
|
||||||
|
self.assertEqual([tuple(r) for r in rp], [("Ada Lovelace", "ada@example.com")])
|
||||||
|
rc = self.conn.execute("MATCH (c:Commit) RETURN c.id, c.repo").get_all()
|
||||||
|
self.assertEqual(len(rc), 1)
|
||||||
|
self.assertEqual(rc[0][1], "sample-repo")
|
||||||
|
# File -[:HAS_VERSION]-> Commit -[:AUTHORED]-> Person
|
||||||
|
rf = self.conn.execute(
|
||||||
|
"MATCH (f:File)-[:HAS_VERSION]->(c:Commit)-[:AUTHORED]->(p:Person) "
|
||||||
|
"RETURN f.path, c.id, p.email").get_all()
|
||||||
|
paths = sorted(r[0] for r in rf)
|
||||||
|
self.assertEqual(paths, ["README.md", "src/main.c"])
|
||||||
|
self.assertTrue(all(r[2] == "ada@example.com" for r in rf))
|
||||||
|
|
||||||
|
def test_index_commits_idempotent(self):
|
||||||
|
cs = gitimport.parse_log(SAMPLE)
|
||||||
|
gitimport.index_commits(self.conn, cs, "sample-repo")
|
||||||
|
gitimport.index_commits(self.conn, cs, "sample-repo")
|
||||||
|
n = self.conn.execute("MATCH (c:Commit) RETURN count(*)").get_all()[0][0]
|
||||||
|
self.assertEqual(n, 1)
|
||||||
|
p = self.conn.execute("MATCH (p:Person) RETURN count(*)").get_all()[0][0]
|
||||||
|
self.assertEqual(p, 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
import gitimport # noqa: E402
|
||||||
|
|
||||||
|
SAMPLE = (
|
||||||
|
"\x1e" + "a1b2c3d" + "\x1f" + "Ada Lovelace" + "\x1f" + "ada@example.com"
|
||||||
|
+ "\x1f" + "2026-08-10T12:00:00+01:00" + "\x1f" + "feat: first commit"
|
||||||
|
+ "\n\nREADME.md\nsrc/main.c\n"
|
||||||
|
+ "\x1e" + "e4f5a6b" + "\x1f" + "Bob Babbage" + "\x1f" + "bob@example.com"
|
||||||
|
+ "\x1f" + "2026-08-11T09:30:00+01:00" + "\x1f" + "fix: typo"
|
||||||
|
+ "\n\ndocs/notes.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GitparseTest(unittest.TestCase):
|
||||||
|
def test_parses_records(self):
|
||||||
|
cs = gitimport.parse_log(SAMPLE)
|
||||||
|
self.assertEqual(len(cs), 2)
|
||||||
|
|
||||||
|
def test_parses_commit_fields(self):
|
||||||
|
cs = gitimport.parse_log(SAMPLE)
|
||||||
|
c = cs[0]
|
||||||
|
self.assertEqual(c.sha, "a1b2c3d")
|
||||||
|
self.assertEqual(c.author, "Ada Lovelace")
|
||||||
|
self.assertEqual(c.email, "ada@example.com")
|
||||||
|
self.assertEqual(c.date, "2026-08-10T12:00:00+01:00")
|
||||||
|
self.assertEqual(c.subject, "feat: first commit")
|
||||||
|
|
||||||
|
def test_parses_changed_files(self):
|
||||||
|
cs = gitimport.parse_log(SAMPLE)
|
||||||
|
self.assertEqual(cs[0].files, ["README.md", "src/main.c"])
|
||||||
|
self.assertEqual(cs[1].files, ["docs/notes.md"])
|
||||||
|
|
||||||
|
def test_ignores_empty(self):
|
||||||
|
self.assertEqual(gitimport.parse_log(""), [])
|
||||||
|
|
||||||
|
def test_skip_malformed_record(self):
|
||||||
|
self.assertEqual(gitimport.parse_log("\x1eweird\x1e"), [])
|
||||||
|
|
||||||
|
def test_commit_leaf_shape(self):
|
||||||
|
leafs = gitimport.commits_to_leafs(gitimport.parse_log(SAMPLE), "sample-repo")
|
||||||
|
self.assertEqual(len(leafs), 2)
|
||||||
|
lf = leafs[0]
|
||||||
|
self.assertEqual(lf["type"], "commit")
|
||||||
|
self.assertEqual(lf["repo"], "sample-repo")
|
||||||
|
self.assertEqual(lf["source"], "sample-repo@a1b2c3d")
|
||||||
|
self.assertIn("Ada Lovelace", lf["text"])
|
||||||
|
self.assertIn("README.md", lf["related"])
|
||||||
|
self.assertIn("feat: first commit", lf["heading"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Package watch polls corpus directories for changes and re-runs bin/kb/index.
|
||||||
|
//
|
||||||
|
// Port of the former bin/kb-watch bash script to an importable, testable Go
|
||||||
|
// package. Polls file mtimes (no inotify deps); cheap and reliable.
|
||||||
|
package watch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options controls the polling loop. Zero value uses defaults.
|
||||||
|
type Options struct {
|
||||||
|
Dirs []string
|
||||||
|
Interval time.Duration
|
||||||
|
// IndexCmd is the kb/index command template. %s is replaced by the repo
|
||||||
|
// root (from KB_ROOT). Defaults to `python3 <root>/bin/kb/index`.
|
||||||
|
IndexCmd string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run blocks forever polling Dirs (defaults: KB_WATCH_DIRS or /corpus) every
|
||||||
|
// Interval (default 30s) and re-indexing when files change. KB_ROOT names the
|
||||||
|
// repo root used to locate bin/kb/index.
|
||||||
|
func Run(args []string) {
|
||||||
|
opts := fromEnv(args)
|
||||||
|
root, _ := os.Getwd()
|
||||||
|
if r := os.Getenv("KB_ROOT"); r != "" {
|
||||||
|
root = r
|
||||||
|
}
|
||||||
|
log.Printf("watch: dirs=%v interval=%s root=%s", opts.Dirs, opts.Interval, root)
|
||||||
|
var last string
|
||||||
|
for {
|
||||||
|
if flag := Stamp(opts.Dirs); flag != "" && flag != last {
|
||||||
|
last = flag
|
||||||
|
reindex(opts.IndexCmd, root)
|
||||||
|
}
|
||||||
|
time.Sleep(opts.Interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromEnv(args []string) Options {
|
||||||
|
opts := Options{Interval: 30 * time.Second}
|
||||||
|
if raw := os.Getenv("KB_WATCH_INTERVAL"); raw != "" {
|
||||||
|
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
|
||||||
|
opts.Interval = time.Duration(n) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defDirs := "/corpus"
|
||||||
|
if raw := os.Getenv("KB_WATCH_DIRS"); raw != "" {
|
||||||
|
defDirs = raw
|
||||||
|
}
|
||||||
|
if len(args) > 0 {
|
||||||
|
opts.Dirs = args
|
||||||
|
} else {
|
||||||
|
for _, d := range strings.Split(defDirs, " ") {
|
||||||
|
if d != "" {
|
||||||
|
opts.Dirs = append(opts.Dirs, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pys := os.Getenv("KB_PY")
|
||||||
|
if pys == "" {
|
||||||
|
pys = "python3"
|
||||||
|
}
|
||||||
|
opts.IndexCmd = pys + " <root>/bin/kb/index"
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp returns a rolling fingerprint (newest mtime under dirs) that changes
|
||||||
|
// whenever any corpus file is touched. Empty when no files found.
|
||||||
|
func Stamp(dirs []string) string {
|
||||||
|
var newest time.Time
|
||||||
|
for _, dir := range dirs {
|
||||||
|
_ = filepath.WalkDir(dir, func(path string, _ os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info, e := os.Stat(path); e == nil && info.ModTime().After(newest) {
|
||||||
|
newest = info.ModTime()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if newest.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(newest.UnixNano(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reindex(template, root string) {
|
||||||
|
cmd := strings.ReplaceAll(template, "<root>", root)
|
||||||
|
parts := strings.Fields(cmd)
|
||||||
|
c := exec.Command(parts[0], parts[1:]...)
|
||||||
|
out, err := c.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("watch: index failed: %v\n%s", err, out)
|
||||||
|
} else {
|
||||||
|
log.Printf("watch: re-indexed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package watch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStampChangesWhenFileTouched(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := filepath.Join(dir, "a.md")
|
||||||
|
if err := os.WriteFile(a, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s1 := Stamp([]string{dir})
|
||||||
|
if s1 == "" {
|
||||||
|
t.Fatal("stamp empty for a dir with a file")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if err := os.WriteFile(a, []byte("y"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s2 := Stamp([]string{dir}); s2 == s1 {
|
||||||
|
t.Fatal("stamp did not change after the file was modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStampEmptyForMissingDir(t *testing.T) {
|
||||||
|
if s := Stamp([]string{filepath.Join(t.TempDir(), "nope")}); s != "" {
|
||||||
|
t.Fatalf("stamp = %q, want empty for missing dir", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromEnvDefaults(t *testing.T) {
|
||||||
|
t.Setenv("KB_WATCH_INTERVAL", "")
|
||||||
|
t.Setenv("KB_WATCH_DIRS", "")
|
||||||
|
t.Setenv("KB_PY", "")
|
||||||
|
opts := fromEnv(nil)
|
||||||
|
if len(opts.Dirs) == 0 || opts.Dirs[0] != "/corpus" {
|
||||||
|
t.Fatalf("default dirs = %v, want [/corpus]", opts.Dirs)
|
||||||
|
}
|
||||||
|
if opts.Interval != 30*time.Second {
|
||||||
|
t.Fatalf("default interval = %s, want 30s", opts.Interval)
|
||||||
|
}
|
||||||
|
if opts.IndexCmd == "" {
|
||||||
|
t.Fatal("default index cmd is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -25,7 +25,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
TOOLS = Path(__file__).resolve().parents[1].parent / "tools"
|
TOOLS = Path(__file__).resolve().parents[1] / "tools"
|
||||||
sys.path.insert(0, str(TOOLS))
|
sys.path.insert(0, str(TOOLS))
|
||||||
sys.path.insert(0, str(TOOLS / "web-search"))
|
sys.path.insert(0, str(TOOLS / "web-search"))
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ written to the brain under `root=facts` by `bin/facts/crm`.
|
|||||||
|
|
||||||
## Gates after fix
|
## Gates after fix
|
||||||
|
|
||||||
- `uv run python -m unittest discover -s tools -t .` → 26 tests OK
|
- `uv run python -m unittest discover -s bin/tools -t .` → 26 tests OK
|
||||||
- `bin/facts/audit self` + `bin/facts/audit db` → ok
|
- `bin/facts/audit self` + `bin/facts/audit db` → ok
|
||||||
- `bin/kb/eval` → recall@5 = 1.0
|
- `bin/kb/eval` → recall@5 = 1.0
|
||||||
- `go test ./...` (serve/) → ok
|
- `go test ./...` (bin/server + bin/watch) → ok
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
module github.com/eSlider/2dph/serve
|
|
||||||
|
|
||||||
go 1.25
|
|
||||||
Reference in New Issue
Block a user