Add Jeannie memory provenance checks

This commit is contained in:
juvdiaz 2026-06-30 13:19:49 -06:00
parent edb36592a7
commit 0af1257d86
10 changed files with 169 additions and 3 deletions

View File

@ -232,6 +232,18 @@ control-plane policy:
The simulator lives under `infra/ai-scheduler` and is a stepping stone toward a The simulator lives under `infra/ai-scheduler` and is a stepping stone toward a
real Kubernetes controller. real Kubernetes controller.
## Memory With Provenance
Jeannie can return cited retrieval context and check whether the local RAG index
is stale:
```bash
./jeannie ask --citations "how do I recover the cluster?"
./jeannie ai-memory check
```
This keeps local AI answers grounded in repo files and runbooks.
## Deploying ## Deploying
From the Debian server: From the Debian server:

View File

@ -232,6 +232,18 @@ control-plane policy:
The simulator lives under `infra/ai-scheduler` and is a stepping stone toward a The simulator lives under `infra/ai-scheduler` and is a stepping stone toward a
real Kubernetes controller. real Kubernetes controller.
## Memory With Provenance
Jeannie can return cited retrieval context and check whether the local RAG index
is stale:
```bash
./{{ main_script }} ask --citations "how do I recover the cluster?"
./{{ main_script }} ai-memory check
```
This keeps local AI answers grounded in repo files and runbooks.
## Deploying ## Deploying
From the Debian server: From the Debian server:

View File

@ -321,6 +321,10 @@ when the backstage helper is enabled.
: Search the local homelab knowledge index for relevant docs, runbooks, scripts, : Search the local homelab knowledge index for relevant docs, runbooks, scripts,
and commands. Set `LAB_AI_ASK_LLM=true` to ask Ollama after retrieval. and commands. Set `LAB_AI_ASK_LLM=true` to ask Ollama after retrieval.
`ask --citations QUESTION...`
: Return cited retrieval context with source paths and line ranges, without
calling a model.
`ai-evals {list|run|show}` `ai-evals {list|run|show}`
: Run deterministic Jeannie explanation and RAG grounding eval cases from : Run deterministic Jeannie explanation and RAG grounding eval cases from
`infra/ai-evals`. The harness is read-only and uses fixtures plus allowlisted `infra/ai-evals`. The harness is read-only and uses fixtures plus allowlisted
@ -330,6 +334,10 @@ commands so it can run before CI or homelab changes.
: Simulate AI workload placement against repo-managed node and workload policy, : Simulate AI workload placement against repo-managed node and workload policy,
including CPU, memory, architecture, tailnet access, and control-plane penalty. including CPU, memory, architecture, tailnet access, and control-plane penalty.
`ai-memory {check}`
: Check whether the local homelab RAG index manifest is present and whether
indexed source files changed after indexing.
### Defensive Security ### Defensive Security
`security-scan` `security-scan`

15
infra/ai/provenance.md Normal file
View File

@ -0,0 +1,15 @@
# Jeannie Memory With Provenance
The homelab RAG path must stay grounded:
- every answer should cite repo files, runbooks, metrics, or command outputs
- stale index data should be detected before relying on retrieved context
- missing context should produce an inspection command, not a guess
Commands:
```bash
./jeannie ask --citations "how do I recover the cluster?"
./jeannie ai-memory check
```

10
jeannie
View File

@ -6342,6 +6342,10 @@ ai_scheduler() {
"${REPO_ROOT}/scripts/ai-scheduler" "${@:2}" "${REPO_ROOT}/scripts/ai-scheduler" "${@:2}"
} }
ai_memory() {
"${REPO_ROOT}/scripts/ai-memory" "${@:2}"
}
validate_homelab() { validate_homelab() {
"${REPO_ROOT}/scripts/validate-homelab" "${REPO_ROOT}/scripts/validate-homelab"
} }
@ -6606,9 +6610,10 @@ Security Learning
AI And Indexing AI And Indexing
ai-index Build the local homelab RAG index. ai-index Build the local homelab RAG index.
ai-check Query/check the local AI index. ai-check Query/check the local AI index.
ask QUESTION... Find relevant docs/runbooks/commands. ask [--citations] QUESTION... Find relevant docs/runbooks/commands.
ai-evals {list|run|show} Run deterministic Jeannie/RAG eval cases. ai-evals {list|run|show} Run deterministic Jeannie/RAG eval cases.
ai-scheduler {simulate|recommend NAME} Simulate AI workload placement policy. ai-scheduler {simulate|recommend NAME} Simulate AI workload placement policy.
ai-memory {check} Check local RAG index provenance freshness.
Destructive Destructive
nuke Guarded cluster state destruction path. nuke Guarded cluster state destruction path.
@ -6837,6 +6842,9 @@ case "${1:-}" in
ai-scheduler) ai-scheduler)
ai_scheduler "$@" ai_scheduler "$@"
;; ;;
ai-memory)
ai_memory "$@"
;;
impact) impact)
impact "$@" impact "$@"
;; ;;

