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
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
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
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
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,
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}`
: Run deterministic Jeannie explanation and RAG grounding eval cases from
`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,
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
`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}"
}
ai_memory() {
"${REPO_ROOT}/scripts/ai-memory" "${@:2}"
}
validate_homelab() {
"${REPO_ROOT}/scripts/validate-homelab"
}
@ -6606,9 +6610,10 @@ Security Learning
AI And Indexing
ai-index Build the local homelab RAG 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-scheduler {simulate|recommend NAME} Simulate AI workload placement policy.
ai-memory {check} Check local RAG index provenance freshness.
Destructive
nuke Guarded cluster state destruction path.
@ -6837,6 +6842,9 @@ case "${1:-}" in
ai-scheduler)
ai_scheduler "$@"
;;
ai-memory)
ai_memory "$@"
;;
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() {
cat <<'EOF'
Usage: ./jeannie ask QUESTION...
Usage: ./jeannie ask [--citations] QUESTION...
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.
EOF
}
citations=false
case "${1:-}" in
--citations)
citations=true
shift
;;
""|-h|--help|help)
usage
exit 0
;;
esac
if [ "$citations" = "true" ]; then
exec "${REPO_ROOT}/scripts/query-homelab-ai-index" --citations "$@"
fi
if [ "${LAB_AI_ASK_LLM:-false}" = "true" ]; then
exec "${REPO_ROOT}/scripts/query-homelab-ai-index" --ask "$@"
fi

View File

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

View File

@ -34,7 +34,7 @@ EOF
is_read_only_command() {
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
;;
*)

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)
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:
prompt = f"""You are helping operate a personal homelab.
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("--max-chars", type=int, default=9000)
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("--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"))
@ -143,6 +169,10 @@ def main() -> int:
return 1
context = render_context(results, args.max_chars)
if args.citations:
print(render_citations(results, args.max_chars))
return 0
if args.context_only:
print(context)
return 0