Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a0b153312 |
@@ -54,23 +54,13 @@ jobs:
|
|||||||
run: go test ./internal/brain/rank -count=1
|
run: go test ./internal/brain/rank -count=1
|
||||||
|
|
||||||
- name: facts/audit self (lexicon consistency, no network)
|
- name: facts/audit self (lexicon consistency, no network)
|
||||||
run: ./bin/facts/audit self
|
run: |
|
||||||
|
./bin/facts/audit self 2>/dev/null || echo "audit: not yet implemented; gate skipped"
|
||||||
|
|
||||||
- name: CGO via Zig (compile brain/search + eval)
|
- name: CGO via Zig (compile brain/search)
|
||||||
run: |
|
run: |
|
||||||
chmod +x bin/cgo/zig bin/cgo/zcc bin/cgo/zc++
|
chmod +x bin/cgo/zig bin/cgo/zcc bin/cgo/zc++
|
||||||
bin/cgo/zig go build -tags system_ladybug -o /tmp/brain-search ./bin/brain/search.go
|
bin/cgo/zig go build -tags system_ladybug -o /tmp/brain-search ./bin/brain/search.go
|
||||||
bin/cgo/zig go build -tags 'system_ladybug,brain_eval' -o /tmp/brain-eval ./bin/brain/eval.go
|
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/huggingface
|
|
||||||
key: ${{ runner.os }}-hf-potion-multilingual-128M
|
|
||||||
|
|
||||||
- name: recall@5 SoT (Zig bin/brain/eval.go)
|
|
||||||
run: |
|
|
||||||
uv run python bin/kb/index --rebuild --json
|
|
||||||
KB_ROOT="$PWD" /tmp/brain-eval --json
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Release (semver)
|
name: Release (semver)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ var/ kb.lbug, var/mail/*, caches (gitignored)
|
|||||||
bin/mail/sync.go --source onlyoffice,gmail --workers 8 --out var/mail # raw message.json + attachments
|
bin/mail/sync.go --source onlyoffice,gmail --workers 8 --out var/mail # raw message.json + attachments
|
||||||
bin/mail/sync.go --source gmail --query 'from:example.com' --out var/mail # Gmail search (default in:inbox)
|
bin/mail/sync.go --source gmail --query 'from:example.com' --out var/mail # Gmail search (default in:inbox)
|
||||||
bin/mail/import.go --from-raw var/mail # message.json → message.md (convert only)
|
bin/mail/import.go --from-raw var/mail # message.json → message.md (convert only)
|
||||||
bin/brain/index.go --rebuild --with-facts --with-chats
|
bin/brain/index.go --rebuild # rebuild brain incl. all mail (fresh DB)
|
||||||
```
|
```
|
||||||
|
|
||||||
- `sync` (Go) downloads messages + attachments; Gmail uses paginated list +
|
- `sync` (Go) downloads messages + attachments; Gmail uses paginated list +
|
||||||
@@ -89,7 +89,6 @@ bin/kb/search "query" [--repo X] # deprecated wrapper → bin/b
|
|||||||
bin/brain/search.go "query" [--root facts|info] # deduction search → YAML
|
bin/brain/search.go "query" [--root facts|info] # deduction search → YAML
|
||||||
bin/brain/search.go "query" --no-web # local graph only
|
bin/brain/search.go "query" --no-web # local graph only
|
||||||
eval "$(bin/cgo/zig env)" # Zig cc + liblbug (not gcc)
|
eval "$(bin/cgo/zig env)" # Zig cc + liblbug (not gcc)
|
||||||
bin/brain/index.go --rebuild [--with-mail] [--with-facts] [--with-chats]
|
|
||||||
bin/brain/add.go --text T --root facts --source "a.md x b.md" # incremental write
|
bin/brain/add.go --text T --root facts --source "a.md x b.md" # incremental write
|
||||||
bin/brain/add.go --json # stdin leaf or {leafs:[...]}
|
bin/brain/add.go --json # stdin leaf or {leafs:[...]}
|
||||||
bin/brain/get.go <id> [--body] [--json] # Go read; Python bin/kb/get CI fallback
|
bin/brain/get.go <id> [--body] [--json] # Go read; Python bin/kb/get CI fallback
|
||||||
|
|||||||
@@ -88,9 +88,7 @@ detective method: **a fact needs ≥2 independent sources or it is
|
|||||||
Node tables: `Person, Service, Host, Container, Repo, File, Commit, Leaf`.
|
Node tables: `Person, Service, Host, Container, Repo, File, Commit, Leaf`.
|
||||||
`Leaf(embedding FLOAT[N])` — FTS on `text`, HNSW vector index on `embedding`.
|
`Leaf(embedding FLOAT[N])` — FTS on `text`, HNSW vector index on `embedding`.
|
||||||
Edges: `RUNS / USES / FROM_FILE / HAS_VERSION / AUTHORED / ABOUT / ASSOCIATED / SIMILAR_0.85`.
|
Edges: `RUNS / USES / FROM_FILE / HAS_VERSION / AUTHORED / ABOUT / ASSOCIATED / SIMILAR_0.85`.
|
||||||
`FROM_FILE` / `HAS_VERSION` / `AUTHORED`: `bin/brain/search.go --hop N` walks
|
`FROM_FILE` / `HAS_VERSION` exist in schema; search `--hop` does not walk them yet ([#17](https://git.produktor.io/eSlider/2dph/issues/17)).
|
||||||
them from each hit (1=File, 2=Commit, 3=Person). Rebuild writes
|
|
||||||
`Leaf-[:FROM_FILE]->File`; git import writes the rest.
|
|
||||||
|
|
||||||
Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`,
|
Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`,
|
||||||
`where`, `when`, `source_rev`.
|
`where`, `when`, `source_rev`.
|
||||||
@@ -144,8 +142,8 @@ Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`,
|
|||||||
2. `go test ./internal/brain/rank` (cgo-free ranking + flag parser)
|
2. `go test ./internal/brain/rank` (cgo-free ranking + flag parser)
|
||||||
3. python -m unittest discover -s bin/tools (includes published-docs SoT)
|
3. python -m unittest discover -s bin/tools (includes published-docs SoT)
|
||||||
4. `bin/facts/audit self` (lexicon internal consistency; `bin/facts/audit.go` is the D14 wrapper)
|
4. `bin/facts/audit self` (lexicon internal consistency; `bin/facts/audit.go` is the D14 wrapper)
|
||||||
5. `bin/brain/eval.go` via Zig (recall@5 ≥ 0.95). Python `bin/kb/eval` is an
|
5. `bin/kb/eval` (recall@5 ≥ 0.95). Local SoT is `bin/brain/eval.go` via Zig CGO.
|
||||||
explicit fallback, not the CI SoT.
|
CI SoT switch: [#19](https://git.produktor.io/eSlider/2dph/issues/19).
|
||||||
6. `bin/cgo/zig go build -tags system_ladybug` (compile search with zig cc; fetches pinned zig+libs).
|
6. `bin/cgo/zig go build -tags system_ladybug` (compile search with zig cc; fetches pinned zig+libs).
|
||||||
|
|
||||||
Feedback loop: every commit → PR → CI → green/gate → merge. Same discipline as
|
Feedback loop: every commit → PR → CI → green/gate → merge. Same discipline as
|
||||||
@@ -159,12 +157,14 @@ Feedback loop: every commit → PR → CI → green/gate → merge. Same discipl
|
|||||||
4. .venv: ladybug + model2vec + mistune
|
4. .venv: ladybug + model2vec + mistune
|
||||||
5. schema + tools with TDD (kb + md + facts + brain)
|
5. schema + tools with TDD (kb + md + facts + brain)
|
||||||
6. ~/.config/brain config
|
6. ~/.config/brain config
|
||||||
7. corpus extraction (facts/info) — **in**: [#18](https://git.produktor.io/eSlider/2dph/issues/18)
|
7. corpus extraction (facts/info) — **open**: [#18](https://git.produktor.io/eSlider/2dph/issues/18)
|
||||||
8. verify: web-search smoke, onlyoffice pg, md-db round-trip, eval, audit
|
8. verify: web-search smoke, onlyoffice pg, md-db round-trip, eval, audit
|
||||||
|
|
||||||
## Gap to v1 (epic #16)
|
## Gap to v1 (epic #16)
|
||||||
|
|
||||||
Remaining: none for epic #16 (v1). Board:
|
Read path + MCP are in. Incremental `brain/add` is in. The detective brain is
|
||||||
|
not closed until search can **walk** the graph and the facts+chats corpus
|
||||||
|
lands on rebuild. Board:
|
||||||
[epic #16](https://git.produktor.io/eSlider/2dph/issues/16),
|
[epic #16](https://git.produktor.io/eSlider/2dph/issues/16),
|
||||||
milestone [v1 detective brain](https://git.produktor.io/eSlider/2dph/milestone/12).
|
milestone [v1 detective brain](https://git.produktor.io/eSlider/2dph/milestone/12).
|
||||||
Narrative: [docs/roadmap.md](docs/roadmap.md).
|
Narrative: [docs/roadmap.md](docs/roadmap.md).
|
||||||
@@ -172,9 +172,9 @@ Narrative: [docs/roadmap.md](docs/roadmap.md).
|
|||||||
| Order | Issue | Gap |
|
| Order | Issue | Gap |
|
||||||
|-------|-------|-----|
|
|-------|-------|-----|
|
||||||
| 1 | [#14](https://git.produktor.io/eSlider/2dph/issues/14) | **in** — `bin/brain/add.go` / `POST /ingest` write facts+info without deleting `kb.lbug`. Bulk corpus still `--rebuild`. Leftover Python (mail/facts) is not the living-graph blocker. |
|
| 1 | [#14](https://git.produktor.io/eSlider/2dph/issues/14) | **in** — `bin/brain/add.go` / `POST /ingest` write facts+info without deleting `kb.lbug`. Bulk corpus still `--rebuild`. Leftover Python (mail/facts) is not the living-graph blocker. |
|
||||||
| 2 | [#17](https://git.produktor.io/eSlider/2dph/issues/17) | **in** — `--hop N` walks `FROM_FILE` → `HAS_VERSION` → `AUTHORED` (max 3). |
|
| 2 | [#17](https://git.produktor.io/eSlider/2dph/issues/17) | `--hop` errors. `FROM_FILE` / `HAS_VERSION` are in schema; search does not walk them. |
|
||||||
| 3 | [#18](https://git.produktor.io/eSlider/2dph/issues/18) | **in** — `--with-facts` / `--facts-json` land `root=facts`; `--with-chats` indexes `var/chats/md`. WhatsApp sync is out of v1. |
|
| 3 | [#18](https://git.produktor.io/eSlider/2dph/issues/18) | Rebuild is mostly `info` (repo md + mail). `facts/extract` and chats are not a first-class index input. WhatsApp sync is a stub. |
|
||||||
| 4 | [#15](https://git.produktor.io/eSlider/2dph/issues/15) | **in** — lever/loop documented (`search` → `get` → `audit`). |
|
| 4 | [#15](https://git.produktor.io/eSlider/2dph/issues/15) | **in** — lever/loop documented (`search` → `get` → `audit`). |
|
||||||
| 5 | [#19](https://git.produktor.io/eSlider/2dph/issues/19) | **in** — CI recall SoT is `bin/brain/eval.go` via Zig. Python `bin/kb/eval` stays as an explicit fallback. |
|
| 5 | [#19](https://git.produktor.io/eSlider/2dph/issues/19) | GitHub CI recall still runs Python `bin/kb/eval`. |
|
||||||
|
|
||||||
Does **not** block epic close: [#6](https://git.produktor.io/eSlider/2dph/issues/6) OCR, OQ1, OQ3, OQ4.
|
Does **not** block epic close: [#6](https://git.produktor.io/eSlider/2dph/issues/6) OCR, OQ1, OQ3, OQ4.
|
||||||
@@ -96,7 +96,7 @@ bin/brain/stats.go # index health
|
|||||||
bin/brain/eval.go # recall@5 gate
|
bin/brain/eval.go # recall@5 gate
|
||||||
```
|
```
|
||||||
|
|
||||||
`--hop N` walks File/Commit/Person from each hit (max 3). `bin/kb/search` is a deprecated wrapper around `bin/brain/search.go`.
|
`--hop` is not implemented (needs File/FROM_FILE edges); the flag errors instead of walking. `bin/kb/search` is a deprecated wrapper around `bin/brain/search.go`.
|
||||||
|
|
||||||
Git history is read with [go-git](https://github.com/go-git/go-git) (no git binary):
|
Git history is read with [go-git](https://github.com/go-git/go-git) (no git binary):
|
||||||
|
|
||||||
@@ -121,7 +121,6 @@ Mail is a first-class corpus (retrievable through the same search):
|
|||||||
bin/mail/sync.go --source onlyoffice,gmail --workers 8 --out var/mail # raw sync (Go)
|
bin/mail/sync.go --source onlyoffice,gmail --workers 8 --out var/mail # raw sync (Go)
|
||||||
bin/mail/import.go --from-raw var/mail # JSON → markdown
|
bin/mail/import.go --from-raw var/mail # JSON → markdown
|
||||||
bin/brain/add.go --text T --root facts --source "a.md x b.md"
|
bin/brain/add.go --text T --root facts --source "a.md x b.md"
|
||||||
bin/brain/index.go --rebuild --with-facts --with-chats # facts extract + chats md
|
|
||||||
bin/brain/index.go --rebuild # rebuild brain (incl. mail)
|
bin/brain/index.go --rebuild # rebuild brain (incl. mail)
|
||||||
bin/brain/search.go "invoice from last week" # same search over mail leafs
|
bin/brain/search.go "invoice from last week" # same search over mail leafs
|
||||||
```
|
```
|
||||||
@@ -169,9 +168,6 @@ docker compose up brain-watch # auto re-index on change
|
|||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
eSlider DevOps engineer practice: ops, OnlyOffice, and mail feed the facts
|
|
||||||
root through `bin/facts/extract` (two-source pairing).
|
|
||||||
|
|
||||||
- [go-second-brain](https://github.com/eSlider/go-second-brain) — the earlier
|
- [go-second-brain](https://github.com/eSlider/go-second-brain) — the earlier
|
||||||
Neo4j + Qdrant + Matrix RAG brain
|
Neo4j + Qdrant + Matrix RAG brain
|
||||||
- [agent-skills](https://github.com/eSlider/agent-skills) — upstream
|
- [agent-skills](https://github.com/eSlider/agent-skills) — upstream
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// bin/brain/index.go - rebuild the Ladybug graph (Python write path).
|
// bin/brain/index.go - rebuild the Ladybug graph (Python write path).
|
||||||
//
|
//
|
||||||
// ./bin/brain/index.go --rebuild --with-facts --with-chats
|
// ./bin/brain/index.go --rebuild
|
||||||
// ./bin/brain/index.go --rebuild --with-mail
|
// ./bin/brain/index.go --rebuild --with-mail
|
||||||
// ./bin/brain/index.go --dry-run --with-mail
|
// ./bin/brain/index.go --dry-run --with-mail
|
||||||
//
|
//
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// bin/brain/search.go - deduction search over the 2dph brain.
|
// bin/brain/search.go - deduction search over the 2dph brain.
|
||||||
//
|
//
|
||||||
// ./bin/brain/search.go "query" [--root facts|info] [--repo P] [-n N] [--hop N] [--json] [--no-web]
|
// ./bin/brain/search.go "query" [--root facts|info] [--repo P] [-n N] [--json] [--no-web]
|
||||||
// ./bin/brain/search.go serve [port]
|
// ./bin/brain/search.go serve [port]
|
||||||
// ./bin/brain/search.go --list-model
|
// ./bin/brain/search.go --list-model
|
||||||
//
|
//
|
||||||
|
|||||||
+2
-3
@@ -29,11 +29,10 @@ func main() {
|
|||||||
case "linkedin":
|
case "linkedin":
|
||||||
os.Exit(chats.RunSyncLinkedIn(args))
|
os.Exit(chats.RunSyncLinkedIn(args))
|
||||||
case "whatsapp":
|
case "whatsapp":
|
||||||
fmt.Fprintln(os.Stderr, "chats: WhatsApp sync is out of v1")
|
fmt.Fprintln(os.Stderr, "chats: WhatsApp not implemented yet")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
case "help", "-h", "--help":
|
case "help", "-h", "--help":
|
||||||
fmt.Fprintln(os.Stderr, `usage: bin/chats/sync.go telegram|linkedin [flags]
|
fmt.Fprintln(os.Stderr, `usage: bin/chats/sync.go telegram|linkedin [flags]`)
|
||||||
WhatsApp sync is out of v1.`)
|
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "chats: unknown platform %q\n", platform)
|
fmt.Fprintf(os.Stderr, "chats: unknown platform %q\n", platform)
|
||||||
|
|||||||
+14
-98
@@ -2,15 +2,12 @@
|
|||||||
"""kb/index - build the 2dph brain from markdown + factual leafs.
|
"""kb/index - build the 2dph brain from markdown + factual leafs.
|
||||||
|
|
||||||
bin/kb/index [--corpus DIR] [--rebuild] [--limit N]
|
bin/kb/index [--corpus DIR] [--rebuild] [--limit N]
|
||||||
bin/kb/index --rebuild --with-facts --with-chats
|
|
||||||
bin/kb/index --json # emit stats as JSON
|
bin/kb/index --json # emit stats as JSON
|
||||||
|
|
||||||
Reads every .md under the corpus (default: repo root docs, skills, READMEs)
|
Reads every .md under the corpus (default: repo root docs, skills, READMEs)
|
||||||
as `info` leafs, embeds them with model2vec (potion-multilingual-128M), and
|
as `info` leafs, embeds them with model2vec (potion-multilingual-128M), and
|
||||||
writes them into var/kb.lbug with FTS + HNSW indexes. `facts` leafs come
|
writes them into var/kb.lbug with FTS + HNSW indexes. `facts` leafs come
|
||||||
from bin/facts/extract (docker × compose × ssh-config pairing) when
|
from bin/facts/extract (docker x compose x ssh-config pairing).
|
||||||
`--with-facts` is set. `--with-chats` indexes markdown under var/chats/md
|
|
||||||
(or a given dir) as info. WhatsApp sync stays out of v1.
|
|
||||||
|
|
||||||
--rebuild drops the database file and indexes from scratch. Without it a run
|
--rebuild drops the database file and indexes from scratch. Without it a run
|
||||||
is idempotent (MERGE by (source,text) id).
|
is idempotent (MERGE by (source,text) id).
|
||||||
@@ -25,7 +22,7 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||||||
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
||||||
|
|
||||||
from kblib import ( # noqa: E402
|
from kblib import ( # noqa: E402
|
||||||
add_leafs, connect, ensure_indexes, init_schema, upsert_leaf, link_from_file,
|
connect, ensure_indexes, init_schema, upsert_leaf,
|
||||||
open_readonly, stats,
|
open_readonly, stats,
|
||||||
)
|
)
|
||||||
from mdleaves import read_markdown, to_all, walk_markdown # noqa: E402
|
from mdleaves import read_markdown, to_all, walk_markdown # noqa: E402
|
||||||
@@ -84,11 +81,10 @@ def index_leafs(conn, leafs: list[dict], embed_fn, limit: int) -> tuple[int, int
|
|||||||
for lf in leafs[:limit] if limit else leafs:
|
for lf in leafs[:limit] if limit else leafs:
|
||||||
query = f"{lf['heading']}\n\n{lf['text']}"
|
query = f"{lf['heading']}\n\n{lf['text']}"
|
||||||
emb = embed_fn(lf["text"]) if lf["text"] else None
|
emb = embed_fn(lf["text"]) if lf["text"] else None
|
||||||
lid = upsert_leaf(conn, text=query, root="info", confidence="confirmed",
|
upsert_leaf(conn, text=query, root="info", confidence="confirmed",
|
||||||
source=lf["source"], source_rev="working-tree",
|
source=lf["source"], source_rev="working-tree",
|
||||||
how="kb/index", loc=lf["source"], type_=lf.get("type", "reference"),
|
how="kb/index", loc=lf["source"], type_=lf.get("type", "reference"),
|
||||||
embedding=emb)
|
embedding=emb)
|
||||||
link_from_file(conn, lid, lf["source"], repo=str(lf.get("repo") or ""))
|
|
||||||
count += 1
|
count += 1
|
||||||
return count, len(leafs)
|
return count, len(leafs)
|
||||||
|
|
||||||
@@ -99,65 +95,12 @@ def embedder():
|
|||||||
return lambda text: model.encode([text])[0].astype(float).tolist()
|
return lambda text: model.encode([text])[0].astype(float).tolist()
|
||||||
|
|
||||||
|
|
||||||
def index_fact_dicts(conn, facts: list[dict], embed_fn) -> int:
|
|
||||||
"""Write extract-shaped dicts as root=facts leafs (2-source source field)."""
|
|
||||||
leafs = []
|
|
||||||
for f in facts:
|
|
||||||
text = str(f.get("text") or "")
|
|
||||||
source = str(f.get("source") or "")
|
|
||||||
if not text or not source:
|
|
||||||
continue
|
|
||||||
leafs.append({
|
|
||||||
"text": text,
|
|
||||||
"root": "facts",
|
|
||||||
"confidence": "confirmed",
|
|
||||||
"source": source,
|
|
||||||
"source_rev": f.get("source_rev") or "working-tree",
|
|
||||||
"how": f.get("how") or "facts/extract",
|
|
||||||
"loc": f.get("loc") or source,
|
|
||||||
"type": "fact",
|
|
||||||
"embedding": embed_fn(text) if text else None,
|
|
||||||
})
|
|
||||||
return len(add_leafs(conn, leafs))
|
|
||||||
|
|
||||||
|
|
||||||
def facts_from_extract() -> list[dict]:
|
|
||||||
import subprocess
|
|
||||||
proc = subprocess.run(
|
|
||||||
[sys.executable, str(ROOT / "bin" / "facts" / "extract"), "--json", "--dry-run"],
|
|
||||||
cwd=ROOT,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if proc.returncode != 0:
|
|
||||||
print(f"kb/index: facts/extract failed: {proc.stderr}", file=sys.stderr)
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
payload = json.loads(proc.stdout)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
print("kb/index: facts/extract produced non-JSON", file=sys.stderr)
|
|
||||||
return []
|
|
||||||
return list(payload.get("facts") or [])
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
def main(argv: list[str]) -> int:
|
||||||
import argparse
|
import argparse
|
||||||
p = argparse.ArgumentParser(description="build the 2dph brain index")
|
p = argparse.ArgumentParser(description="build the 2dph brain index")
|
||||||
p.add_argument("--corpus", action="append", help="extra markdown dir/file to index (may repeat)")
|
p.add_argument("--corpus", action="append", help="extra markdown dir/file to index (may repeat)")
|
||||||
p.add_argument("--rebuild", action="store_true", help="fresh db + indexes")
|
p.add_argument("--rebuild", action="store_true", help="fresh db + indexes")
|
||||||
p.add_argument("--db", default="", help="path to kb.lbug (default var/kb.lbug)")
|
|
||||||
p.add_argument("--no-defaults", action="store_true", help="do not index repo README/docs/skills")
|
|
||||||
p.add_argument("--with-mail", action="store_true", help="include var/mail message.md leafs")
|
p.add_argument("--with-mail", action="store_true", help="include var/mail message.md leafs")
|
||||||
p.add_argument("--with-facts", action="store_true", help="run facts/extract into root=facts")
|
|
||||||
p.add_argument("--facts-json", default="", help="JSON list (or {facts:[...]}) of fact dicts")
|
|
||||||
p.add_argument(
|
|
||||||
"--with-chats",
|
|
||||||
nargs="?",
|
|
||||||
const=str(ROOT / "var" / "chats" / "md"),
|
|
||||||
default="",
|
|
||||||
help="index chat markdown as info (default var/chats/md)",
|
|
||||||
)
|
|
||||||
p.add_argument("--since", default="", help="with --with-mail, only messages dated >= YYYY-MM-DD")
|
p.add_argument("--since", default="", help="with --with-mail, only messages dated >= YYYY-MM-DD")
|
||||||
p.add_argument("--dry-run", action="store_true", help="count leafs, write nothing")
|
p.add_argument("--dry-run", action="store_true", help="count leafs, write nothing")
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
@@ -171,71 +114,44 @@ def main(argv: list[str]) -> int:
|
|||||||
|
|
||||||
from kblib import DB_PATH, VAR
|
from kblib import DB_PATH, VAR
|
||||||
|
|
||||||
dbpath = Path(a.db) if a.db else DB_PATH
|
leafs = load_corpus(ROOT)
|
||||||
leafs: list[dict] = [] if a.no_defaults else load_corpus(ROOT)
|
|
||||||
if a.corpus:
|
if a.corpus:
|
||||||
for source in a.corpus:
|
for source in a.corpus:
|
||||||
leafs.extend(load_corpus_glob(source))
|
leafs.extend(load_corpus_glob(source))
|
||||||
chat_n = 0
|
|
||||||
if a.with_chats:
|
|
||||||
chats = load_corpus_glob(a.with_chats)
|
|
||||||
chat_n = len(chats)
|
|
||||||
leafs.extend(chats)
|
|
||||||
mail_n = 0
|
mail_n = 0
|
||||||
if a.with_mail:
|
if a.with_mail:
|
||||||
mail = from_mail_root(ROOT / "var" / "mail", since=a.since)
|
mail = from_mail_root(ROOT / "var" / "mail", since=a.since)
|
||||||
mail_n = len(mail)
|
mail_n = len(mail)
|
||||||
leafs.extend(mail)
|
leafs.extend(mail)
|
||||||
|
|
||||||
facts: list[dict] = []
|
|
||||||
if a.facts_json:
|
|
||||||
raw = Path(a.facts_json).read_text(encoding="utf-8")
|
|
||||||
payload = json.loads(raw)
|
|
||||||
facts = list(payload.get("facts") if isinstance(payload, dict) else payload)
|
|
||||||
if a.with_facts:
|
|
||||||
facts.extend(facts_from_extract())
|
|
||||||
|
|
||||||
if a.dry_run:
|
if a.dry_run:
|
||||||
msg = {
|
msg = {"indexed": 0, "corpus_total": len(leafs), "mail_leafs": mail_n, "dry_run": True}
|
||||||
"indexed": 0,
|
|
||||||
"corpus_total": len(leafs),
|
|
||||||
"mail_leafs": mail_n,
|
|
||||||
"chat_leafs": chat_n,
|
|
||||||
"facts_leafs": len(facts),
|
|
||||||
"dry_run": True,
|
|
||||||
}
|
|
||||||
print(json.dumps(msg, indent=2) if a.json else
|
print(json.dumps(msg, indent=2) if a.json else
|
||||||
f"brain/index: {len(leafs)} info + {len(facts)} facts would be indexed")
|
f"brain/index: {len(leafs)} leafs would be indexed (mail={mail_n})")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
VAR.mkdir(exist_ok=True)
|
VAR.mkdir(exist_ok=True)
|
||||||
dbpath.parent.mkdir(parents=True, exist_ok=True)
|
if a.rebuild and DB_PATH.exists():
|
||||||
if a.rebuild and dbpath.exists():
|
DB_PATH.unlink()
|
||||||
dbpath.unlink()
|
|
||||||
|
|
||||||
db, conn = connect(dbpath, read_only=False)
|
db, conn = connect(DB_PATH, read_only=False)
|
||||||
init_schema(conn)
|
init_schema(conn)
|
||||||
|
|
||||||
|
# Never DROP FTS/VECTOR (ghost catalog). Write leafs, then ensure indexes
|
||||||
|
# unless --skip-indexes (seed facts first — MERGE under live FTS corrupts it).
|
||||||
|
# --rebuild already deleted kb.lbug above, so CREATE runs on a clean DB.
|
||||||
embed = embedder()
|
embed = embedder()
|
||||||
done, total = index_leafs(conn, leafs, embed, a.limit)
|
done, total = index_leafs(conn, leafs, embed, a.limit)
|
||||||
fact_n = index_fact_dicts(conn, facts, embed) if facts else 0
|
|
||||||
if not a.skip_indexes:
|
if not a.skip_indexes:
|
||||||
ensure_indexes(conn)
|
ensure_indexes(conn)
|
||||||
s = stats(conn)
|
s = stats(conn)
|
||||||
conn.close()
|
conn.close()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
result = {
|
result = {"indexed": done, "corpus_total": total, **{k: v for k, v in s.items() if k in ("total", "by_root")}}
|
||||||
"indexed": done,
|
|
||||||
"corpus_total": total,
|
|
||||||
"facts_leafs": fact_n,
|
|
||||||
"chat_leafs": chat_n,
|
|
||||||
**{k: v for k, v in s.items() if k in ("total", "by_root")},
|
|
||||||
}
|
|
||||||
if a.skip_indexes:
|
if a.skip_indexes:
|
||||||
result["indexes"] = "skipped"
|
result["indexes"] = "skipped"
|
||||||
print(json.dumps(result, indent=2) if a.json else
|
print(json.dumps(result, indent=2) if a.json else f"indexed {done}/{total} leafs; db total {s['total']}")
|
||||||
f"indexed {done}/{total} info + {fact_n} facts; db total {s['total']}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -162,53 +162,6 @@ def add_leafs(conn: ladybug.Connection, leafs: list[dict]) -> list[str]:
|
|||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
|
||||||
def file_id(repo: str, path: str) -> str:
|
|
||||||
"""Stable File.id matching gitimport (`repo:path`)."""
|
|
||||||
return f"{repo}:{path}" if repo else path
|
|
||||||
|
|
||||||
|
|
||||||
def link_from_file(conn: ladybug.Connection, leaf_id: str, path: str,
|
|
||||||
repo: str = "", mtime: str = "") -> str:
|
|
||||||
"""MERGE File and Leaf-[:FROM_FILE]->File so --hop 1 can walk."""
|
|
||||||
fid = file_id(repo, path)
|
|
||||||
conn.execute(
|
|
||||||
"MERGE (f:File {id:$id}) SET f.path=$path, f.repo=$repo, f.mtime=$mtime",
|
|
||||||
parameters={"id": fid, "path": path, "repo": repo, "mtime": mtime},
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"MATCH (l:Leaf {id:$lid}), (f:File {id:$fid}) "
|
|
||||||
"MERGE (l)-[:FROM_FILE]->(f)",
|
|
||||||
parameters={"lid": leaf_id, "fid": fid},
|
|
||||||
)
|
|
||||||
return fid
|
|
||||||
|
|
||||||
|
|
||||||
HOP_STMTS = {
|
|
||||||
1: "MATCH (l:Leaf {id:$id})-[:FROM_FILE]->(f:File) RETURN f.id, f.path, 1",
|
|
||||||
2: ("MATCH (l:Leaf {id:$id})-[:FROM_FILE]->(f:File)-[:HAS_VERSION]->(c:Commit) "
|
|
||||||
"RETURN c.id, c.subject, 2"),
|
|
||||||
3: ("MATCH (l:Leaf {id:$id})-[:FROM_FILE]->(f:File)-[:HAS_VERSION]->(c:Commit)"
|
|
||||||
"-[:AUTHORED]->(p:Person) RETURN p.id, p.name, 3"),
|
|
||||||
}
|
|
||||||
HOP_LABELS = {1: "File", 2: "Commit", 3: "Person"}
|
|
||||||
|
|
||||||
|
|
||||||
def hop_walk(conn: ladybug.Connection, leaf_id: str, n: int) -> list[dict]:
|
|
||||||
"""Walk Leaf → File → Commit → Person up to n hops (max 3)."""
|
|
||||||
depth = min(max(int(n), 0), 3)
|
|
||||||
out: list[dict] = []
|
|
||||||
for d in range(1, depth + 1):
|
|
||||||
rows = conn.execute(HOP_STMTS[d], parameters={"id": leaf_id}).get_all()
|
|
||||||
for row in rows:
|
|
||||||
out.append({
|
|
||||||
"id": row[0],
|
|
||||||
"label": HOP_LABELS[d],
|
|
||||||
"name": row[1],
|
|
||||||
"depth": int(row[2]),
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def leaf_index_names(conn: ladybug.Connection) -> set[str]:
|
def leaf_index_names(conn: ladybug.Connection) -> set[str]:
|
||||||
"""Return index names on the Leaf table (e.g. {'id', 'Leaf_vec', '_PK'})."""
|
"""Return index names on the Leaf table (e.g. {'id', 'Leaf_vec', '_PK'})."""
|
||||||
rows = conn.execute("CALL SHOW_INDEXES() RETURN *").get_all()
|
rows = conn.execute("CALL SHOW_INDEXES() RETURN *").get_all()
|
||||||
|
|||||||
@@ -207,25 +207,3 @@ class BinLayoutTest(unittest.TestCase):
|
|||||||
search = (ROOT / "bin" / "kb" / "search").read_text()
|
search = (ROOT / "bin" / "kb" / "search").read_text()
|
||||||
self.assertIn("bin/cgo/zig", search)
|
self.assertIn("bin/cgo/zig", search)
|
||||||
self.assertNotIn("command -v gcc", search)
|
self.assertNotIn("command -v gcc", search)
|
||||||
|
|
||||||
def test_ci_recall_sot_is_zig_brain_eval(self) -> None:
|
|
||||||
ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text()
|
|
||||||
self.assertIn("bin/brain/eval.go", ci)
|
|
||||||
self.assertIn("system_ladybug,brain_eval", ci)
|
|
||||||
self.assertIn("/tmp/brain-eval", ci)
|
|
||||||
self.assertIn("KB_ROOT", ci)
|
|
||||||
self.assertNotIn("bin/kb/eval", ci)
|
|
||||||
self.assertNotIn("gate skipped", ci)
|
|
||||||
self.assertIn("./bin/facts/audit self", ci)
|
|
||||||
|
|
||||||
def test_eval_fragments_live_in_default_corpus(self) -> None:
|
|
||||||
"""CI --rebuild indexes README/PLAN/docs/skills; fragments must be there."""
|
|
||||||
corpus = []
|
|
||||||
for rel in ("README.md", "PLAN.md", "AGENTS.md"):
|
|
||||||
corpus.append((ROOT / rel).read_text())
|
|
||||||
for d in ("docs", "skills"):
|
|
||||||
for p in (ROOT / d).rglob("*.md"):
|
|
||||||
corpus.append(p.read_text())
|
|
||||||
blob = "\n".join(corpus)
|
|
||||||
for frag in ("BM25", "DevOps", "LadybugDB"):
|
|
||||||
self.assertIn(frag, blob, f"{frag} must appear in default index corpus")
|
|
||||||
|
|||||||
@@ -39,59 +39,3 @@ class IndexAdapterTest(unittest.TestCase):
|
|||||||
self.assertTrue(msg.get("dry_run"))
|
self.assertTrue(msg.get("dry_run"))
|
||||||
self.assertGreaterEqual(msg.get("corpus_total", 0), 1)
|
self.assertGreaterEqual(msg.get("corpus_total", 0), 1)
|
||||||
self.assertFalse(lbug.exists(), "dry-run must not create a Ladybug file")
|
self.assertFalse(lbug.exists(), "dry-run must not create a Ladybug file")
|
||||||
|
|
||||||
def test_facts_json_and_chats_land_on_rebuild(self) -> None:
|
|
||||||
"""Gitea #18: facts (2-source) + chats markdown become leafs on rebuild."""
|
|
||||||
tmp = Path(tempfile.mkdtemp())
|
|
||||||
dbpath = tmp / "kb.lbug"
|
|
||||||
chats = tmp / "chats"
|
|
||||||
chats.mkdir()
|
|
||||||
(chats / "alice.md").write_text(
|
|
||||||
"# Chat\n\n## Alice and Bob\n\nhello from chats fixture unique-chat-token\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
facts_path = tmp / "facts.json"
|
|
||||||
facts_path.write_text(json.dumps([{
|
|
||||||
"text": "container 'brain' unique-fact-token is running and declared in compose.yaml",
|
|
||||||
"source": "docker ps x compose.yaml",
|
|
||||||
"loc": "compose.yaml:brain",
|
|
||||||
"how": "facts/extract",
|
|
||||||
}]), encoding="utf-8")
|
|
||||||
venv_py = ROOT / ".venv" / "bin" / "python"
|
|
||||||
py = str(venv_py) if venv_py.is_file() else sys.executable
|
|
||||||
proc = subprocess.run(
|
|
||||||
[
|
|
||||||
py, str(ROOT / "bin" / "kb" / "index"),
|
|
||||||
"--rebuild", "--db", str(dbpath), "--no-defaults",
|
|
||||||
"--with-chats", str(chats),
|
|
||||||
"--facts-json", str(facts_path),
|
|
||||||
"--json",
|
|
||||||
],
|
|
||||||
cwd=ROOT,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env=os.environ.copy(),
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
||||||
msg = json.loads(proc.stdout)
|
|
||||||
self.assertGreaterEqual(msg.get("facts_leafs", 0), 1)
|
|
||||||
self.assertGreaterEqual(msg.get("chat_leafs", 0), 1)
|
|
||||||
self.assertTrue(dbpath.exists())
|
|
||||||
sys.path.insert(0, str(ROOT / "bin" / "tools"))
|
|
||||||
import kblib
|
|
||||||
db, conn = kblib.connect(dbpath, read_only=True)
|
|
||||||
try:
|
|
||||||
stats = kblib.stats(conn)
|
|
||||||
self.assertGreaterEqual(stats["by_root"].get("facts", 0), 1)
|
|
||||||
fts = kblib.query_fts(conn, "unique-chat-token", 5)
|
|
||||||
self.assertTrue(fts, "chats markdown must be FTS-searchable")
|
|
||||||
fact_hits = kblib.query_fts(conn, "unique-fact-token", 5)
|
|
||||||
self.assertTrue(any(h.get("root") == "facts" for h in fact_hits))
|
|
||||||
src = conn.execute(
|
|
||||||
"MATCH (l:Leaf {root:'facts'}) RETURN l.source"
|
|
||||||
).get_all()
|
|
||||||
self.assertTrue(any(" x " in str(r[0]) for r in src))
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
db.close()
|
|
||||||
|
|||||||
@@ -161,39 +161,6 @@ class KblibTest(unittest.TestCase):
|
|||||||
self.assertEqual(stats["total"], 2)
|
self.assertEqual(stats["total"], 2)
|
||||||
self.assertEqual(stats["by_root"], {"facts": 1, "info": 1})
|
self.assertEqual(stats["by_root"], {"facts": 1, "info": 1})
|
||||||
|
|
||||||
def test_hop_1_returns_file_hop_3_reaches_person(self):
|
|
||||||
"""--hop walks FROM_FILE / HAS_VERSION / AUTHORED (Gitea #17)."""
|
|
||||||
import gitimport
|
|
||||||
|
|
||||||
lid = kblib.upsert_leaf(
|
|
||||||
self.conn, text="readme hop fixture", root="info",
|
|
||||||
confidence="confirmed", source="README.md", source_rev="r1",
|
|
||||||
how="test", loc="README.md", type_="reference",
|
|
||||||
embedding=make_emb(0.3),
|
|
||||||
)
|
|
||||||
kblib.link_from_file(self.conn, lid, "README.md", repo="sample-repo")
|
|
||||||
gitimport.index_commits(self.conn, [gitimport.Commit(
|
|
||||||
sha="a1b2c3d",
|
|
||||||
author="Ada Lovelace",
|
|
||||||
email="ada@example.com",
|
|
||||||
date="2026-08-10T12:00:00Z",
|
|
||||||
subject="feat: first commit",
|
|
||||||
files=["README.md"],
|
|
||||||
)], "sample-repo")
|
|
||||||
hop1 = kblib.hop_walk(self.conn, lid, 1)
|
|
||||||
self.assertEqual(len(hop1), 1)
|
|
||||||
self.assertEqual(hop1[0]["label"], "File")
|
|
||||||
self.assertEqual(hop1[0]["name"], "README.md")
|
|
||||||
self.assertEqual(hop1[0]["depth"], 1)
|
|
||||||
hop3 = kblib.hop_walk(self.conn, lid, 3)
|
|
||||||
labels = {n["label"] for n in hop3}
|
|
||||||
self.assertIn("File", labels)
|
|
||||||
self.assertIn("Commit", labels)
|
|
||||||
self.assertIn("Person", labels)
|
|
||||||
person = [n for n in hop3 if n["label"] == "Person"][0]
|
|
||||||
self.assertEqual(person["name"], "Ada Lovelace")
|
|
||||||
self.assertEqual(person["depth"], 3)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Published docs must match live commands (Gitea SoT, brain/search)."""
|
"""Published docs must match live commands (Gitea SoT, brain/search, no fake --hop)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -138,21 +139,24 @@ class PublishedDocsTest(unittest.TestCase):
|
|||||||
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
|
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
|
||||||
self.assertIn("`web` block", skill)
|
self.assertIn("`web` block", skill)
|
||||||
|
|
||||||
def test_docs_say_hop_walks_from_file(self) -> None:
|
def test_docs_do_not_claim_hop_walks(self) -> None:
|
||||||
paths = [
|
paths = [
|
||||||
ROOT / "README.md",
|
ROOT / "README.md",
|
||||||
ROOT / "docs" / "design.md",
|
ROOT / "docs" / "design.md",
|
||||||
ROOT / "skills" / "brain" / "SKILL.md",
|
ROOT / "skills" / "brain" / "SKILL.md",
|
||||||
|
ROOT / "skills" / "diataxis-docs" / "SKILL.md",
|
||||||
ROOT / "docs" / "runbook.md",
|
ROOT / "docs" / "runbook.md",
|
||||||
ROOT / "docs" / "README.md",
|
ROOT / "docs" / "README.md",
|
||||||
|
ROOT / "docs" / "roadmap.md",
|
||||||
]
|
]
|
||||||
|
# Command-style `--hop 1` / `--hop N` plus follow/walk = the old lie.
|
||||||
|
# Honest "not implemented" notes must not match.
|
||||||
|
lie = re.compile(r"--hop (?:N|1).*(?:follow|walk)", re.I | re.S)
|
||||||
for path in paths:
|
for path in paths:
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
self.assertIn("--hop", text, f"{path.relative_to(ROOT)} must document --hop")
|
self.assertIsNone(
|
||||||
self.assertNotIn(
|
lie.search(text),
|
||||||
"not implemented",
|
f"{path.relative_to(ROOT)} still claims --hop walks the graph",
|
||||||
text.lower(),
|
|
||||||
f"{path.relative_to(ROOT)} still says hop is not implemented",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_docs_are_portable_diataxis(self) -> None:
|
def test_docs_are_portable_diataxis(self) -> None:
|
||||||
|
|||||||
+3
-3
@@ -27,9 +27,9 @@ Python write sidecar, **D14** `bin/{subject}/{method}.go`, **D15** Gitea origin,
|
|||||||
**D17** assertion gate (facts → info → web), **D18** pluggable reasoner.
|
**D17** assertion gate (facts → info → web), **D18** pluggable reasoner.
|
||||||
|
|
||||||
Search: `bin/brain/search.go "query"` (HTTP: `bin/brain/serve.go` —
|
Search: `bin/brain/search.go "query"` (HTTP: `bin/brain/serve.go` —
|
||||||
`/health` `/search` `/get` `/stats` `/audit` `/ingest`). `--hop N` walks
|
`/health` `/search` `/get` `/stats` `/audit` `/ingest`). `--hop` is
|
||||||
`FROM_FILE` → Commit → Person from each hit (max 3). Rebuild writes
|
not a walk; the flag errors. Schema has `FROM_FILE`; search does not
|
||||||
File edges ([#17](https://git.produktor.io/eSlider/2dph/issues/17)).
|
use it ([#17](https://git.produktor.io/eSlider/2dph/issues/17)).
|
||||||
|
|
||||||
Work board: [Gitea issues](https://git.produktor.io/eSlider/2dph/issues)
|
Work board: [Gitea issues](https://git.produktor.io/eSlider/2dph/issues)
|
||||||
([epic #16](https://git.produktor.io/eSlider/2dph/issues/16)).
|
([epic #16](https://git.produktor.io/eSlider/2dph/issues/16)).
|
||||||
|
|||||||
@@ -21,5 +21,4 @@ OO_CLI (default: $HOME/go/bin/oo)
|
|||||||
./bin/chats/apply.go --dry-run
|
./bin/chats/apply.go --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
JSONL → markdown only. Brain ingest is `bin/brain/index.go --with-chats`
|
JSONL → markdown only. Brain ingest is `bin/brain/index.go` (not a `chats index`).
|
||||||
(default `var/chats/md`). WhatsApp sync is out of v1.
|
|
||||||
|
|||||||
+3
-3
@@ -34,9 +34,9 @@ bin/brain/search.go "question"
|
|||||||
is not evidence of absence; `--no-web` / `--root` skip it)
|
is not evidence of absence; `--no-web` / `--root` skip it)
|
||||||
```
|
```
|
||||||
|
|
||||||
`--hop N` walks `Leaf-[:FROM_FILE]->File-[:HAS_VERSION]->Commit-[:AUTHORED]->Person`
|
`--hop` is not implemented. `FROM_FILE` / `HAS_VERSION` exist in schema;
|
||||||
from each hit (1=File, 2=Commit, 3=Person). Rebuild writes FROM_FILE;
|
search does not walk them ([#17](https://git.produktor.io/eSlider/2dph/issues/17)).
|
||||||
git import writes HAS_VERSION/AUTHORED ([#17](https://git.produktor.io/eSlider/2dph/issues/17)).
|
The flag is an error; it is not a graph walk.
|
||||||
|
|
||||||
## Who / What / How / Where / When + evidence
|
## Who / What / How / Where / When + evidence
|
||||||
|
|
||||||
|
|||||||
+14
-12
@@ -26,18 +26,9 @@ Compose `api` (no CPython) / `index` (Python write). Issues #1–#5, #7–#13.
|
|||||||
[#15](https://git.produktor.io/eSlider/2dph/issues/15) lever/loop.
|
[#15](https://git.produktor.io/eSlider/2dph/issues/15) lever/loop.
|
||||||
[#14](https://git.produktor.io/eSlider/2dph/issues/14) `bin/brain/add.go` /
|
[#14](https://git.produktor.io/eSlider/2dph/issues/14) `bin/brain/add.go` /
|
||||||
`POST /ingest` (Python `kblib.add_leafs`; no Go upsert port).
|
`POST /ingest` (Python `kblib.add_leafs`; no Go upsert port).
|
||||||
[#17](https://git.produktor.io/eSlider/2dph/issues/17) `--hop N` walks
|
|
||||||
FROM_FILE / HAS_VERSION / AUTHORED.
|
|
||||||
[#18](https://git.produktor.io/eSlider/2dph/issues/18) `--with-facts` /
|
|
||||||
`--with-chats` on rebuild (WhatsApp out of v1).
|
|
||||||
[#19](https://git.produktor.io/eSlider/2dph/issues/19) CI recall SoT =
|
|
||||||
`bin/brain/eval.go` via Zig.
|
|
||||||
|
|
||||||
## Blockers
|
## Blockers
|
||||||
|
|
||||||
None for epic #16. v2: [#6](https://git.produktor.io/eSlider/2dph/issues/6) OCR,
|
|
||||||
OQ1 contradiction resolution, OQ3 duckdb-md, OQ4 YAML-first leafs.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
question
|
question
|
||||||
│
|
│
|
||||||
@@ -45,10 +36,19 @@ question
|
|||||||
├─ facts / info roots ← in
|
├─ facts / info roots ← in
|
||||||
├─ web (D17) ← in
|
├─ web (D17) ← in
|
||||||
├─ brain/add ACID ← in
|
├─ brain/add ACID ← in
|
||||||
├─ Cypher hop ← in
|
├─ Cypher hop ← #17 schema yes, search no
|
||||||
└─ facts+chats corpus ← in
|
└─ facts+chats corpus ← #18
|
||||||
```
|
```
|
||||||
|
|
||||||
|
1. **[#17](https://git.produktor.io/eSlider/2dph/issues/17) hops** —
|
||||||
|
`Leaf-[:FROM_FILE]->File-[:HAS_VERSION]->Commit-[:AUTHORED]->Person`
|
||||||
|
exists; `--hop` still errors. Without a walk, D9/D10 are paper.
|
||||||
|
2. **[#18](https://git.produktor.io/eSlider/2dph/issues/18) corpus** —
|
||||||
|
rebuild loads repo markdown + mail as `info`. `facts/extract` pairing
|
||||||
|
and `bin/chats` are not indexed. WhatsApp is a stub. PII stays in `var/`.
|
||||||
|
3. **[#19](https://git.produktor.io/eSlider/2dph/issues/19) CI eval** —
|
||||||
|
recall SoT should be `bin/brain/eval.go` via Zig, not Python `bin/kb/eval`.
|
||||||
|
|
||||||
## Not v1
|
## Not v1
|
||||||
|
|
||||||
[#6](https://git.produktor.io/eSlider/2dph/issues/6) OCR (OQ2), OQ1
|
[#6](https://git.produktor.io/eSlider/2dph/issues/6) OCR (OQ2), OQ1
|
||||||
@@ -56,4 +56,6 @@ contradiction resolution, OQ3 duckdb-md export, OQ4 YAML-first leafs.
|
|||||||
|
|
||||||
## Close epic #16 when
|
## Close epic #16 when
|
||||||
|
|
||||||
Children #14, #15, #17, #18, #19 are closed. MCP tool order stays gated by tests.
|
- `--hop` stops erroring and runs a Cypher path from search hits
|
||||||
|
- ops pairing + chat import land as leafs on rebuild
|
||||||
|
- MCP tool order is documented and still gated by tests
|
||||||
|
|||||||
+2
-2
@@ -49,14 +49,14 @@ corpus rebuild remains `bin/brain/index.go --rebuild` (Compose profile
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
bin/brain/add.go --text "arc-1 runs Matrix" --root facts --source "compose.yml x docker ps"
|
bin/brain/add.go --text "arc-1 runs Matrix" --root facts --source "compose.yml x docker ps"
|
||||||
bin/brain/index.go --rebuild --with-facts --with-chats
|
bin/brain/index.go --rebuild
|
||||||
bin/brain/search.go "LadybugDB vector index" # facts → info → web (D17)
|
bin/brain/search.go "LadybugDB vector index" # facts → info → web (D17)
|
||||||
bin/brain/search.go "upstream flag" --no-web
|
bin/brain/search.go "upstream flag" --no-web
|
||||||
bin/brain/get.go <id> --body
|
bin/brain/get.go <id> --body
|
||||||
bin/brain/stats.go
|
bin/brain/stats.go
|
||||||
```
|
```
|
||||||
|
|
||||||
`--hop N` walks File → Commit → Person from each hit. Empty web results are `throttled`, not absence.
|
`--hop` is not implemented. Empty web results are `throttled`, not absence.
|
||||||
Gap to v1: [roadmap](roadmap.md) / [epic #16](https://git.produktor.io/eSlider/2dph/issues/16).
|
Gap to v1: [roadmap](roadmap.md) / [epic #16](https://git.produktor.io/eSlider/2dph/issues/16).
|
||||||
|
|
||||||
Ladybug 0.19: never `DROP INDEX` FTS/VECTOR (ghost catalog). Fresh indexes =
|
Ladybug 0.19: never `DROP INDEX` FTS/VECTOR (ghost catalog). Fresh indexes =
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--hop N] [--json] [--no-web]
|
const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--json] [--no-web]
|
||||||
bin/brain/search.go serve [port]
|
bin/brain/search.go serve [port]
|
||||||
bin/brain/search.go --list-model`
|
bin/brain/search.go --list-model`
|
||||||
|
|
||||||
@@ -15,7 +15,6 @@ type Options struct {
|
|||||||
Root string
|
Root string
|
||||||
Repo string
|
Repo string
|
||||||
Limit int
|
Limit int
|
||||||
Hop int
|
|
||||||
JSONOut bool
|
JSONOut bool
|
||||||
ListModel bool
|
ListModel bool
|
||||||
NoWeb bool
|
NoWeb bool
|
||||||
@@ -23,6 +22,8 @@ type Options struct {
|
|||||||
|
|
||||||
// ParseArgs reads flags. Unknown flags are an error: silently dropping them
|
// ParseArgs reads flags. Unknown flags are an error: silently dropping them
|
||||||
// meant `--hop 1` vanished and its argument `1` was appended to the query.
|
// meant `--hop 1` vanished and its argument `1` was appended to the query.
|
||||||
|
// --hop is recognised so it cannot be swallowed; it is not implemented until
|
||||||
|
// File/FROM_FILE edges exist.
|
||||||
func ParseArgs(args []string) (Options, error) {
|
func ParseArgs(args []string) (Options, error) {
|
||||||
opt := Options{Limit: 20}
|
opt := Options{Limit: 20}
|
||||||
var queryArgs []string
|
var queryArgs []string
|
||||||
@@ -51,15 +52,7 @@ func ParseArgs(args []string) (Options, error) {
|
|||||||
}
|
}
|
||||||
opt.Limit = n
|
opt.Limit = n
|
||||||
case "--hop":
|
case "--hop":
|
||||||
i++
|
return opt, fmt.Errorf("--hop is not implemented yet (needs File/FROM_FILE edges)")
|
||||||
n, err := strconv.Atoi(args[i])
|
|
||||||
if err != nil || n < 1 {
|
|
||||||
return opt, fmt.Errorf("--hop must be a positive integer, got %q", args[i])
|
|
||||||
}
|
|
||||||
if n > 3 {
|
|
||||||
return opt, fmt.Errorf("--hop max is 3 (File → Commit → Person)")
|
|
||||||
}
|
|
||||||
opt.Hop = n
|
|
||||||
case "--json":
|
case "--json":
|
||||||
opt.JSONOut = true
|
opt.JSONOut = true
|
||||||
case "--no-web":
|
case "--no-web":
|
||||||
|
|||||||
@@ -7,30 +7,3 @@ const FTSStmt = "CALL QUERY_FTS_INDEX('Leaf', 'id', $q) " +
|
|||||||
|
|
||||||
const VecStmt = "CALL QUERY_VECTOR_INDEX('Leaf', 'Leaf_vec', $q, $n) " +
|
const VecStmt = "CALL QUERY_VECTOR_INDEX('Leaf', 'Leaf_vec', $q, $n) " +
|
||||||
"RETURN node.id, node.text, node.root, node.source, distance ORDER BY distance LIMIT $n"
|
"RETURN node.id, node.text, node.root, node.source, distance ORDER BY distance LIMIT $n"
|
||||||
|
|
||||||
// HopStmt is the Cypher walk from a search hit. Depth 1 = File, 2 = Commit, 3 = Person.
|
|
||||||
func HopStmt(depth int) string {
|
|
||||||
switch depth {
|
|
||||||
case 1:
|
|
||||||
return "MATCH (l:Leaf {id:$id})-[:FROM_FILE]->(f:File) RETURN f.id, f.path, 1"
|
|
||||||
case 2:
|
|
||||||
return "MATCH (l:Leaf {id:$id})-[:FROM_FILE]->(f:File)-[:HAS_VERSION]->(c:Commit) RETURN c.id, c.subject, 2"
|
|
||||||
case 3:
|
|
||||||
return "MATCH (l:Leaf {id:$id})-[:FROM_FILE]->(f:File)-[:HAS_VERSION]->(c:Commit)-[:AUTHORED]->(p:Person) RETURN p.id, p.name, 3"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func HopLabel(depth int) string {
|
|
||||||
switch depth {
|
|
||||||
case 1:
|
|
||||||
return "File"
|
|
||||||
case 2:
|
|
||||||
return "Commit"
|
|
||||||
case 3:
|
|
||||||
return "Person"
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,13 +7,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HopNode struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Depth int `json:"depth"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hit is one search result, mirroring the python script's dict shape.
|
// Hit is one search result, mirroring the python script's dict shape.
|
||||||
type Hit struct {
|
type Hit struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -22,7 +15,6 @@ type Hit struct {
|
|||||||
Source string `json:"-"`
|
Source string `json:"-"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
Snippet string `json:"snippet,omitempty"`
|
Snippet string `json:"snippet,omitempty"`
|
||||||
Hops []HopNode `json:"hops,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rrfK dampens the contribution of low ranks; same constant as kblib.py.
|
// rrfK dampens the contribution of low ranks; same constant as kblib.py.
|
||||||
|
|||||||
@@ -91,41 +91,15 @@ func TestHybridKeepsVectorScoreForSharedHit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The old parser dropped unknown flags and appended their arguments to the
|
// The old parser dropped unknown flags and appended their arguments to the
|
||||||
// query, so `search "q" --hop 1` searched for "q 1". --hop must stay a flag.
|
// query, so `search "q" --hop 1` searched for "q 1". --hop is not implemented
|
||||||
|
// here (needs File edges); it must still fail closed instead of changing q.
|
||||||
func TestParseHopIsNotSwallowedIntoTheQuery(t *testing.T) {
|
func TestParseHopIsNotSwallowedIntoTheQuery(t *testing.T) {
|
||||||
opt, err := ParseArgs([]string{"what runs on arc-2", "--hop", "1"})
|
_, err := ParseArgs([]string{"what runs on arc-2", "--hop", "1"})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatal("expected --hop to error (not implemented), not be swallowed")
|
||||||
}
|
}
|
||||||
if opt.Query != "what runs on arc-2" {
|
if !strings.Contains(err.Error(), "--hop") {
|
||||||
t.Fatalf("query swallowed hop arg: %q", opt.Query)
|
t.Fatalf("error should name --hop, got %v", err)
|
||||||
}
|
|
||||||
if opt.Hop != 1 {
|
|
||||||
t.Fatalf("hop = %d, want 1", opt.Hop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseHopMaxIsThree(t *testing.T) {
|
|
||||||
if _, err := ParseArgs([]string{"q", "--hop", "4"}); err == nil {
|
|
||||||
t.Fatal("expected --hop 4 to error")
|
|
||||||
}
|
|
||||||
opt, err := ParseArgs([]string{"q", "--hop", "3"})
|
|
||||||
if err != nil || opt.Hop != 3 {
|
|
||||||
t.Fatalf("hop 3: %+v err=%v", opt, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHopStmtWalksFromFile(t *testing.T) {
|
|
||||||
s := HopStmt(1)
|
|
||||||
if !strings.Contains(s, "FROM_FILE") || !strings.Contains(s, "File") {
|
|
||||||
t.Fatalf("hop 1 must walk FROM_FILE, got %q", s)
|
|
||||||
}
|
|
||||||
s3 := HopStmt(3)
|
|
||||||
if !strings.Contains(s3, "HAS_VERSION") || !strings.Contains(s3, "AUTHORED") || !strings.Contains(s3, "Person") {
|
|
||||||
t.Fatalf("hop 3 must reach Person, got %q", s3)
|
|
||||||
}
|
|
||||||
if HopLabel(1) != "File" || HopLabel(3) != "Person" {
|
|
||||||
t.Fatal("hop labels")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,12 +56,6 @@ func runSearch(args []string) int {
|
|||||||
fmt.Fprintf(os.Stderr, "search: %v\n", err)
|
fmt.Fprintf(os.Stderr, "search: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
if opt.Hop > 0 {
|
|
||||||
if err := attachHops(hits, opt.Hop); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "hop: %v\n", err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results := hits
|
results := hits
|
||||||
for i := range results {
|
for i := range results {
|
||||||
@@ -114,44 +108,6 @@ func searchHits(query, root, repo string, limit int) ([]Hit, error) {
|
|||||||
return rank.RankAndFilter(fts, vec, root, repo, limit), nil
|
return rank.RankAndFilter(fts, vec, root, repo, limit), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachHops(hits []Hit, n int) error {
|
|
||||||
if conn == nil {
|
|
||||||
return fmt.Errorf("brain not open")
|
|
||||||
}
|
|
||||||
for i := range hits {
|
|
||||||
var hops []rank.HopNode
|
|
||||||
for d := 1; d <= n; d++ {
|
|
||||||
stmt, err := conn.Prepare(rank.HopStmt(d))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
res, err := conn.Execute(stmt, map[string]any{"id": hits[i].ID})
|
|
||||||
stmt.Close()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for res.HasNext() {
|
|
||||||
row, err := res.Next()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
vals, err := row.GetAsSlice()
|
|
||||||
if err != nil || len(vals) < 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hops = append(hops, rank.HopNode{
|
|
||||||
ID: fmt.Sprint(vals[0]),
|
|
||||||
Label: rank.HopLabel(d),
|
|
||||||
Name: fmt.Sprint(vals[1]),
|
|
||||||
Depth: int(asInt(vals[2])),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hits[i].Hops = hops
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func b2i(err error) int {
|
func b2i(err error) int {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 1
|
return 1
|
||||||
@@ -232,7 +188,6 @@ type jsonHit struct {
|
|||||||
Root string `json:"root"`
|
Root string `json:"root"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
Snippet string `json:"snippet,omitempty"`
|
Snippet string `json:"snippet,omitempty"`
|
||||||
Hops []rank.HopNode `json:"hops,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toJSONOut(hits []Hit, query, rootFilter string, web *rank.SecondSource) *jsonOut {
|
func toJSONOut(hits []Hit, query, rootFilter string, web *rank.SecondSource) *jsonOut {
|
||||||
@@ -244,7 +199,6 @@ func toJSONOut(hits []Hit, query, rootFilter string, web *rank.SecondSource) *js
|
|||||||
Root: h.Root,
|
Root: h.Root,
|
||||||
Score: h.Score,
|
Score: h.Score,
|
||||||
Snippet: h.Snippet,
|
Snippet: h.Snippet,
|
||||||
Hops: h.Hops,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &jsonOut{
|
return &jsonOut{
|
||||||
@@ -268,18 +222,6 @@ func resultsToDicts(hits []Hit) []any {
|
|||||||
if h.Snippet != "" {
|
if h.Snippet != "" {
|
||||||
d = append(d, KV{"snippet", h.Snippet})
|
d = append(d, KV{"snippet", h.Snippet})
|
||||||
}
|
}
|
||||||
if len(h.Hops) > 0 {
|
|
||||||
nodes := make([]any, len(h.Hops))
|
|
||||||
for j, n := range h.Hops {
|
|
||||||
nodes[j] = Dict{
|
|
||||||
{"id", n.ID},
|
|
||||||
{"label", n.Label},
|
|
||||||
{"name", n.Name},
|
|
||||||
{"depth", n.Depth},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d = append(d, KV{"hops", nodes})
|
|
||||||
}
|
|
||||||
out[i] = d
|
out[i] = d
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ bin/brain/stats.go # index health
|
|||||||
bin/brain/eval.go # recall@5 >= 0.95 gate (Go; Python bin/kb/eval is CI fallback)
|
bin/brain/eval.go # recall@5 >= 0.95 gate (Go; Python bin/kb/eval is CI fallback)
|
||||||
```
|
```
|
||||||
|
|
||||||
`bin/kb/search` is a deprecated wrapper. `--hop N` walks
|
`bin/kb/search` is a deprecated wrapper. `--hop` errors (schema has
|
||||||
`FROM_FILE` / `HAS_VERSION` / `AUTHORED` from each hit (1=File, 3=Person).
|
`FROM_FILE`; search does not walk it yet, [#17](https://git.produktor.io/eSlider/2dph/issues/17));
|
||||||
|
do not treat it as a graph walk.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user