Add optional homelab AI knowledge index
This commit is contained in:
parent
8488c9523e
commit
172bcca975
12
README.md
12
README.md
|
|
@ -868,6 +868,18 @@ Disable it for low-CPU sessions with:
|
|||
LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge
|
||||
```
|
||||
|
||||
Build the local homelab knowledge index separately from the main deployment:
|
||||
|
||||
```bash
|
||||
./jeannie ai-index
|
||||
./jeannie ai-check
|
||||
```
|
||||
|
||||
The index is built from the non-secret source list in
|
||||
`infra/ai/knowledge-sources.txt` and defaults to `/data/homelab-ai/index`.
|
||||
Doctor commands use it as extra context when it exists, but infrastructure
|
||||
deployment does not depend on it.
|
||||
|
||||
The CV page has two client-side presentation modes:
|
||||
|
||||
- `Elegant`: dark, minimal, terminal-inspired styling with a square profile
|
||||
|
|
|
|||
|
|
@ -868,6 +868,18 @@ Disable it for low-CPU sessions with:
|
|||
LAB_BACKSTAGE_BRAIN_ENABLED=false ./{{ main_script }} doctor-edge
|
||||
```
|
||||
|
||||
Build the local homelab knowledge index separately from the main deployment:
|
||||
|
||||
```bash
|
||||
./{{ main_script }} ai-index
|
||||
./{{ main_script }} ai-check
|
||||
```
|
||||
|
||||
The index is built from the non-secret source list in
|
||||
`infra/ai/knowledge-sources.txt` and defaults to `/data/homelab-ai/index`.
|
||||
Doctor commands use it as extra context when it exists, but infrastructure
|
||||
deployment does not depend on it.
|
||||
|
||||
The CV page has two client-side presentation modes:
|
||||
|
||||
- `Elegant`: dark, minimal, terminal-inspired styling with a square profile
|
||||
|
|
|
|||
|
|
@ -124,3 +124,4 @@ ai_gateway:
|
|||
url: http://192.168.100.73:11434
|
||||
model: qwen2.5:0.5b
|
||||
timeout_seconds: 20
|
||||
knowledge_index_dir: /data/homelab-ai/index
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
# Homelab AI Knowledge Index
|
||||
|
||||
This directory defines the optional local knowledge layer for the backstage
|
||||
`jeannie` helper.
|
||||
|
||||
The index is not model training. It is retrieval over repo files, so current
|
||||
inventory, runbooks, scripts, Terraform, compose files, and app manifests remain
|
||||
normal source-controlled text.
|
||||
|
||||
## Build
|
||||
|
||||
Run on the Debian homelab server:
|
||||
|
||||
```bash
|
||||
./jeannie ai-index
|
||||
```
|
||||
|
||||
The default index path comes from `homelab.yml`:
|
||||
|
||||
```text
|
||||
/data/homelab-ai/index
|
||||
```
|
||||
|
||||
Override it with `LAB_AI_KNOWLEDGE_INDEX_DIR` when testing.
|
||||
|
||||
## Check
|
||||
|
||||
```bash
|
||||
./jeannie ai-check
|
||||
```
|
||||
|
||||
This validates:
|
||||
|
||||
- Python is available
|
||||
- the index exists
|
||||
- a simple retrieval query returns context
|
||||
- Ollama is reachable when the backstage helper is enabled
|
||||
- the configured model is pulled
|
||||
|
||||
## Query
|
||||
|
||||
The query script can be used directly:
|
||||
|
||||
```bash
|
||||
scripts/query-homelab-ai-index "gitea public url is failing"
|
||||
scripts/query-homelab-ai-index --ask "why is lab2025 returning 502?"
|
||||
```
|
||||
|
||||
`--ask` sends retrieved context to the configured Ollama endpoint and model.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Files and globs indexed for the local homelab AI helper.
|
||||
# Keep this non-secret. The index is built from repository content only.
|
||||
homelab.yml
|
||||
README.md
|
||||
jeannie
|
||||
docs/**/*.md
|
||||
infra/**/*.md
|
||||
infra/**/*.yml
|
||||
infra/**/*.yaml
|
||||
infra/**/*.json
|
||||
infra/**/*.txt
|
||||
infra/**/*.sh
|
||||
infra/**/*.py
|
||||
bootstrap/**/*.md
|
||||
bootstrap/**/*.tf
|
||||
bootstrap/**/*.tftpl
|
||||
bootstrap/**/*.yml
|
||||
bootstrap/**/*.yaml
|
||||
apps/**/*.md
|
||||
apps/**/*.yaml
|
||||
apps/**/*.yml
|
||||
apps/**/*.tf
|
||||
apps/**/*.php
|
||||
apps/**/*.sh
|
||||
scripts/*
|
||||
97
jeannie
97
jeannie
|
|
@ -65,6 +65,7 @@ mapping = {
|
|||
"ai_gateway.url": "LAB_AI_GATEWAY_URL",
|
||||
"ai_gateway.model": "LAB_AI_GATEWAY_MODEL",
|
||||
"ai_gateway.timeout_seconds": "LAB_AI_GATEWAY_TIMEOUT_SECONDS",
|
||||
"ai_gateway.knowledge_index_dir": "LAB_AI_KNOWLEDGE_INDEX_DIR",
|
||||
"pimox.worker_base_vmid": "LAB_PIMOX_WORKER_BASE_VMID",
|
||||
"pimox.default_worker_count": "LAB_PIMOX_WORKER_COUNT",
|
||||
}
|
||||
|
|
@ -3839,7 +3840,9 @@ backstage_brain_note() {
|
|||
local endpoint="${LAB_AI_GATEWAY_URL:-${LAB_OLLAMA_URL:-http://127.0.0.1:11434}}"
|
||||
local model="${LAB_AI_GATEWAY_MODEL:-qwen2.5:0.5b}"
|
||||
local timeout="${LAB_AI_GATEWAY_TIMEOUT_SECONDS:-20}"
|
||||
local index_dir="${LAB_AI_KNOWLEDGE_INDEX_DIR:-/data/homelab-ai/index}"
|
||||
local context
|
||||
local retrieved_context=""
|
||||
|
||||
if ! backstage_brain_enabled; then
|
||||
return 0
|
||||
|
|
@ -3849,6 +3852,15 @@ backstage_brain_note() {
|
|||
fi
|
||||
|
||||
context="$(cat)"
|
||||
if [[ -s "${index_dir}/index.json" ]]; then
|
||||
retrieved_context="$("${REPO_ROOT}/scripts/query-homelab-ai-index" --index-dir "${index_dir}" --context-only --limit 3 "${topic} ${context}" 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -n "${retrieved_context}" ]]; then
|
||||
context="${context}
|
||||
|
||||
Retrieved homelab knowledge:
|
||||
${retrieved_context}"
|
||||
fi
|
||||
BACKSTAGE_CONTEXT="${context}" python3 - "${endpoint}" "${model}" "${timeout}" "${topic}" <<'PY' || true
|
||||
import json
|
||||
import os
|
||||
|
|
@ -4614,6 +4626,83 @@ tailnet_policy_check() {
|
|||
"${REPO_ROOT}/scripts/validate-tailnet-policy"
|
||||
}
|
||||
|
||||
ai_index() {
|
||||
local index_dir="${LAB_AI_KNOWLEDGE_INDEX_DIR:-/data/homelab-ai/index}"
|
||||
|
||||
require_debian_server "ai-index"
|
||||
ensure_python3
|
||||
if [[ "${index_dir}" == /data/* ]]; then
|
||||
sudo mkdir -p "${index_dir}"
|
||||
sudo chown "${USER}:$(id -gn)" "${index_dir}"
|
||||
fi
|
||||
"${REPO_ROOT}/scripts/build-homelab-ai-index" --index-dir "${index_dir}"
|
||||
}
|
||||
|
||||
ai_check() {
|
||||
local index_dir="${LAB_AI_KNOWLEDGE_INDEX_DIR:-/data/homelab-ai/index}"
|
||||
local endpoint="${LAB_AI_GATEWAY_URL:-${LAB_OLLAMA_URL:-http://127.0.0.1:11434}}"
|
||||
local model="${LAB_AI_GATEWAY_MODEL:-qwen2.5:0.5b}"
|
||||
local failures=0
|
||||
|
||||
require_debian_server "ai-check"
|
||||
|
||||
status_section "AI Knowledge"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
printf '%-28s ok\n' "python3"
|
||||
else
|
||||
printf '%-28s missing\n' "python3"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ -s "${index_dir}/index.json" ]]; then
|
||||
printf '%-28s %s\n' "knowledge index" "${index_dir}/index.json"
|
||||
else
|
||||
printf '%-28s missing - run ./jeannie ai-index\n' "knowledge index"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if "${REPO_ROOT}/scripts/query-homelab-ai-index" --index-dir "${index_dir}" --context-only --limit 2 "gitea edge traefik" >/dev/null 2>&1; then
|
||||
printf '%-28s ok\n' "retrieval smoke test"
|
||||
else
|
||||
printf '%-28s failed\n' "retrieval smoke test"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if backstage_brain_enabled; then
|
||||
if curl -fsS --max-time 5 "${endpoint%/}/api/tags" >/dev/null 2>&1; then
|
||||
printf '%-28s ok - %s\n' "ollama endpoint" "${endpoint}"
|
||||
if python3 - "${endpoint}" "${model}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
endpoint, model = sys.argv[1:3]
|
||||
with urllib.request.urlopen(f"{endpoint.rstrip('/')}/api/tags", timeout=5) as response:
|
||||
tags = json.loads(response.read().decode("utf-8"))
|
||||
models = {item.get("name", "") for item in tags.get("models", [])}
|
||||
raise SystemExit(0 if model in models or f"{model}:latest" in models else 1)
|
||||
PY
|
||||
then
|
||||
printf '%-28s ok - %s\n' "ollama model" "${model}"
|
||||
else
|
||||
printf '%-28s missing - run: ollama pull %s\n' "ollama model" "${model}"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
else
|
||||
printf '%-28s unreachable - %s\n' "ollama endpoint" "${endpoint}"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
else
|
||||
printf '%-28s disabled\n' "backstage helper"
|
||||
fi
|
||||
|
||||
if ((failures > 0)); then
|
||||
echo "AI check failed with ${failures} issue(s)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "AI check passed."
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
up)
|
||||
up
|
||||
|
|
@ -4687,6 +4776,12 @@ case "${1:-}" in
|
|||
tailnet-policy-check)
|
||||
tailnet_policy_check
|
||||
;;
|
||||
ai-index)
|
||||
ai_index
|
||||
;;
|
||||
ai-check)
|
||||
ai_check
|
||||
;;
|
||||
openwrt)
|
||||
openwrt
|
||||
;;
|
||||
|
|
@ -4694,7 +4789,7 @@ case "${1:-}" in
|
|||
nuke
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {up|rebuild-cluster|stop-cluster|start-cluster|status|apps|website-translation-model|website-ollama-listen|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|secrets-init|secrets-check|tailnet-policy-check|openwrt|nuke}"
|
||||
echo "Usage: $0 {up|rebuild-cluster|stop-cluster|start-cluster|status|apps|website-translation-model|website-ollama-listen|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|secrets-init|secrets-check|tailnet-policy-check|ai-index|ai-check|openwrt|nuke}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build a small local retrieval index from non-secret homelab repo files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
DEFAULT_SOURCES_FILE = REPO_ROOT / "infra" / "ai" / "knowledge-sources.txt"
|
||||
DEFAULT_INDEX_DIR = pathlib.Path(os.environ.get("LAB_AI_KNOWLEDGE_INDEX_DIR", "/data/homelab-ai/index"))
|
||||
TOKEN_RE = re.compile(r"[A-Za-z0-9_./:-]{2,}")
|
||||
MAX_FILE_BYTES = 512 * 1024
|
||||
CHUNK_TARGET_LINES = 80
|
||||
CHUNK_OVERLAP_LINES = 12
|
||||
|
||||
|
||||
def tokenise(text: str) -> list[str]:
|
||||
return [token.lower() for token in TOKEN_RE.findall(text)]
|
||||
|
||||
|
||||
def load_patterns(path: pathlib.Path) -> list[str]:
|
||||
patterns: list[str] = []
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
patterns.append(line)
|
||||
return patterns
|
||||
|
||||
|
||||
def path_is_secret(path: pathlib.Path) -> bool:
|
||||
lowered = str(path).lower()
|
||||
secret_markers = (
|
||||
".git/",
|
||||
".terraform/",
|
||||
".lab/",
|
||||
"__pycache__/",
|
||||
"terraform.tfstate",
|
||||
".secret.",
|
||||
".enc.",
|
||||
"id_ed25519",
|
||||
"cosign.key",
|
||||
"keys.txt",
|
||||
)
|
||||
return any(marker in lowered for marker in secret_markers)
|
||||
|
||||
|
||||
def candidate_files(patterns: list[str]) -> list[pathlib.Path]:
|
||||
files: set[pathlib.Path] = set()
|
||||
all_files = [path for path in REPO_ROOT.rglob("*") if path.is_file()]
|
||||
|
||||
for pattern in patterns:
|
||||
if any(char in pattern for char in "*?["):
|
||||
for path in all_files:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
if fnmatch.fnmatch(rel, pattern):
|
||||
files.add(path)
|
||||
else:
|
||||
path = REPO_ROOT / pattern
|
||||
if path.is_file():
|
||||
files.add(path)
|
||||
|
||||
return sorted(path for path in files if not path_is_secret(path))
|
||||
|
||||
|
||||
def read_text(path: pathlib.Path) -> str | None:
|
||||
try:
|
||||
if path.stat().st_size > MAX_FILE_BYTES:
|
||||
return None
|
||||
return path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def chunk_file(path: pathlib.Path, text: str) -> list[dict[str, object]]:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
lines = text.splitlines()
|
||||
chunks: list[dict[str, object]] = []
|
||||
start = 0
|
||||
|
||||
while start < len(lines):
|
||||
end = min(start + CHUNK_TARGET_LINES, len(lines))
|
||||
body = "\n".join(lines[start:end]).strip()
|
||||
if body:
|
||||
chunks.append(
|
||||
{
|
||||
"id": f"{rel}:{start + 1}",
|
||||
"path": rel,
|
||||
"start_line": start + 1,
|
||||
"end_line": end,
|
||||
"text": body,
|
||||
"tokens": tokenise(body),
|
||||
}
|
||||
)
|
||||
if end == len(lines):
|
||||
break
|
||||
start = max(end - CHUNK_OVERLAP_LINES, start + 1)
|
||||
|
||||
if not chunks and text.strip():
|
||||
chunks.append(
|
||||
{
|
||||
"id": f"{rel}:1",
|
||||
"path": rel,
|
||||
"start_line": 1,
|
||||
"end_line": 1,
|
||||
"text": text.strip(),
|
||||
"tokens": tokenise(text),
|
||||
}
|
||||
)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def build_index(files: list[pathlib.Path]) -> dict[str, object]:
|
||||
chunks: list[dict[str, object]] = []
|
||||
document_frequency: Counter[str] = Counter()
|
||||
|
||||
for path in files:
|
||||
text = read_text(path)
|
||||
if text is None:
|
||||
continue
|
||||
for chunk in chunk_file(path, text):
|
||||
tokens = chunk["tokens"]
|
||||
if not isinstance(tokens, list) or not tokens:
|
||||
continue
|
||||
chunks.append(chunk)
|
||||
document_frequency.update(set(tokens))
|
||||
|
||||
chunk_count = len(chunks)
|
||||
average_length = sum(len(chunk["tokens"]) for chunk in chunks) / chunk_count if chunk_count else 0
|
||||
idf = {
|
||||
token: math.log(1 + (chunk_count - freq + 0.5) / (freq + 0.5))
|
||||
for token, freq in document_frequency.items()
|
||||
}
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"repo_root": str(REPO_ROOT),
|
||||
"chunk_count": chunk_count,
|
||||
"average_length": average_length,
|
||||
"chunks": chunks,
|
||||
"idf": idf,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--sources-file", type=pathlib.Path, default=DEFAULT_SOURCES_FILE)
|
||||
parser.add_argument("--index-dir", type=pathlib.Path, default=DEFAULT_INDEX_DIR)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.sources_file.is_file():
|
||||
print(f"missing sources file: {args.sources_file}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
patterns = load_patterns(args.sources_file)
|
||||
files = candidate_files(patterns)
|
||||
index = build_index(files)
|
||||
|
||||
args.index_dir.mkdir(parents=True, exist_ok=True)
|
||||
index_path = args.index_dir / "index.json"
|
||||
manifest_path = args.index_dir / "manifest.json"
|
||||
index_path.write_text(json.dumps(index, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sources_file": str(args.sources_file.relative_to(REPO_ROOT)),
|
||||
"file_count": len(files),
|
||||
"chunk_count": index["chunk_count"],
|
||||
"repo_root": str(REPO_ROOT),
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(f"indexed {len(files)} file(s), {index['chunk_count']} chunk(s) into {index_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Query the local homelab retrieval index and optionally ask Ollama."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
|
||||
|
||||
DEFAULT_INDEX_DIR = pathlib.Path(os.environ.get("LAB_AI_KNOWLEDGE_INDEX_DIR", "/data/homelab-ai/index"))
|
||||
TOKEN_RE = re.compile(r"[A-Za-z0-9_./:-]{2,}")
|
||||
|
||||
|
||||
def tokenise(text: str) -> list[str]:
|
||||
return [token.lower() for token in TOKEN_RE.findall(text)]
|
||||
|
||||
|
||||
def load_index(index_dir: pathlib.Path) -> dict[str, object]:
|
||||
index_path = index_dir / "index.json"
|
||||
if not index_path.is_file():
|
||||
raise FileNotFoundError(f"missing index: {index_path}")
|
||||
return json.loads(index_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def score_chunks(index: dict[str, object], query: str, limit: int) -> list[tuple[float, dict[str, object]]]:
|
||||
query_terms = tokenise(query)
|
||||
if not query_terms:
|
||||
return []
|
||||
|
||||
query_counts = Counter(query_terms)
|
||||
idf = index.get("idf", {})
|
||||
average_length = float(index.get("average_length") or 1.0)
|
||||
chunks = index.get("chunks", [])
|
||||
results: list[tuple[float, dict[str, object]]] = []
|
||||
k1 = 1.5
|
||||
b = 0.75
|
||||
|
||||
if not isinstance(idf, dict) or not isinstance(chunks, list):
|
||||
return []
|
||||
|
||||
for chunk in chunks:
|
||||
if not isinstance(chunk, dict):
|
||||
continue
|
||||
tokens = chunk.get("tokens", [])
|
||||
if not isinstance(tokens, list) or not tokens:
|
||||
continue
|
||||
term_counts = Counter(str(token) for token in tokens)
|
||||
doc_len = len(tokens)
|
||||
score = 0.0
|
||||
for term, query_count in query_counts.items():
|
||||
frequency = term_counts.get(term, 0)
|
||||
if frequency == 0:
|
||||
continue
|
||||
term_idf = float(idf.get(term, 0.0))
|
||||
denominator = frequency + k1 * (1 - b + b * doc_len / average_length)
|
||||
score += query_count * term_idf * (frequency * (k1 + 1)) / denominator
|
||||
if score > 0:
|
||||
results.append((score, chunk))
|
||||
|
||||
return sorted(results, key=lambda item: item[0], reverse=True)[:limit]
|
||||
|
||||
|
||||
def render_context(results: list[tuple[float, dict[str, object]]], max_chars: int) -> str:
|
||||
sections: list[str] = []
|
||||
remaining = max_chars
|
||||
|
||||
for score, chunk in results:
|
||||
header = f"[{chunk.get('path')}:{chunk.get('start_line')}] score={score:.3f}"
|
||||
text = str(chunk.get("text", "")).strip()
|
||||
section = f"{header}\n{text}"
|
||||
if len(section) > remaining:
|
||||
section = section[: max(0, remaining - 20)].rstrip() + "\n[truncated]"
|
||||
sections.append(section)
|
||||
remaining -= len(section)
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
return "\n\n---\n\n".join(sections)
|
||||
|
||||
|
||||
def ask_ollama(question: str, context: str, endpoint: str, model: str, timeout: int) -> str:
|
||||
prompt = f"""You are helping operate a personal homelab.
|
||||
Use only the context below. If the context is insufficient, say what to inspect next.
|
||||
Be concise, factual, and command-oriented.
|
||||
|
||||
Question:
|
||||
{question}
|
||||
|
||||
Context:
|
||||
{context}
|
||||
"""
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.1,
|
||||
"num_predict": 360,
|
||||
},
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
f"{endpoint.rstrip('/')}/api/generate",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
return str(result.get("response", "")).strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("query", nargs="+", help="Question or search text")
|
||||
parser.add_argument("--index-dir", type=pathlib.Path, default=DEFAULT_INDEX_DIR)
|
||||
parser.add_argument("--limit", type=int, default=5)
|
||||
parser.add_argument("--max-chars", type=int, default=9000)
|
||||
parser.add_argument("--context-only", action="store_true")
|
||||
parser.add_argument("--ask", action="store_true")
|
||||
parser.add_argument("--ollama-url", default=os.environ.get("LAB_AI_GATEWAY_URL", "http://127.0.0.1:11434"))
|
||||
parser.add_argument("--model", default=os.environ.get("LAB_AI_GATEWAY_MODEL", "qwen2.5:0.5b"))
|
||||
parser.add_argument("--timeout", type=int, default=int(os.environ.get("LAB_AI_GATEWAY_TIMEOUT_SECONDS", "20")))
|
||||
args = parser.parse_args()
|
||||
|
||||
query = " ".join(args.query)
|
||||
try:
|
||||
index = load_index(args.index_dir)
|
||||
except FileNotFoundError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
results = score_chunks(index, query, args.limit)
|
||||
if not results:
|
||||
print("no matching homelab knowledge chunks found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
context = render_context(results, args.max_chars)
|
||||
if args.context_only:
|
||||
print(context)
|
||||
return 0
|
||||
|
||||
if args.ask:
|
||||
try:
|
||||
answer = ask_ollama(query, context, args.ollama_url, args.model, args.timeout)
|
||||
except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
print(f"ollama request failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(answer)
|
||||
return 0
|
||||
|
||||
print(context)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in New Issue