66
scripts/ai-memory Executable file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Check local homelab AI memory provenance and freshness."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INDEX_DIR = Path(os.environ.get("LAB_AI_KNOWLEDGE_INDEX_DIR", "/data/homelab-ai/index"))
def check(index_dir: Path) -> int:
manifest_path = index_dir / "manifest.json"
if not manifest_path.is_file():
print(f"fail index manifest missing: {manifest_path}")
print("fix: ./jeannie ai-index")
return 1
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
stale: list[str] = []
missing: list[str] = []
for rel_path, indexed_mtime in manifest.get("files", {}).items():
path = REPO_ROOT / rel_path
if not path.is_file():
missing.append(rel_path)
continue
if int(path.stat().st_mtime) > int(indexed_mtime):
stale.append(rel_path)
print("Jeannie Memory")
print("==============")
print(f"index: {index_dir}")
print(f"chunks: {manifest.get('chunk_count', 'unknown')}")
print(f"sources: {manifest.get('file_count', 'unknown')}")
if stale:
print(f"status: stale ({len(stale)} source file(s) changed)")
for rel_path in stale[:20]:
print(f" stale: {rel_path}")
print("fix: ./jeannie ai-index")
return 1
if missing:
print(f"status: warn ({len(missing)} indexed source file(s) missing)")
for rel_path in missing[:20]:
print(f" missing: {rel_path}")
print("fix: ./jeannie ai-index")
return 1
print("status: ok")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
check_parser = subparsers.add_parser("check")
check_parser.add_argument("--index-dir", type=Path, default=DEFAULT_INDEX_DIR)
args = parser.parse_args()
if args.command == "check":
return check(args.index_dir)
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -5,20 +5,29 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: ./jeannie ask QUESTION... Usage: ./jeannie ask [--citations] QUESTION...
Search the homelab knowledge index for the closest docs, runbooks, scripts, and Search the homelab knowledge index for the closest docs, runbooks, scripts, and
Jeannie commands. Set LAB_AI_ASK_LLM=true to ask Ollama after retrieval. Jeannie commands. Set LAB_AI_ASK_LLM=true to ask Ollama after retrieval.
EOF EOF
} }
citations=false
case "${1:-}" in case "${1:-}" in
--citations)
citations=true
shift
;;
""|-h|--help|help) ""|-h|--help|help)
usage usage
exit 0 exit 0
;; ;;
esac esac
if [ "$citations" = "true" ]; then
exec "${REPO_ROOT}/scripts/query-homelab-ai-index" --citations "$@"
fi
if [ "${LAB_AI_ASK_LLM:-false}" = "true" ]; then if [ "${LAB_AI_ASK_LLM:-false}" = "true" ]; then
exec "${REPO_ROOT}/scripts/query-homelab-ai-index" --ask "$@" exec "${REPO_ROOT}/scripts/query-homelab-ai-index" --ask "$@"
fi fi

View File

