diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00f2d38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.venv/ diff --git a/docs/index-suche.md b/docs/index-suche.md new file mode 100644 index 0000000..af1e362 --- /dev/null +++ b/docs/index-suche.md @@ -0,0 +1,89 @@ +# Index und Suche — SQLite mit FTS5 und sqlite-vec + +Entscheidung Andreas, 2026-09-04 (spine/core#8): kein Datenbankdienst. Der +Index ist eine **abgeleitete Sicht** auf die Markdown-Dateien der Ablage und +liegt als eine SQLite-Datei neben ihr. Er darf jederzeit gelöscht und neu +gebaut werden — die Wahrheit sind die Dateien. + +Code: `image/km/index.py` (bauen), `image/km/search.py` (suchen). +Abhängigkeiten: `requests`, `sqlite-vec` (`image/requirements.txt`). + +## Was drin ist + +| Tabelle | Inhalt | +|---|---| +| `docs` | eine Zeile je Datei: Pfad, Namespace, Projekt, Klassifikation, Typ (`vorgang`, `doku`, …), Zustand, Titel, Zeitstempel, SHA-256, ob eingebettet | +| `chunks` | Absatz-Chunks je Datei (max. 600 Zeichen, 60 Überlappung — dasselbe Chunking wie der Wissens-MCP); der Titel ist Chunk 0 | +| `chunks_fts` | FTS5 über die Chunks, Tokenizer `unicode61` **ohne** Diakritika-Entfernung (ä bleibt ä) | +| `chunks_vec` | sqlite-vec, `bge-m3` mit 1024 Dimensionen, rowid = Chunk-ID | +| `verweise` | Kanten aus dem Frontmatter (`verweise:`), beide Richtungen abfragbar | + +Die Filterspalten sind der Punkt, an dem die Zugriffsregel greift: Namespace, +Projekt, Klassifikation, Typ, Zustand sind Spalten in `docs`, und die Suche +filtert **vor** dem Ranking. Wer eine Klassifikation nicht sehen darf, bekommt +`0 Treffer` — nicht „vorhanden, aber gesperrt" (spine/core#18). + +## Bauen + +```bash +# Volltext (Sekunden) — nach jedem Ingest +image/km/index.py --ablage /srv/km/ablage + +# dazu Vektoren fuer neue und geaenderte Dateien +image/km/index.py --ablage /srv/km/ablage --embed + +# nur ausstehende Vektoren nachziehen, z. B. nachts +image/km/index.py --ablage /srv/km/ablage --embed-only [--embed-limit 200] +``` + +Inkrementell über den SHA-256 je Datei: unveränderte Dateien werden nicht +angefasst, geänderte komplett neu zerlegt (alte Chunks und Vektoren raus, +neue rein), verschwundene entfernt. Der Volltext ist sofort da; die Vektoren +folgen, sortiert nach `aktualisiert` absteigend — das Neueste zuerst. + +## Suchen + +```bash +search.py "worktree prune" # hybrid: Volltext + Vektor, RRF +search.py --mode fts "live/live#2181" # exakt +search.py --mode vec "warum braucht der bus keine persistenz mehr" +search.py --ns 42i --typ vorgang --zustand open "i18n" +search.py --classification lan --classification public "…" # Sichtkreis +search.py --json "…" +``` + +Volltext führend, Vektor ergänzend (spine/core#1). Hybrid fusioniert beide +Ranglisten per Reciprocal Rank Fusion; ausgegeben wird ein Treffer je Datei +mit dem besten Chunk als Anriss. Die FTS-Anfrage macht aus jedem Wort einen +Präfix (`worktree*`), Zeichenketten mit Sonderzeichen (`live/live#2181`) +werden wörtlich gesucht. + +## Gemessen am 2026-09-04 (Mac, Ablage mit 3424 Vorgängen aus elf Repos) + +| Schritt | Menge | Dauer | +|---|---|---| +| Volltext-Index, Erstbau | 2935 Dateien, 61.141 Chunks | 3,1 s | +| Volltext, inkrementell | 491 neue Dateien | 0,3 s | +| Vektoren | 1756 Chunks (30 Dateien) | 550 s → **0,31 s je Chunk** | +| Vektoren, hochgerechnet | 64.392 Chunks | **~5,5 h** | +| FTS-Suche | — | unter 50 ms | +| Vektorsuche | Embedding der Anfrage + Nachbarn | ~1 s, davon fast alles das Embedding | + +Die Vektorzeit ist der Rechner, nicht die Leitung: `/api/embed` mit 32 Texten +im Batch liefert 0,31 s je Text, einzeln 1,0 s — die Ollama-Instanz auf dem +s18-CT rechnet auf der CPU (`OLLAMA_VULKAN=0`, siehe `info: lan/infra/wissens-mcp.md`). +Die Erstbefüllung fällt einmal an; danach kostet ein geändertes Ticket +Sekunden. Wer es schneller braucht, hängt ein GPU-Embedding ans Gateway — +das ist eine Betriebsfrage, keine am Index. + +**Dateigröße:** 62 MB für den Volltext über 61.000 Chunks; mit Vektoren +(1024 × 4 Byte je Chunk) kommen rund 260 MB dazu. + +## Was fehlt + +- **Deutsche Wortformen.** `unicode61` stemmt nicht; „Sperrung" findet + „gesperrt" nur über die Vektorsuche. Falls das nicht reicht: Trigramm- + Tokenizer, keine neue Datenbank. +- **Kanten zu Doku-Seiten.** `verweise` kennt heute nur Vorgang→Vorgang. +- **Der Dienst.** Heute zwei Skripte; `read`/`search`/`summary` als HTTP-API + und MCP-Endpunkt kommen darüber (Reihenfolge in `image/README.md`). diff --git a/docs/ingest-gitea.md b/docs/ingest-gitea.md index 81e2015..6cd9699 100644 --- a/docs/ingest-gitea.md +++ b/docs/ingest-gitea.md @@ -67,11 +67,15 @@ KM_GITEA_URL=https://git.home KM_NAMESPACE=familie KM_CLASSIFICATION=home \ |---|---|---|---| | spine/core | 20 | 6 s | Erstbefüllung | | spine/core | 0 gesehen | 0,3 s | inkrementell, nichts geändert | -| 42i/intern | 1165 | ~3 min | Erstbefüllung, ein API-Aufruf je Vorgang für die Kommentare | +| 42i/intern | 1165 | ~3 min | Erstbefüllung, noch ein API-Aufruf je Vorgang für die Kommentare | +| live/live | 2175 | ~2 min | Erstbefüllung mit repo-weitem Kommentar-Endpunkt (ein Aufruf je 50 Kommentare) | +| elf Repos (live, intern, spine/*, hive/*) | 2241 | 94 s | ein Lauf | -Die Erstbefüllung kostet einen Aufruf je Vorgang mit Kommentaren; das ist -die Last auf der Forge, von der #13 spricht. Sie fällt einmal an. Danach -kostet ein Lauf einen Aufruf je Repo plus einen je geändertem Vorgang. +Die erste Fassung holte die Kommentare je Vorgang und blieb bei live/live +nach 371 Vorgängen an einer offenen Verbindung hängen. Seitdem gilt: bei der +Erstbefüllung kommen die Kommentare **repo-weit** (`/issues/comments`, älteste +zuerst), inkrementell je geändertem Vorgang. Dazu ein Verbindungs-Timeout und +Fortschritt je 100 Vorgänge auf stderr. ## Was noch fehlt (Reihenfolge) @@ -79,10 +83,8 @@ kostet ein Lauf einen Aufruf je Repo plus einen je geändertem Vorgang. `wake.km` geweckt werden (spine/core#12: Gitea-Webhook → HTTP→Bus-Adapter → Bus → km) und dann den inkrementellen Lauf machen. Der periodische Abgleich bleibt daneben Pflicht. -2. **Suche über die Ablage.** Volltext führend, Vektor ergänzend (spine/core#1). - Der bestehende Indexer `info/ai/scripts/index_docs.py` chunkt Markdown mit - Frontmatter — die Vorgangsdateien sind dafür gebaut. Welche Datenbank(en) - dahinter stehen, ist noch nicht entschieden (Stand 04.09.). +2. ~~Suche über die Ablage.~~ Gebaut: SQLite mit FTS5 und sqlite-vec, siehe + [index-suche.md](index-suche.md). 3. **Verknüpfung mit Doku und Memory.** Die `verweise:` sind erst Kanten zwischen Vorgängen. Kanten zu Doku-Seiten (ein Ticket, das eine Seite betrifft) und die Konsistenzprüfung gegen abgelegte Festlegungen kommen diff --git a/image/README.md b/image/README.md index de94789..afa9dac 100644 --- a/image/README.md +++ b/image/README.md @@ -3,9 +3,10 @@ Hier entsteht `git.42i.org/spine/km`: die API, die Ablage, der MCP-Endpunkt und die prüfende Schreibfunktion. -**Erster Baustein vorhanden:** `km/ingest_gitea.py` holt Tickets und Kommentare -aus Gitea in die Ablage (spine/core#13, Doku in -[../docs/ingest-gitea.md](../docs/ingest-gitea.md)). Der Rest steht in +**Vorhanden:** `km/ingest_gitea.py` holt Tickets und Kommentare aus Gitea in +die Ablage ([../docs/ingest-gitea.md](../docs/ingest-gitea.md)); `km/index.py` +und `km/search.py` bauen den SQLite-Index (FTS5 + sqlite-vec) und suchen +darüber ([../docs/index-suche.md](../docs/index-suche.md)). Der Rest steht in [../docs/konzept.md](../docs/konzept.md). Die Reihenfolge, in der es sinnvoll wächst: diff --git a/image/km/__pycache__/ingest_gitea.cpython-314.pyc b/image/km/__pycache__/ingest_gitea.cpython-314.pyc deleted file mode 100644 index 978df1a..0000000 Binary files a/image/km/__pycache__/ingest_gitea.cpython-314.pyc and /dev/null differ diff --git a/image/km/index.py b/image/km/index.py new file mode 100755 index 0000000..06c8224 --- /dev/null +++ b/image/km/index.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""Index ueber die km-Ablage: SQLite mit FTS5 (Volltext) und sqlite-vec (Vektor). + +Der Index ist eine abgeleitete Sicht auf die Markdown-Dateien der Ablage und +darf jederzeit geloescht und neu gebaut werden -- die Wahrheit liegt in den +Dateien (spine/core#8, Entscheidung 2026-09-04). Eine Datei im Volume, kein +Dienst. + +Inkrementell ueber den SHA-256 je Datei: unveraenderte Dateien werden nicht +angefasst, geaenderte komplett neu zerlegt und eingebettet, verschwundene +entfernt. Embeddings kommen aus Ollama (`bge-m3`, 1024 Dimensionen) wie beim +Wissens-MCP; ohne `--embed` wird nur der Volltext gebaut, was in Sekunden +geht -- die Vektoren lassen sich spaeter nachziehen (`--embed --embed-only`). + +Chunking (Absaetze, max_chars, Ueberlappung) ist aus +`info/ai/scripts/index_docs.py` uebernommen, nicht neu erfunden. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import re +import sqlite3 +import sys +import time +from pathlib import Path + +import requests +import sqlite_vec + +EMBED_DIM = 1024 +SCHEMA = """ +CREATE TABLE IF NOT EXISTS docs ( + path TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + projekt TEXT, + classification TEXT NOT NULL, + typ TEXT NOT NULL, + zustand TEXT, + titel TEXT NOT NULL, + aktualisiert TEXT, + sha256 TEXT NOT NULL, + indexiert TEXT NOT NULL, + eingebettet INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS chunks ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL REFERENCES docs(path) ON DELETE CASCADE, + idx INTEGER NOT NULL, + text TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS chunks_path ON chunks(path); +CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( + text, titel UNINDEXED, path UNINDEXED, + content='chunks', content_rowid='id', tokenize='unicode61 remove_diacritics 0' +); +CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(rowid, text) VALUES (new.id, new.text); +END; +CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN + INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES ('delete', old.id, old.text); +END; +CREATE TABLE IF NOT EXISTS verweise ( + von TEXT NOT NULL, nach TEXT NOT NULL, PRIMARY KEY (von, nach) +); +CREATE INDEX IF NOT EXISTS verweise_nach ON verweise(nach); +""" +SCHEMA_VEC = f"CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[{EMBED_DIM}]);" + + +def connect(db: Path) -> sqlite3.Connection: + con = sqlite3.connect(db) + con.enable_load_extension(True) + sqlite_vec.load(con) + con.enable_load_extension(False) + con.execute("PRAGMA journal_mode=WAL") + con.execute("PRAGMA foreign_keys=ON") + con.executescript(SCHEMA) + con.execute(SCHEMA_VEC) + return con + + +# ---------------------------------------------------------------- Dateien + +def parse_frontmatter(text: str) -> tuple[dict[str, str], str]: + if not text.startswith("---"): + return {}, text + lines = text.splitlines() + meta: dict[str, str] = {} + for end in range(1, len(lines)): + if lines[end].strip() == "---": + for line in lines[1:end]: + if ":" not in line or line.startswith((" ", "-", "#")): + continue + k, v = line.split(":", 1) + meta[k.strip()] = v.strip().strip("\"'") + return meta, "\n".join(lines[end + 1:]).lstrip("\n") + return {}, text + + +def split_list(value: str) -> list[str]: + value = (value or "").strip().strip("[]") + return [p.strip().strip("\"'") for p in re.split(r"[,;]", value) if p.strip()] + + +def derive_title(body: str, fallback: str) -> str: + m = re.search(r"^#\s+(.+)$", body, re.MULTILINE) + return m.group(1).strip() if m else fallback + + +def doc_fields(rel: str, meta: dict[str, str], body: str, default_ns: str) -> dict: + parts = rel.split("/") + namespace = meta.get("namespace") or (parts[0] if len(parts) > 1 else default_ns) + typ = meta.get("typ") or meta.get("type") or ("vorgang" if meta.get("quelle") == "gitea" else "doku") + projekt = meta.get("projekt") or meta.get("project") + if not projekt and typ == "vorgang" and meta.get("repo"): + projekt = meta["repo"].split("/")[0] + return { + "namespace": namespace, + "projekt": projekt or None, + "classification": meta.get("classification") or "lan", + "typ": typ, + "zustand": meta.get("zustand") or meta.get("state"), + "titel": meta.get("titel") or meta.get("title") or derive_title(body, rel), + "aktualisiert": meta.get("aktualisiert") or meta.get("updated"), + "verweise": split_list(meta.get("verweise", "")), + } + + +# ---------------------------------------------------------------- Chunking (aus index_docs.py) + +def split_into_paragraphs(text: str) -> list[str]: + return [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] + + +def split_long_paragraphs(paragraphs: list[str], max_chars: int, overlap: int) -> list[str]: + out: list[str] = [] + step = max(1, max_chars - max(0, overlap)) + for p in paragraphs: + if len(p) <= max_chars: + out.append(p) + continue + start = 0 + while start < len(p): + end = min(len(p), start + max_chars) + out.append(p[start:end]) + if end == len(p): + break + start += step + return out + + +def chunk_paragraphs(paragraphs: list[str], max_chars: int, overlap: int) -> list[str]: + chunks: list[str] = [] + cur: list[str] = [] + cur_len = 0 + for p in paragraphs: + if cur and cur_len + len(p) + 2 > max_chars: + chunks.append("\n\n".join(cur)) + if overlap > 0: + keep: list[str] = [] + keep_len = 0 + for q in reversed(cur): + keep.insert(0, q) + keep_len += len(q) + 2 + if keep_len >= overlap: + break + cur, cur_len = keep, sum(len(q) + 2 for q in keep) + else: + cur, cur_len = [], 0 + cur.append(p) + cur_len += len(p) + 2 + if cur: + chunks.append("\n\n".join(cur)) + return chunks + + +def chunk_body(body: str, max_chars: int, overlap: int) -> list[str]: + paras = split_long_paragraphs(split_into_paragraphs(body), max_chars, overlap) + return chunk_paragraphs(paras, max_chars, overlap) + + +# ---------------------------------------------------------------- Embeddings + +class Embedder: + def __init__(self, url: str, model: str) -> None: + self.url = url.rstrip("/") + self.model = model + self.session = requests.Session() + + def embed(self, text: str) -> list[float]: + return self.embed_many([text])[0] + + def embed_many(self, texts: list[str]) -> list[list[float]]: + """Batch ueber /api/embed. Gemessen am 04.09. gegen bge-m3 (CPU): + 0,31 s je Text bei 32 im Batch, 1,0 s einzeln -- der Rechner ist + gebunden, nicht die Leitung; Batch spart Roundtrips, nicht Rechenzeit.""" + last: Exception | None = None + for attempt in range(3): + try: + r = self.session.post(f"{self.url}/api/embed", + json={"model": self.model, "input": [t[:600] for t in texts], + "keep_alive": "30m"}, + timeout=(10, 600)) + r.raise_for_status() + vs = r.json().get("embeddings") or [] + if len(vs) != len(texts) or any(len(v) != EMBED_DIM for v in vs): + raise RuntimeError(f"embeddings fehlen oder falsche Dimension ({len(vs)}/{len(texts)})") + return vs + except Exception as e: # noqa: BLE001 + last = e + time.sleep(2 ** attempt) + raise RuntimeError(f"embedding fehlgeschlagen: {last}") + + +# ---------------------------------------------------------------- Lauf + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def index_file(con: sqlite3.Connection, ablage: Path, f: Path, default_ns: str, + max_chars: int, overlap: int) -> bool: + rel = f.relative_to(ablage).as_posix() + raw = f.read_bytes() + digest = sha256(raw) + row = con.execute("SELECT sha256 FROM docs WHERE path=?", (rel,)).fetchone() + if row and row[0] == digest: + return False + meta, body = parse_frontmatter(raw.decode("utf-8", errors="replace")) + fields = doc_fields(rel, meta, body, default_ns) + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + with con: + old_ids = [r[0] for r in con.execute("SELECT id FROM chunks WHERE path=?", (rel,))] + if old_ids: + con.executemany("DELETE FROM chunks_vec WHERE rowid=?", [(i,) for i in old_ids]) + con.execute("DELETE FROM chunks WHERE path=?", (rel,)) + con.execute("DELETE FROM verweise WHERE von=?", (rel,)) + con.execute( + "INSERT OR REPLACE INTO docs(path,namespace,projekt,classification,typ,zustand,titel,aktualisiert,sha256,indexiert,eingebettet)" + " VALUES (?,?,?,?,?,?,?,?,?,?,0)", + (rel, fields["namespace"], fields["projekt"], fields["classification"], fields["typ"], + fields["zustand"], fields["titel"], fields["aktualisiert"], digest, now)) + # Titel als eigener erster Chunk, damit er im Volltext gefunden wird + chunks = [fields["titel"]] + chunk_body(body, max_chars, overlap) + con.executemany("INSERT INTO chunks(path,idx,text) VALUES (?,?,?)", + [(rel, i, c) for i, c in enumerate(chunks)]) + con.executemany("INSERT OR IGNORE INTO verweise(von,nach) VALUES (?,?)", + [(rel, v) for v in fields["verweise"]]) + return True + + +def remove_missing(con: sqlite3.Connection, ablage: Path) -> int: + gone = [r[0] for r in con.execute("SELECT path FROM docs") if not (ablage / r[0]).exists()] + with con: + for rel in gone: + ids = [r[0] for r in con.execute("SELECT id FROM chunks WHERE path=?", (rel,))] + con.executemany("DELETE FROM chunks_vec WHERE rowid=?", [(i,) for i in ids]) + con.execute("DELETE FROM docs WHERE path=?", (rel,)) + return len(gone) + + +def embed_pending(con: sqlite3.Connection, emb: Embedder, limit: int | None) -> int: + paths = [r[0] for r in con.execute("SELECT path FROM docs WHERE eingebettet=0 ORDER BY aktualisiert DESC")] + if limit: + paths = paths[:limit] + done = 0 + t0 = time.time() + for n, rel in enumerate(paths, 1): + rows = con.execute("SELECT id, text FROM chunks WHERE path=? ORDER BY idx", (rel,)).fetchall() + vecs = [] + for i in range(0, len(rows), 32): + batch = rows[i:i + 32] + vecs += [(cid, sqlite_vec.serialize_float32(v)) + for (cid, _), v in zip(batch, emb.embed_many([t for _, t in batch]))] + with con: + con.executemany("DELETE FROM chunks_vec WHERE rowid=?", [(cid,) for cid, _ in vecs]) + con.executemany("INSERT INTO chunks_vec(rowid, embedding) VALUES (?,?)", vecs) + con.execute("UPDATE docs SET eingebettet=1 WHERE path=?", (rel,)) + done += len(vecs) + if n % 25 == 0: + print(f" eingebettet: {n}/{len(paths)} dateien, {done} chunks, {time.time() - t0:.0f}s", + file=sys.stderr, flush=True) + return done + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--ablage", required=True, type=Path) + ap.add_argument("--db", type=Path, help="Default: /../km-index.sqlite") + ap.add_argument("--namespace", default=os.environ.get("KM_NAMESPACE", "42i"), + help="Namespace fuer Dateien ohne Angabe im Frontmatter") + ap.add_argument("--max-chars", type=int, default=600) + ap.add_argument("--overlap-chars", type=int, default=60) + ap.add_argument("--embed", action="store_true", help="Vektoren fuer neue/geaenderte Dateien erzeugen") + ap.add_argument("--embed-only", action="store_true", help="nur ausstehende Vektoren nachziehen") + ap.add_argument("--embed-limit", type=int, help="hoechstens so viele Dateien einbetten") + ap.add_argument("--ollama-url", default=os.environ.get("KM_OLLAMA_URL", "http://10.18.5.30:11434")) + ap.add_argument("--embed-model", default=os.environ.get("KM_EMBED_MODEL", "bge-m3")) + a = ap.parse_args() + + db = a.db or (a.ablage.parent / "km-index.sqlite") + con = connect(db) + t0 = time.time() + if not a.embed_only: + files = sorted(p for p in a.ablage.rglob("*.md") if not any(s.startswith(".") for s in p.parts)) + changed = sum(index_file(con, a.ablage, f, a.namespace, a.max_chars, a.overlap_chars) for f in files) + gone = remove_missing(con, a.ablage) + n_chunks = con.execute("SELECT count(*) FROM chunks").fetchone()[0] + print(f"volltext: {len(files)} dateien, {changed} neu/geaendert, {gone} entfernt, " + f"{n_chunks} chunks, {time.time() - t0:.1f}s", file=sys.stderr) + if a.embed or a.embed_only: + t1 = time.time() + n = embed_pending(con, Embedder(a.ollama_url, a.embed_model), a.embed_limit) + pending = con.execute("SELECT count(*) FROM docs WHERE eingebettet=0").fetchone()[0] + print(f"vektoren: {n} chunks eingebettet, {pending} dateien ausstehend, {time.time() - t1:.0f}s", + file=sys.stderr) + con.close() + + +if __name__ == "__main__": + main() diff --git a/image/km/ingest_gitea.py b/image/km/ingest_gitea.py index f53fc13..f9a6e28 100755 --- a/image/km/ingest_gitea.py +++ b/image/km/ingest_gitea.py @@ -80,7 +80,7 @@ class Forge: def get(self, path: str, **params) -> object: for attempt in range(4): - r = self.session.get(f"{self.url}/api/v1{path}", params=params, timeout=60) + r = self.session.get(f"{self.url}/api/v1{path}", params=params, timeout=(10, 60)) if r.status_code in (429, 502, 503, 504) and attempt < 3: time.sleep(2 ** attempt) continue @@ -118,6 +118,22 @@ class Forge: def comments(self, repo: str, number: int) -> list[dict]: return list(self.paged(f"/repos/{repo}/issues/{number}/comments")) + def all_comments(self, repo: str) -> dict[int, list[dict]]: + """Alle Kommentare eines Repos in einem Rutsch, nach Vorgangsnummer. + + Fuer die Erstbefuellung: ein Aufruf je 50 Kommentare statt einer je + Vorgang (live/live: ~300 statt ~2200 Aufrufe). Der Endpunkt liefert + aelteste zuerst, also bleibt die Reihenfolge je Vorgang erhalten. + """ + by_issue: dict[int, list[dict]] = {} + for c in self.paged(f"/repos/{repo}/issues/comments"): + url = c.get("issue_url") or "" + m = re.search(r"/(?:issues|pulls)/(\d+)$", url) + if not m: + continue + by_issue.setdefault(int(m.group(1)), []).append(c) + return by_issue + # ---------------------------------------------------------------- Markdown @@ -230,15 +246,27 @@ def ingest_repo(forge: Forge, repo: str, ablage: Path, state: dict, full: bool) target = ablage / forge.namespace / "vorgaenge" / repo target.mkdir(parents=True, exist_ok=True) seen = written = 0 + # Erstbefuellung: Kommentare repo-weit in einem Rutsch. Inkrementell sind + # es wenige geaenderte Vorgaenge, da ist ein Aufruf je Vorgang billiger. + bulk = forge.all_comments(repo) if since is None else None + t_rep = time.time() for kind in ("issues", "pulls"): for issue in forge.issues(repo, kind, since): seen += 1 - comments = forge.comments(repo, issue["number"]) if issue.get("comments") else [] + n = issue["number"] + if not issue.get("comments"): + comments = [] + elif bulk is not None: + comments = bulk.get(n, []) + else: + comments = forge.comments(repo, n) text = render(forge, repo, issue, comments) - f = target / f"{issue['number']}.md" + f = target / f"{n}.md" if not f.exists() or f.read_text(encoding="utf-8") != text: f.write_text(text, encoding="utf-8") written += 1 + if seen % 100 == 0: + print(f" {repo}: {seen} vorgaenge, {time.time() - t_rep:.0f}s", file=sys.stderr, flush=True) # Stand erst nach vollstaendigem Durchlauf setzen -- bricht der Lauf ab, # holt der naechste dieselben Vorgaenge noch einmal (Wiederholen ist billig, # Luecken sind es nicht). diff --git a/image/km/search.py b/image/km/search.py new file mode 100755 index 0000000..f71d645 --- /dev/null +++ b/image/km/search.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Suche ueber den km-Index: Volltext fuehrend, Vektor ergaenzend (spine/core#1). + + search.py "worktree prune" # hybrid + search.py --mode fts "live/live#2181" # nur Volltext (exakte Treffer) + search.py --mode vec "warum bus ohne jetstream" + search.py --ns 42i --typ vorgang --zustand open "i18n" + +Filter greifen VOR der Suche als WHERE-Klausel -- wer eine Klassifikation +nicht sehen darf, bekommt 0 Treffer, nicht "vorhanden, aber gesperrt" +(spine/core#18). Hybrid = Reciprocal Rank Fusion ueber beide Ranglisten. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sqlite3 +import sys +from pathlib import Path + +import sqlite_vec + +from index import Embedder, connect # noqa: E402 (gleicher Ordner) + + +def fts_query(q: str) -> str: + """Nutzertext -> FTS5-Ausdruck: jedes Wort als Praefix, Sonderzeichen in Anfuehrungszeichen.""" + terms = [] + for tok in re.findall(r"\S+", q): + tok = tok.replace('"', '""') + terms.append(f'"{tok}"' if not tok.isalnum() else f'"{tok}"*') + return " ".join(terms) + + +def where(a, params: list) -> str: + conds = [] + if a.ns: + conds.append("d.namespace = ?"); params.append(a.ns) + if a.projekt: + conds.append("d.projekt = ?"); params.append(a.projekt) + if a.typ: + conds.append("d.typ = ?"); params.append(a.typ) + if a.zustand: + conds.append("d.zustand = ?"); params.append(a.zustand) + if a.classification: + ph = ",".join("?" * len(a.classification)) + conds.append(f"d.classification IN ({ph})"); params.extend(a.classification) + return (" AND " + " AND ".join(conds)) if conds else "" + + +def search_fts(con: sqlite3.Connection, a, k: int) -> list[tuple[int, float]]: + params: list = [fts_query(a.query)] + w = where(a, params) + params.append(k) + rows = con.execute(f""" + SELECT c.id, bm25(chunks_fts) AS score + FROM chunks_fts JOIN chunks c ON c.id = chunks_fts.rowid JOIN docs d ON d.path = c.path + WHERE chunks_fts MATCH ? {w} + ORDER BY score LIMIT ?""", params).fetchall() + return [(r[0], r[1]) for r in rows] + + +def search_vec(con: sqlite3.Connection, a, k: int, emb: Embedder) -> list[tuple[int, float]]: + q = sqlite_vec.serialize_float32(emb.embed(a.query)) + # vec0 kann nicht joinen -- erst k*4 Nachbarn holen, dann filtern + rows = con.execute("SELECT rowid, distance FROM chunks_vec WHERE embedding MATCH ? ORDER BY distance LIMIT ?", + (q, k * 4)).fetchall() + if not rows: + return [] + params: list = [r[0] for r in rows] + w = where(a, params) + ph = ",".join("?" * len(rows)) + ok = {r[0] for r in con.execute(f"SELECT c.id FROM chunks c JOIN docs d ON d.path=c.path WHERE c.id IN ({ph}) {w}", params)} + return [(cid, dist) for cid, dist in rows if cid in ok][:k] + + +def rrf(*lists: list[tuple[int, float]], k: int = 60) -> dict[int, float]: + scores: dict[int, float] = {} + for lst in lists: + for rank, (cid, _) in enumerate(lst, 1): + scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank) + return scores + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("query") + ap.add_argument("--db", type=Path, default=Path(os.environ.get("KM_INDEX", "km-index.sqlite"))) + ap.add_argument("--mode", choices=["hybrid", "fts", "vec"], default="hybrid") + ap.add_argument("--limit", type=int, default=8) + ap.add_argument("--ns"); ap.add_argument("--projekt"); ap.add_argument("--typ"); ap.add_argument("--zustand") + ap.add_argument("--classification", action="append", help="erlaubte Klassifikationen (Sichtkreis), mehrfach") + ap.add_argument("--ollama-url", default=os.environ.get("KM_OLLAMA_URL", "http://10.18.5.30:11434")) + ap.add_argument("--embed-model", default=os.environ.get("KM_EMBED_MODEL", "bge-m3")) + ap.add_argument("--json", action="store_true") + a = ap.parse_args() + + con = connect(a.db) + k = a.limit * 3 + fts = search_fts(con, a, k) if a.mode in ("hybrid", "fts") else [] + vec = search_vec(con, a, k, Embedder(a.ollama_url, a.embed_model)) if a.mode in ("hybrid", "vec") else [] + if a.mode == "fts": + ranked = [(cid, -s) for cid, s in fts] + elif a.mode == "vec": + ranked = [(cid, -d) for cid, d in vec] + else: + ranked = sorted(rrf(fts, vec).items(), key=lambda x: -x[1]) + + # ein Treffer je Dokument, bester Chunk zaehlt + out, seen = [], set() + for cid, score in ranked: + row = con.execute("SELECT c.path, c.idx, c.text, d.titel, d.typ, d.zustand, d.aktualisiert " + "FROM chunks c JOIN docs d ON d.path=c.path WHERE c.id=?", (cid,)).fetchone() + if not row or row[0] in seen: + continue + seen.add(row[0]) + snippet = re.sub(r"\s+", " ", row[2])[:240] + out.append({"path": row[0], "titel": row[3], "typ": row[4], "zustand": row[5], + "aktualisiert": row[6], "score": round(score, 4), "chunk": row[1], "snippet": snippet}) + if len(out) >= a.limit: + break + + if a.json: + print(json.dumps(out, ensure_ascii=False, indent=1)) + else: + for r in out: + print(f"{r['score']:>8} {r['path']} [{r['typ']}/{r['zustand'] or '-'}] {r['titel']}\n" + f" {r['snippet']}") + if not out: + print("0 Treffer", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/image/requirements.txt b/image/requirements.txt index 535409c..1250592 100644 --- a/image/requirements.txt +++ b/image/requirements.txt @@ -1 +1,2 @@ requests>=2.31 +sqlite-vec>=0.1.6