@ -11,6 +11,7 @@ import os
import pathlib import pathlib
import re import re
import sys import sys
import time
from collections import Counter from collections import Counter
@ -185,11 +186,16 @@ def main() -> int:
manifest_path.write_text( manifest_path.write_text(
json.dumps( json.dumps(
{ {
"generated_at_epoch": int(time.time()),
"sources_file": str(args.sources_file.relative_to(REPO_ROOT)), "sources_file": str(args.sources_file.relative_to(REPO_ROOT)),
"excludes_file": str(args.excludes_file.relative_to(REPO_ROOT)) if args.excludes_file.is_file() else None, "excludes_file": str(args.excludes_file.relative_to(REPO_ROOT)) if args.excludes_file.is_file() else None,
"file_count": len(files), "file_count": len(files),
"chunk_count": index["chunk_count"], "chunk_count": index["chunk_count"],
"repo_root": str(REPO_ROOT), "repo_root": str(REPO_ROOT),
"files": {
path.relative_to(REPO_ROOT).as_posix(): int(path.stat().st_mtime)
for path in files
},
}, },
indent=2, indent=2,
sort_keys=True, sort_keys=True,

View File

@ -34,7 +34,7 @@ EOF
is_read_only_command() { is_read_only_command() {
case "$1" in case "$1" in
status|capacity|capacity-advisor|capacity-limits|recover-plan|incident|safety-case|agent-sandbox|scorecard|gitops-status|cert-check|backup-status|synthetic-checks|resource-budget|route-inventory|golden-ledger|workers|change-journal|map|validate|access-audit|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|doctor-preapply|inventory-check|secrets-check|tailnet-policy-check|ai-check|ask|ai-evals|ai-scheduler|impact|review-last-change|security-scan|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-attack-path|prompt-injection-lab|control-plane) status|capacity|capacity-advisor|capacity-limits|recover-plan|incident|safety-case|agent-sandbox|scorecard|gitops-status|cert-check|backup-status|synthetic-checks|resource-budget|route-inventory|golden-ledger|workers|change-journal|map|validate|access-audit|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|doctor-preapply|inventory-check|secrets-check|tailnet-policy-check|ai-check|ask|ai-evals|ai-scheduler|ai-memory|impact|review-last-change|security-scan|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-attack-path|prompt-injection-lab|control-plane)
return 0 return 0
;; ;;
*) *)

View File

@ -86,6 +86,31 @@ def render_context(results: list[tuple[float, dict[str, object]]], max_chars: in
return "\n\n---\n\n".join(sections) return "\n\n---\n\n".join(sections)
def render_citations(results: list[tuple[float, dict[str, object]]], max_chars: int) -> str:
sections = ["Jeannie Memory With Provenance", "=============================", ""]
remaining = max_chars
for index, (score, chunk) in enumerate(results, start=1):
path = chunk.get("path")
start_line = chunk.get("start_line")
end_line = chunk.get("end_line")
text = str(chunk.get("text", "")).strip()
excerpt = text[:900].rstrip()
section = [
f"[{index}] {path}:{start_line}-{end_line} score={score:.3f}",
excerpt,
]
rendered = "\n".join(section)
if len(rendered) > remaining:
rendered = rendered[: max(0, remaining - 20)].rstrip() + "\n[truncated]"
sections.append(rendered)
sections.append("")
remaining -= len(rendered)
if remaining <= 0:
break
sections.append("Rule: answers must cite these source paths or say the context is insufficient.")
return "\n".join(sections)
def ask_ollama(question: str, context: str, endpoint: str, model: str, timeout: int) -> str: def ask_ollama(question: str, context: str, endpoint: str, model: str, timeout: int) -> str:
prompt = f"""You are helping operate a personal homelab. prompt = f"""You are helping operate a personal homelab.
Use only the context below. If the context is insufficient, say what to inspect next. Use only the context below. If the context is insufficient, say what to inspect next.
@ -124,6 +149,7 @@ def main() -> int:
parser.add_argument("--limit", type=int, default=5) parser.add_argument("--limit", type=int, default=5)
parser.add_argument("--max-chars", type=int, default=9000) parser.add_argument("--max-chars", type=int, default=9000)
parser.add_argument("--context-only", action="store_true") parser.add_argument("--context-only", action="store_true")
parser.add_argument("--citations", action="store_true")
parser.add_argument("--ask", 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("--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("--model", default=os.environ.get("LAB_AI_GATEWAY_MODEL", "qwen2.5:0.5b"))
@ -143,6 +169,10 @@ def main() -> int:
return 1 return 1
context = render_context(results, args.max_chars) context = render_context(results, args.max_chars)
if args.citations:
print(render_citations(results, args.max_chars))
return 0
if args.context_only: if args.context_only:
print(context) print(context)
return 0 return 0