Compare commits
46 Commits
3e0b9055b0
...
claude/fc-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbbc07023b | ||
|
|
4b0eef0fb0 | ||
|
|
bb09a3786f | ||
|
|
006dbcf671 | ||
|
|
1be71d6ba7 | ||
|
|
0c8026c912 | ||
|
|
621ae47e00 | ||
|
|
ae6b8c0142 | ||
|
|
da55220218 | ||
|
|
b1ad253dd6 | ||
|
|
ee935f6e07 | ||
|
|
2853ee2024 | ||
|
|
b4a34e16ca | ||
|
|
0d5a1fd530 | ||
|
|
1b633f57b2 | ||
|
|
ee8afd0a08 | ||
|
|
cf35884eae | ||
|
|
9881767b11 | ||
|
|
c9bf23834b | ||
|
|
174002023d | ||
|
|
b71f9e4ec9 | ||
|
|
f1431f7324 | ||
|
|
35bd055cb4 | ||
|
|
f604ab419e | ||
|
|
b2786252b0 | ||
|
|
45ee40920d | ||
|
|
8ad7eb714b | ||
|
|
3cb44c3104 | ||
|
|
2400329acd | ||
|
|
c17af882cc | ||
|
|
76b1938afa | ||
|
|
ced04a6148 | ||
|
|
f2258b92a2 | ||
|
|
979a7c7b25 | ||
|
|
0df8f7b936 | ||
|
|
38558641c1 | ||
|
|
63d905b4df | ||
|
|
d95f4e0caf | ||
|
|
7bc565d17e | ||
|
|
dfe9c3b67e | ||
|
|
37f8db89e4 | ||
|
|
00c7d8df24 | ||
|
|
c6811eadd8 | ||
|
|
4d9d537d83 | ||
|
|
0f9d56ee16 | ||
|
|
3bf6511d5d |
@@ -2,14 +2,15 @@
|
|||||||
# Agent Zero AI Stack — NUC Deployment (RKE2 Bare-Metal)
|
# Agent Zero AI Stack — NUC Deployment (RKE2 Bare-Metal)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Deploys: AgentZero (agent UI) on RKE2 cluster with Blue Jay profile
|
# Deploys: AgentZero (agent UI) on RKE2 cluster with Blue Jay profile
|
||||||
# Ollama: workstation-first via BLUEJAY-WS (10.0.56.20:11434) with edge1 Pi 5
|
# Ollama: edge1 Pi 5 + AI HAT+ ONLY (10.0.57.17:11434).
|
||||||
# fallback (10.0.57.17:11434)
|
# Workstation Ollama (BLUEJAY-WS) is intentionally NOT in the upstream —
|
||||||
|
# the workstation is private dev hardware, not a cluster dependency.
|
||||||
# Target: RKE2 bare-metal cluster, namespace: agent-zero
|
# Target: RKE2 bare-metal cluster, namespace: agent-zero
|
||||||
# Profile: Blue Jay (21 tools, 3 prompts, 4 extensions, theme)
|
# Profile: Blue Jay (21 tools, 3 prompts, 4 extensions, theme)
|
||||||
#
|
#
|
||||||
# Differences from LOCAL (WSL K3s):
|
# Differences from LOCAL (WSL K3s):
|
||||||
# - Uses Longhorn StorageClass (not local-path)
|
# - Uses Longhorn StorageClass (not local-path)
|
||||||
# - Prefers workstation Ollama on the R9700, falls back to edge1 Pi 5
|
# - Cluster-only Ollama path (edge1) — keeps workstation private
|
||||||
# - NO Anthropic API key (free/local models only)
|
# - NO Anthropic API key (free/local models only)
|
||||||
# - NO Piper TTS or Kiwix (edge1 handles TTS, no Wikipedia needed)
|
# - NO Piper TTS or Kiwix (edge1 handles TTS, no Wikipedia needed)
|
||||||
# - NO hostPath volumes — profile/tools/extensions loaded via ConfigMaps
|
# - NO hostPath volumes — profile/tools/extensions loaded via ConfigMaps
|
||||||
@@ -91,14 +92,17 @@ subjects:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Agent Zero — AI Agent Web UI (NUC Edition, Blue Jay Profile)
|
# Agent Zero — AI Agent Web UI (NUC Edition, Blue Jay Profile)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Connects to a local proxy that routes to workstation Ollama first and edge1 second
|
# Connects directly to fc-llm-bridge for chat + internal util/embed + browser.
|
||||||
# Blue Jay profile with 21 tools, 3 prompts, 4 extensions
|
# Agent Zero's internal util/embed slots stay on the bridge's OpenAI-compatible
|
||||||
|
# /v1 surface, while browser + corpus-search use the Ollama-compatible /api/*
|
||||||
|
# surface through OLLAMA_HOST.
|
||||||
|
# Blue Jay profile with 21 tools, 3 prompts, 4 extensions.
|
||||||
|
|
||||||
---
|
---
|
||||||
# FC LLM Bridge API key for Agent Zero (ADR-088 chat_model routing).
|
# FC LLM Bridge API key for Agent Zero (ADR-088 chat/util/embed/browser routing).
|
||||||
# Syncs from 1Password item "FC LLM Bridge API Keys" (field: agent-zero-k8s).
|
# Syncs from 1Password item "FC LLM Bridge API Keys" (field: agent-zero-k8s).
|
||||||
# Consumed by the chat_model only; util / embedding / browser stay on local
|
# Consumed by chat, internal util/embed, browser, and corpus-search requests
|
||||||
# Ollama via the 127.0.0.1 sidecar proxy.
|
# that traverse fc-llm-bridge.
|
||||||
apiVersion: onepassword.com/v1
|
apiVersion: onepassword.com/v1
|
||||||
kind: OnePasswordItem
|
kind: OnePasswordItem
|
||||||
metadata:
|
metadata:
|
||||||
@@ -107,6 +111,34 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
itemPath: "vaults/IAmWorkin/items/FC LLM Bridge API Keys"
|
itemPath: "vaults/IAmWorkin/items/FC LLM Bridge API Keys"
|
||||||
|
|
||||||
|
---
|
||||||
|
# Print.Web API key for Agent Zero's print_web.py Python tool.
|
||||||
|
# Syncs from 1Password item "Print.Web API Keys" (password field = API key).
|
||||||
|
# The print_web.py tool reads PRINT_WEB_API_KEY env var for all HTTP requests
|
||||||
|
# to the thermal print service (GET /api/mcp/tools, POST /api/print/*, etc.).
|
||||||
|
# Note: Print.Web uses the legacy REST MCP shape (/api/mcp/tools/*), not the
|
||||||
|
# streamable-http MCP protocol. The print_web Python tool bridges this gap
|
||||||
|
# and is already present in bluejay-tools ConfigMaps.
|
||||||
|
apiVersion: onepassword.com/v1
|
||||||
|
kind: OnePasswordItem
|
||||||
|
metadata:
|
||||||
|
name: print-web-api-keys
|
||||||
|
namespace: agent-zero
|
||||||
|
spec:
|
||||||
|
itemPath: "vaults/IAmWorkin/items/Print.Web API Keys"
|
||||||
|
|
||||||
|
---
|
||||||
|
# Knowledge MCP bearer token for the direct Agent Zero -> Knowledge.Web path.
|
||||||
|
# The 1Password item currently stores the raw token in its concealed PASSWORD
|
||||||
|
# field, which the operator syncs to Secret key `password`.
|
||||||
|
apiVersion: onepassword.com/v1
|
||||||
|
kind: OnePasswordItem
|
||||||
|
metadata:
|
||||||
|
name: knowledge-mcp-tokens
|
||||||
|
namespace: agent-zero
|
||||||
|
spec:
|
||||||
|
itemPath: "vaults/IAmWorkin/items/FlowerCore Knowledge MCP Tokens"
|
||||||
|
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
@@ -118,7 +150,7 @@ metadata:
|
|||||||
annotations:
|
annotations:
|
||||||
agent-zero/deployment: "nuc"
|
agent-zero/deployment: "nuc"
|
||||||
agent-zero/profile: "bluejay"
|
agent-zero/profile: "bluejay"
|
||||||
agent-zero/ollama: "BLUEJAY-WS primary (10.0.56.20:11434), edge1 fallback (10.0.57.17:11434)"
|
agent-zero/ollama: "fc-llm-bridge fronts edge1 Pi 5 + AI HAT+ Ollama for cluster browser/corpus-search traffic; internal chat/util/embed route through the bridge's authenticated OpenAI surface"
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
@@ -133,19 +165,18 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
serviceAccountName: agent-zero
|
serviceAccountName: agent-zero
|
||||||
initContainers:
|
initContainers:
|
||||||
# Wait for either workstation or edge1 Ollama to be reachable before starting Agent Zero.
|
# Wait for fc-llm-bridge to be reachable before starting Agent Zero.
|
||||||
- name: wait-for-ollama
|
- name: wait-for-llm-bridge
|
||||||
image: busybox:1.37
|
image: busybox:1.37
|
||||||
command: ["sh", "-c"]
|
command: ["sh", "-c"]
|
||||||
args:
|
args:
|
||||||
- |
|
- |
|
||||||
echo "Waiting for Ollama at BLUEJAY-WS or edge1..."
|
echo "Waiting for fc-llm-bridge..."
|
||||||
until wget -qO- --timeout=2 http://10.0.56.20:11434/api/tags >/dev/null 2>&1 || \
|
until wget -qO- --timeout=2 http://fc-llm-bridge.fc-llm-bridge.svc:8080/healthz >/dev/null 2>&1; do
|
||||||
wget -qO- --timeout=2 http://10.0.57.17:11434/api/tags >/dev/null 2>&1; do
|
echo "fc-llm-bridge not ready yet, retrying in 5s..."
|
||||||
echo "No Ollama endpoint ready yet, retrying in 5s..."
|
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
echo "At least one Ollama endpoint is reachable."
|
echo "fc-llm-bridge is reachable."
|
||||||
# Assemble the Blue Jay profile directory structure from ConfigMaps.
|
# Assemble the Blue Jay profile directory structure from ConfigMaps.
|
||||||
# ConfigMaps can't create nested dirs, so we copy into the workspace PVC.
|
# ConfigMaps can't create nested dirs, so we copy into the workspace PVC.
|
||||||
- name: setup-bluejay
|
- name: setup-bluejay
|
||||||
@@ -192,71 +223,6 @@ spec:
|
|||||||
- name: bluejay-theme
|
- name: bluejay-theme
|
||||||
mountPath: /tmp/bluejay-theme
|
mountPath: /tmp/bluejay-theme
|
||||||
containers:
|
containers:
|
||||||
- name: ollama-proxy
|
|
||||||
image: nginx:1.27-alpine
|
|
||||||
command: ["/bin/sh", "-c"]
|
|
||||||
args:
|
|
||||||
- |
|
|
||||||
cat > /etc/nginx/nginx.conf <<'NGINX'
|
|
||||||
worker_processes 1;
|
|
||||||
events { worker_connections 1024; }
|
|
||||||
http {
|
|
||||||
upstream ollama_upstream {
|
|
||||||
server 10.0.56.20:11434 max_fails=2 fail_timeout=10s;
|
|
||||||
server 10.0.57.17:11434 backup;
|
|
||||||
keepalive 16;
|
|
||||||
}
|
|
||||||
server {
|
|
||||||
listen 11434;
|
|
||||||
# Local healthcheck — proves nginx itself is alive.
|
|
||||||
# Must NOT depend on upstream so liveness doesn't restart
|
|
||||||
# the container when BLUEJAY-WS Ollama is slow/offline
|
|
||||||
# and nginx is mid-failover to the edge1 backup.
|
|
||||||
location = /healthz {
|
|
||||||
access_log off;
|
|
||||||
return 200 'ok\n';
|
|
||||||
default_type text/plain;
|
|
||||||
}
|
|
||||||
location / {
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_connect_timeout 5s;
|
|
||||||
proxy_read_timeout 600s;
|
|
||||||
proxy_send_timeout 600s;
|
|
||||||
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
|
|
||||||
proxy_pass http://ollama_upstream;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NGINX
|
|
||||||
exec nginx -g 'daemon off;'
|
|
||||||
ports:
|
|
||||||
- containerPort: 11434
|
|
||||||
# Readiness probe DOES check upstream so K8s only routes traffic
|
|
||||||
# when at least one Ollama backend is reachable. timeoutSeconds=5
|
|
||||||
# allows nginx to fail over from BLUEJAY-WS primary to edge1
|
|
||||||
# backup before the probe fails (was timeoutSeconds=1 default →
|
|
||||||
# 172 historic restarts when workstation Ollama was down).
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /api/tags
|
|
||||||
port: 11434
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 15
|
|
||||||
timeoutSeconds: 5
|
|
||||||
failureThreshold: 3
|
|
||||||
# Liveness probe hits ONLY local healthz — restarts the container
|
|
||||||
# only when nginx itself is dead. Decoupling liveness from upstream
|
|
||||||
# eliminates restart-loops caused by transient upstream outages.
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /healthz
|
|
||||||
port: 11434
|
|
||||||
initialDelaySeconds: 10
|
|
||||||
periodSeconds: 30
|
|
||||||
timeoutSeconds: 3
|
|
||||||
failureThreshold: 3
|
|
||||||
- name: agent-zero
|
- name: agent-zero
|
||||||
image: agent0ai/agent-zero:latest
|
image: agent0ai/agent-zero:latest
|
||||||
command: ["/bin/bash", "-c"]
|
command: ["/bin/bash", "-c"]
|
||||||
@@ -277,23 +243,41 @@ spec:
|
|||||||
# chat_model: FlowerCore LLM Bridge (ADR-088) — OpenAI-compat,
|
# chat_model: FlowerCore LLM Bridge (ADR-088) — OpenAI-compat,
|
||||||
# spend-tracked, tier-aliased (fc:balanced → Claude Sonnet).
|
# spend-tracked, tier-aliased (fc:balanced → Claude Sonnet).
|
||||||
# api_key comes from A0_SET_chat_model_api_key env var (overrides
|
# api_key comes from A0_SET_chat_model_api_key env var (overrides
|
||||||
# config.json). util + embedding stay on local 127.0.0.1 Ollama
|
# config.json). Utility + embedding stay on the authenticated
|
||||||
# proxy (workstation primary, edge1 fallback).
|
# OpenAI-compatible /v1 surface; browser and direct tool traffic
|
||||||
|
# use the bridge's Ollama-compatible root via OLLAMA_HOST.
|
||||||
mkdir -p /a0/usr/plugins/_model_config
|
mkdir -p /a0/usr/plugins/_model_config
|
||||||
cat > /a0/usr/plugins/_model_config/config.json << 'MODELCFG'
|
cat > /a0/usr/plugins/_model_config/config.json << 'MODELCFG'
|
||||||
{"allow_chat_override":true,"chat_model":{"provider":"openai","name":"fc:balanced","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","ctx_length":8192,"ctx_history":0.7,"vision":false,"kwargs":{"temperature":0,"num_ctx":8192}},"utility_model":{"provider":"ollama","name":"qwen2.5:1.5b","api_base":"http://127.0.0.1:11434","ctx_length":8192,"ctx_input":0.7,"kwargs":{"num_ctx":8192}},"embedding_model":{"provider":"ollama","name":"nomic-embed-text","api_base":"http://127.0.0.1:11434","kwargs":{}}}
|
{"allow_chat_override":true,"chat_model":{"provider":"openai","name":"fc:balanced","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","ctx_length":8192,"ctx_history":0.7,"vision":false,"kwargs":{"temperature":0,"num_ctx":8192}},"utility_model":{"provider":"openai","name":"fc:cheap","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","ctx_length":8192,"ctx_input":0.7,"kwargs":{"num_ctx":8192}},"embedding_model":{"provider":"openai","name":"openai/fc:embedding","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","kwargs":{}}}
|
||||||
MODELCFG
|
MODELCFG
|
||||||
# Strip heredoc indentation
|
# Strip heredoc indentation
|
||||||
sed -i 's/^ //' /a0/usr/plugins/_model_config/config.json
|
sed -i 's/^ //' /a0/usr/plugins/_model_config/config.json
|
||||||
# Phase 0 Chat MCP pilot: Agent Zero does not interpolate env vars
|
# Phase 0 Chat MCP pilot: Agent Zero does not interpolate env vars
|
||||||
# inside A0_SET_mcp_servers JSON, so build the final JSON here from
|
# inside A0_SET_mcp_servers JSON, so build the final JSON here from
|
||||||
# the secret-backed CHAT_MCP_API_KEY env var before initialize.sh.
|
# the secret-backed env vars before initialize.sh. Keep the local
|
||||||
# Use the in-cluster Chat service URL rather than the public
|
# corpus_search.py tool mounted either way so outage fallback
|
||||||
# Traefik hostname so the pod stays off the private VIP lane that
|
# remains available even when fc_knowledge is not advertised.
|
||||||
# the default egress rule blocks.
|
export KNOWLEDGE_MCP_ENABLED=false
|
||||||
if [ -n "${CHAT_MCP_API_KEY:-}" ]; then
|
if [ -n "${KNOWLEDGE_MCP_BEARER_TOKEN:-}" ]; then
|
||||||
export A0_SET_mcp_servers="{\"mcpServers\":{\"fc-chat\":{\"type\":\"streamable-http\",\"url\":\"http://chat-web.fc-chat.svc/mcp\",\"headers\":{\"X-Api-Key\":\"${CHAT_MCP_API_KEY}\"}}}}"
|
if curl -sf --connect-timeout 3 "${KNOWLEDGE_MCP_HEALTH_URL}" > /dev/null && \
|
||||||
|
curl -sf --connect-timeout 5 \
|
||||||
|
-H "Authorization: Bearer ${KNOWLEDGE_MCP_BEARER_TOKEN}" \
|
||||||
|
-H "Accept: application/json, text/event-stream" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":"fc-knowledge-bootstrap","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"agent-zero-bootstrap","version":"1.0"}}}' \
|
||||||
|
"${KNOWLEDGE_MCP_URL}" > /dev/null; then
|
||||||
|
export KNOWLEDGE_MCP_ENABLED=true
|
||||||
|
echo "fc_knowledge enabled from ${KNOWLEDGE_MCP_URL}."
|
||||||
|
else
|
||||||
|
echo "fc_knowledge unavailable or unauthorized; keeping local corpus_search.py as the fallback path."
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo "fc_knowledge token missing; keeping local corpus_search.py as the fallback path."
|
||||||
|
fi
|
||||||
|
|
||||||
|
export A0_SET_mcp_servers="$(
|
||||||
|
python3 -c 'import json, os; servers = {}; chat_key = os.getenv("CHAT_MCP_API_KEY"); knowledge_enabled = os.getenv("KNOWLEDGE_MCP_ENABLED", "false").lower() == "true"; token = os.getenv("KNOWLEDGE_MCP_BEARER_TOKEN", "") if knowledge_enabled else ""; chat_key and servers.setdefault("fc_chat", {"type": "streamable-http", "url": "http://chat-web.fc-chat.svc/mcp", "headers": {"X-Api-Key": chat_key}}); token and servers.setdefault("fc_knowledge", {"type": "streamable-http", "url": os.getenv("KNOWLEDGE_MCP_URL", "http://knowledge-web.knowledge.svc/mcp"), "headers": {"Authorization": f"Bearer {token}"}}); print(json.dumps({"mcpServers": servers}, separators=(",", ":")))'
|
||||||
|
)"
|
||||||
# Run the original entrypoint
|
# Run the original entrypoint
|
||||||
exec /exe/initialize.sh $BRANCH
|
exec /exe/initialize.sh $BRANCH
|
||||||
ports:
|
ports:
|
||||||
@@ -305,8 +289,9 @@ spec:
|
|||||||
# Chat model — routed through FlowerCore LLM Bridge (ADR-088)
|
# Chat model — routed through FlowerCore LLM Bridge (ADR-088)
|
||||||
# so spend is tracked and tier aliases (fc:cheap/fc:balanced/fc:deep)
|
# so spend is tracked and tier aliases (fc:cheap/fc:balanced/fc:deep)
|
||||||
# dispatch to Ollama or Anthropic via a single OpenAI-compat endpoint.
|
# dispatch to Ollama or Anthropic via a single OpenAI-compat endpoint.
|
||||||
# Util / embedding / browser stay on local Ollama via 127.0.0.1 proxy
|
# Internal utility + embedding use the authenticated OpenAI surface,
|
||||||
# for zero-latency, zero-cost small-model traffic.
|
# while browser/corpus-search use the bridge's Ollama-compatible
|
||||||
|
# endpoints so Agent Zero no longer needs a local proxy sidecar.
|
||||||
- name: A0_SET_chat_model_provider
|
- name: A0_SET_chat_model_provider
|
||||||
value: "openai"
|
value: "openai"
|
||||||
- name: A0_SET_chat_model_name
|
- name: A0_SET_chat_model_name
|
||||||
@@ -328,35 +313,51 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: fc-llm-bridge-api-keys
|
name: fc-llm-bridge-api-keys
|
||||||
key: agent-zero-k8s
|
key: agent-zero-k8s
|
||||||
|
- name: FC_LLM_BRIDGE_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: fc-llm-bridge-api-keys
|
||||||
|
key: agent-zero-k8s
|
||||||
- name: A0_SET_chat_model_ctx_length
|
- name: A0_SET_chat_model_ctx_length
|
||||||
value: "8192"
|
value: "8192"
|
||||||
- name: A0_SET_chat_model_kwargs
|
- name: A0_SET_chat_model_kwargs
|
||||||
value: '{"temperature": 0, "num_ctx": 8192}'
|
value: '{"temperature": 0, "num_ctx": 8192}'
|
||||||
# Utility model — fast small helper tier through the same proxy
|
# Utility model — fast small helper tier through the OpenAI surface
|
||||||
- name: A0_SET_util_model_provider
|
- name: A0_SET_util_model_provider
|
||||||
value: "ollama"
|
value: "openai"
|
||||||
- name: A0_SET_util_model_name
|
- name: A0_SET_util_model_name
|
||||||
value: "qwen2.5:1.5b"
|
value: "fc:cheap"
|
||||||
- name: A0_SET_util_model_api_base
|
- name: A0_SET_util_model_api_base
|
||||||
value: "http://127.0.0.1:11434"
|
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1"
|
||||||
- name: A0_SET_util_model_kwargs
|
- name: A0_SET_util_model_kwargs
|
||||||
value: '{"num_ctx": 2048}'
|
value: '{"num_ctx": 2048}'
|
||||||
# Embedding model — nomic through the same proxy
|
# Embedding model — authenticated bridge alias to nomic-embed-text.
|
||||||
|
# LiteLLM's embedding() path needs an explicit provider prefix here
|
||||||
|
# even though the chat slot can use bare fc:* aliases.
|
||||||
- name: A0_SET_embed_model_provider
|
- name: A0_SET_embed_model_provider
|
||||||
value: "ollama"
|
value: "openai"
|
||||||
- name: A0_SET_embed_model_name
|
- name: A0_SET_embed_model_name
|
||||||
value: "nomic-embed-text"
|
value: "openai/fc:embedding"
|
||||||
- name: A0_SET_embed_model_api_base
|
- name: A0_SET_embed_model_api_base
|
||||||
value: "http://127.0.0.1:11434"
|
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1"
|
||||||
# Browser model — small Gemma candidate through the same proxy
|
# Browser model — small Gemma candidate through the same proxy
|
||||||
- name: A0_SET_browser_model_provider
|
- name: A0_SET_browser_model_provider
|
||||||
value: "ollama"
|
value: "ollama"
|
||||||
- name: A0_SET_browser_model_name
|
- name: A0_SET_browser_model_name
|
||||||
value: "gemma3:4b"
|
value: "gemma3:4b"
|
||||||
- name: A0_SET_browser_model_api_base
|
- name: A0_SET_browser_model_api_base
|
||||||
value: "http://127.0.0.1:11434"
|
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080"
|
||||||
|
- name: A0_SET_browser_model_api_key
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: fc-llm-bridge-api-keys
|
||||||
|
key: agent-zero-k8s
|
||||||
- name: A0_SET_browser_model_vision
|
- name: A0_SET_browser_model_vision
|
||||||
value: "true"
|
value: "true"
|
||||||
|
- name: OLLAMA_HOST
|
||||||
|
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080"
|
||||||
|
- name: FLOWERCORE_AGENTZERO_OLLAMA_URL
|
||||||
|
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080"
|
||||||
# Agent profile — Blue Jay personality, tools, and system prompt
|
# Agent profile — Blue Jay personality, tools, and system prompt
|
||||||
- name: A0_SET_agent_profile
|
- name: A0_SET_agent_profile
|
||||||
value: "bluejay"
|
value: "bluejay"
|
||||||
@@ -379,9 +380,38 @@ spec:
|
|||||||
name: chat-mcp-api-key
|
name: chat-mcp-api-key
|
||||||
key: api-key
|
key: api-key
|
||||||
optional: true
|
optional: true
|
||||||
# Print.Web — Thermal printer service on edge2
|
# FlowerCore.Knowledge MCP Phase 1 — direct Agent Zero client path.
|
||||||
|
# Probe /healthz first, then try an authenticated initialize call.
|
||||||
|
# If either fails, Agent Zero boots without fc_knowledge and keeps
|
||||||
|
# the local corpus_search.py tool as the outage-safe path.
|
||||||
|
- name: KNOWLEDGE_MCP_URL
|
||||||
|
value: "http://knowledge-web.knowledge.svc/mcp"
|
||||||
|
- name: KNOWLEDGE_MCP_HEALTH_URL
|
||||||
|
value: "http://knowledge-web.knowledge.svc/healthz"
|
||||||
|
- name: KNOWLEDGE_MCP_BEARER_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: knowledge-mcp-tokens
|
||||||
|
key: password
|
||||||
|
# Print.Web — Thermal printer service on edge2.
|
||||||
|
# PRINT_WEB_URL: internal HTTP (bypasses Traefik TLS — print_web.py
|
||||||
|
# runs in-cluster and can reach edge2 directly on the PROD VLAN).
|
||||||
|
# PRINT_WEB_API_KEY: from 1Password "Print.Web API Keys" password field,
|
||||||
|
# synced by the print-web-api-keys OnePasswordItem CRD above.
|
||||||
|
# The print_web.py Python tool reads both env vars for all HTTP calls.
|
||||||
- name: PRINT_WEB_URL
|
- name: PRINT_WEB_URL
|
||||||
value: "http://10.0.57.16:5200"
|
value: "http://10.0.57.16:5200"
|
||||||
|
- name: PRINT_WEB_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: print-web-api-keys
|
||||||
|
key: password
|
||||||
|
# Intranet search — use in-cluster HTTP (no step-ca TLS needed)
|
||||||
|
# corpus_search.py reads FLOWERCORE_FLEET_VECTOR_DIR but that mount is not
|
||||||
|
# on the cluster yet (BLUEJAY-WS only). The tool gracefully returns a
|
||||||
|
# "no DB found" message with rebuild instructions rather than crashing.
|
||||||
|
- name: FLOWERCORE_INTRANET_URL
|
||||||
|
value: "http://intranet-web.intranet.svc:5300"
|
||||||
# Kubernetes
|
# Kubernetes
|
||||||
- name: KUBERNETES_SERVICE_HOST
|
- name: KUBERNETES_SERVICE_HOST
|
||||||
value: "kubernetes.default.svc"
|
value: "kubernetes.default.svc"
|
||||||
@@ -416,7 +446,7 @@ spec:
|
|||||||
command:
|
command:
|
||||||
- /bin/bash
|
- /bin/bash
|
||||||
- -c
|
- -c
|
||||||
- "curl -sf http://localhost:80/ > /dev/null && curl -sf --connect-timeout 3 http://127.0.0.1:11434/api/tags > /dev/null"
|
- "curl -sf http://localhost:80/ > /dev/null && curl -sf --connect-timeout 3 http://fc-llm-bridge.fc-llm-bridge.svc:8080/healthz > /dev/null"
|
||||||
periodSeconds: 30
|
periodSeconds: 30
|
||||||
failureThreshold: 2
|
failureThreshold: 2
|
||||||
resources:
|
resources:
|
||||||
@@ -554,18 +584,6 @@ spec:
|
|||||||
protocol: UDP
|
protocol: UDP
|
||||||
- port: 53
|
- port: 53
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
# Ollama on BLUEJAY-WS
|
|
||||||
- to:
|
|
||||||
- ipBlock:
|
|
||||||
cidr: 10.0.56.20/32
|
|
||||||
ports:
|
|
||||||
- port: 11434
|
|
||||||
# Ollama on edge1 fallback
|
|
||||||
- to:
|
|
||||||
- ipBlock:
|
|
||||||
cidr: 10.0.57.17/32
|
|
||||||
ports:
|
|
||||||
- port: 11434
|
|
||||||
# Print.Web on edge2
|
# Print.Web on edge2
|
||||||
- to:
|
- to:
|
||||||
- ipBlock:
|
- ipBlock:
|
||||||
@@ -599,6 +617,26 @@ spec:
|
|||||||
protocol: TCP
|
protocol: TCP
|
||||||
- port: 8080
|
- port: 8080
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
|
# FlowerCore.Knowledge MCP (Phase 1) — in-cluster direct route with
|
||||||
|
# anonymous /healthz probe plus authenticated /mcp initialize/tool calls.
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: knowledge
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
protocol: TCP
|
||||||
|
- port: 8080
|
||||||
|
protocol: TCP
|
||||||
|
# Intranet search API — use in-cluster svc so traffic stays inside
|
||||||
|
# the cluster and is not blocked by the private-range egress denylist.
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: intranet
|
||||||
|
ports:
|
||||||
|
- port: 5300
|
||||||
|
protocol: TCP
|
||||||
# Allow internet (for kubectl image pull, etc)
|
# Allow internet (for kubectl image pull, etc)
|
||||||
- to:
|
- to:
|
||||||
- ipBlock:
|
- ipBlock:
|
||||||
|
|||||||
@@ -7209,6 +7209,9 @@ data:
|
|||||||
"keep_alive": keep_alive,
|
"keep_alive": keep_alive,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
})
|
})
|
||||||
|
curl_headers = ["-H", "Content-Type: application/json"]
|
||||||
|
if os.environ.get("FC_LLM_BRIDGE_API_KEY"):
|
||||||
|
curl_headers.extend(["-H", f"X-Api-Key: {os.environ['FC_LLM_BRIDGE_API_KEY']}"])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -7216,7 +7219,7 @@ data:
|
|||||||
"curl", "-s", "--max-time", "120",
|
"curl", "-s", "--max-time", "120",
|
||||||
"-X", "POST",
|
"-X", "POST",
|
||||||
f"{api_base}/api/generate",
|
f"{api_base}/api/generate",
|
||||||
"-H", "Content-Type: application/json",
|
*curl_headers,
|
||||||
"-d", payload,
|
"-d", payload,
|
||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -13150,6 +13153,451 @@ data:
|
|||||||
- PowerShell 5.1 compatibility is assumed (no PowerShell 7+ features).
|
- PowerShell 5.1 compatibility is assumed (no PowerShell 7+ features).
|
||||||
- All commands run with `-NoProfile -NonInteractive` flags for clean execution.
|
- All commands run with `-NoProfile -NonInteractive` flags for clean execution.
|
||||||
"""
|
"""
|
||||||
|
corpus_search.py: |
|
||||||
|
# FlowerCore Fleet Corpus Vector Search Tool
|
||||||
|
#
|
||||||
|
# Queries the AiStation-built SqliteVecVectorStore DB at /a0/usr/vectors/fleet.db
|
||||||
|
# (bind-mounted read-only from /var/lib/flowercore/vector-stores/ on the host).
|
||||||
|
# Embeds the query through Ollama's nomic-embed-text model, computes cosine
|
||||||
|
# similarity against every stored chunk in pure Python (no numpy — not present
|
||||||
|
# in the container), and returns the top-K nearest neighbors with source metadata.
|
||||||
|
#
|
||||||
|
# This is the offline-friendly counterpart to `intranet_search` (which hits the
|
||||||
|
# Intranet's live REST API). Use it for Bible/Greek/Hebrew/Strong's lookups and
|
||||||
|
# anywhere the workstation has a newer DB than the Intranet one. The store is
|
||||||
|
# refreshed by `aistation-indexer build <edition>` — see the FlowerCore.Knowledge
|
||||||
|
# ADR at docs/ai-agents/flowercore-knowledge-service-plan.md.
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from python.helpers.tool import Tool, Response
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_VECTORS_DIR = os.environ.get(
|
||||||
|
"FLOWERCORE_FLEET_VECTOR_DIR",
|
||||||
|
"/a0/usr/vectors",
|
||||||
|
)
|
||||||
|
# When the caller doesn't pick an explicit DB, prefer the biggest fleet tier
|
||||||
|
# present on disk. Workstation → pi-edge → bmo-bot.
|
||||||
|
PREFERRED_DB_ORDER = [
|
||||||
|
os.environ.get("FLOWERCORE_FLEET_VECTOR_DB", ""),
|
||||||
|
"fleet-workstation-full.db",
|
||||||
|
"fleet-pi-edge.db",
|
||||||
|
"fleet-bmo-bot.db",
|
||||||
|
]
|
||||||
|
OLLAMA_BASE_URL = os.environ.get(
|
||||||
|
"FLOWERCORE_AGENTZERO_OLLAMA_URL",
|
||||||
|
"http://host.containers.internal:11434",
|
||||||
|
)
|
||||||
|
BRIDGE_API_KEY = os.environ.get("FC_LLM_BRIDGE_API_KEY", "").strip()
|
||||||
|
EMBEDDING_MODEL = os.environ.get(
|
||||||
|
"FLOWERCORE_FLEET_EMBEDDING_MODEL",
|
||||||
|
"nomic-embed-text",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CorpusSearch(Tool):
|
||||||
|
async def execute(self, **kwargs) -> Response:
|
||||||
|
"""
|
||||||
|
Semantic search over the FlowerCore fleet corpus (Bible texts, lexicons,
|
||||||
|
dictionaries, morphology) pre-indexed by aistation-indexer.
|
||||||
|
|
||||||
|
Args (via self.args):
|
||||||
|
query (str): Search query text. Required unless action=stats.
|
||||||
|
limit (int): Max results. Default 8.
|
||||||
|
index (str): Optional index name filter ("bible-texts", "lexicons",
|
||||||
|
"dictionaries", "morphology"). Default: all indexes.
|
||||||
|
repo (str): Optional repo filter (e.g. "world-english-bible").
|
||||||
|
db (str): Override DB path OR file name inside FLOWERCORE_FLEET_VECTOR_DIR
|
||||||
|
(defaults to /a0/usr/vectors). If omitted, the largest
|
||||||
|
fleet tier present on disk is picked automatically.
|
||||||
|
action (str): Optional. "stats" returns an inventory of all fleet DBs
|
||||||
|
visible to the tool (names, sizes, index counts, chunk
|
||||||
|
counts, last-built timestamps). No embedding call.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response with ranked chunks (score, source, text preview) OR
|
||||||
|
(when action=stats) a markdown inventory of available fleet DBs.
|
||||||
|
"""
|
||||||
|
query = (self.args.get("query") or "").strip()
|
||||||
|
limit = int(self.args.get("limit") or 8)
|
||||||
|
index_filter = (self.args.get("index") or "").strip()
|
||||||
|
repo_filter = (self.args.get("repo") or "").strip()
|
||||||
|
db_override = (self.args.get("db") or "").strip()
|
||||||
|
action = (self.args.get("action") or "").strip().lower()
|
||||||
|
|
||||||
|
if action == "stats":
|
||||||
|
return Response(message=_render_stats(), break_loop=False)
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return Response(
|
||||||
|
message=(
|
||||||
|
"Error: 'query' is required unless action=stats.\n"
|
||||||
|
"Example: query=\"what does Genesis 1:1 say\" limit=5\n"
|
||||||
|
"Inventory: action=stats"
|
||||||
|
),
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = _resolve_db(db_override)
|
||||||
|
if db is None:
|
||||||
|
return Response(
|
||||||
|
message=(
|
||||||
|
f"Error: no fleet vector DB found under {DEFAULT_VECTORS_DIR}.\n"
|
||||||
|
"Host side: run `aistation-indexer build fleet-workstation-full`\n"
|
||||||
|
"(or `fleet-pi-edge`/`fleet-bmo-bot`) to produce\n"
|
||||||
|
"`/var/lib/flowercore/vector-stores/<slug>.db`, then confirm the\n"
|
||||||
|
"Podman unit mounts that directory into `/a0/usr/vectors:ro`."
|
||||||
|
),
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
query_vec = _embed(query)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
message=f"Error: failed to embed query via Ollama at {OLLAMA_BASE_URL}: {e}",
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
hits = _search(db, query_vec, index_filter, repo_filter, limit)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
message=f"Error: corpus search failed: {e}",
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hits:
|
||||||
|
return Response(
|
||||||
|
message=(
|
||||||
|
f"No matches for '{query}' in {db.name}.\n"
|
||||||
|
f"Indexes available: " + _list_indexes_summary(db)
|
||||||
|
),
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [f"**Corpus search: `{query}`** (top {len(hits)} of {limit} requested, DB={db.name})", ""]
|
||||||
|
for rank, h in enumerate(hits, 1):
|
||||||
|
passage = h.get("passage") or ""
|
||||||
|
lang = h.get("language") or ""
|
||||||
|
meta_bits = [x for x in (h["index"], h["repo"], passage, lang) if x]
|
||||||
|
meta = " · ".join(meta_bits)
|
||||||
|
preview = h["text"]
|
||||||
|
if len(preview) > 320:
|
||||||
|
preview = preview[:320].rstrip() + "…"
|
||||||
|
lines.append(f"{rank}. **{h['score']:.3f}** {meta}")
|
||||||
|
lines.append(f" `{h['source']}`")
|
||||||
|
lines.append(f" {preview}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return Response(message="\n".join(lines).rstrip() + "\n", break_loop=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_db(override: str) -> "Path | None":
|
||||||
|
"""Pick a fleet DB by explicit path, explicit filename, or preferred order."""
|
||||||
|
vectors_dir = Path(DEFAULT_VECTORS_DIR)
|
||||||
|
if override:
|
||||||
|
# Absolute or relative path that points at a real file wins outright.
|
||||||
|
p = Path(override)
|
||||||
|
if p.is_absolute() and p.exists():
|
||||||
|
return p
|
||||||
|
# Otherwise treat it as a filename within the vectors dir.
|
||||||
|
candidate = vectors_dir / override
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
for name in PREFERRED_DB_ORDER:
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
p = Path(name) if Path(name).is_absolute() else vectors_dir / name
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
|
||||||
|
# Fallback: any *.db in the dir, largest first.
|
||||||
|
if vectors_dir.is_dir():
|
||||||
|
candidates = sorted(vectors_dir.glob("*.db"), key=lambda p: p.stat().st_size, reverse=True)
|
||||||
|
if candidates:
|
||||||
|
return candidates[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _embed(text: str) -> list:
|
||||||
|
"""Embed a query via Ollama's /api/embeddings. Single-vector response."""
|
||||||
|
body = json.dumps({"model": EMBEDDING_MODEL, "prompt": text}).encode("utf-8")
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if BRIDGE_API_KEY:
|
||||||
|
headers["X-Api-Key"] = BRIDGE_API_KEY
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{OLLAMA_BASE_URL.rstrip('/')}/api/embeddings",
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
vec = data.get("embedding")
|
||||||
|
if not isinstance(vec, list) or not vec:
|
||||||
|
raise RuntimeError(f"Ollama returned no embedding: {data}")
|
||||||
|
return [float(x) for x in vec]
|
||||||
|
|
||||||
|
|
||||||
|
def _cosine(a: list, b: list) -> float:
|
||||||
|
"""Cosine similarity in pure Python — no numpy in the A0 container."""
|
||||||
|
# zip() stops at the shorter — AiStation DB guarantees same dim per index.
|
||||||
|
dot = 0.0
|
||||||
|
na = 0.0
|
||||||
|
nb = 0.0
|
||||||
|
for x, y in zip(a, b):
|
||||||
|
dot += x * y
|
||||||
|
na += x * x
|
||||||
|
nb += y * y
|
||||||
|
if na == 0.0 or nb == 0.0:
|
||||||
|
return 0.0
|
||||||
|
return dot / (math.sqrt(na) * math.sqrt(nb))
|
||||||
|
|
||||||
|
|
||||||
|
def _search(db_path: Path, query_vec: list, index_filter: str, repo_filter: str, limit: int) -> list:
|
||||||
|
"""Load entries, compute cosine, return top-K.
|
||||||
|
|
||||||
|
SqliteVecVectorStore schema:
|
||||||
|
VectorIndexes(IndexName, Dimensions, UpdatedAtUtc)
|
||||||
|
VectorEntries(IndexName, ChunkId, TextContent, SourceRepo, SourceFile,
|
||||||
|
Book, Chapter, VerseRange, Language, ContentType, License,
|
||||||
|
EstimatedTokens, EmbeddingJson)
|
||||||
|
|
||||||
|
Embeddings are stored as JSON arrays in EmbeddingJson; similarity is computed
|
||||||
|
in Python. For ~100k chunks × 768 dims this takes a couple seconds on a
|
||||||
|
workstation — acceptable for interactive A0 use.
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
sql = [
|
||||||
|
"SELECT IndexName, ChunkId, TextContent, SourceRepo, SourceFile, ",
|
||||||
|
" Book, Chapter, VerseRange, Language, EmbeddingJson ",
|
||||||
|
"FROM VectorEntries",
|
||||||
|
]
|
||||||
|
where = []
|
||||||
|
params = []
|
||||||
|
if index_filter:
|
||||||
|
where.append("IndexName = ?")
|
||||||
|
params.append(index_filter)
|
||||||
|
if repo_filter:
|
||||||
|
where.append("SourceRepo LIKE ?")
|
||||||
|
params.append(f"%{repo_filter}%")
|
||||||
|
if where:
|
||||||
|
sql.append(" WHERE " + " AND ".join(where))
|
||||||
|
sql.append(";")
|
||||||
|
|
||||||
|
cursor = conn.execute("".join(sql), params)
|
||||||
|
|
||||||
|
# Min-heap by (score, ...) would be faster but for interactive use we
|
||||||
|
# just sort at the end — simpler and readable.
|
||||||
|
scored = []
|
||||||
|
for row in cursor:
|
||||||
|
idx, chunk_id, text, repo, source_file, book, chapter, verses, lang, emb_json = row
|
||||||
|
try:
|
||||||
|
vec = json.loads(emb_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
score = _cosine(query_vec, vec)
|
||||||
|
passage = None
|
||||||
|
if book and chapter:
|
||||||
|
passage = f"{book} {chapter}"
|
||||||
|
if verses:
|
||||||
|
passage += f":{verses}"
|
||||||
|
scored.append((score, {
|
||||||
|
"index": idx,
|
||||||
|
"chunk_id": chunk_id,
|
||||||
|
"text": text,
|
||||||
|
"repo": repo or "",
|
||||||
|
"source": source_file or "",
|
||||||
|
"passage": passage or "",
|
||||||
|
"language": lang or "",
|
||||||
|
}))
|
||||||
|
scored.sort(key=lambda t: t[0], reverse=True)
|
||||||
|
return [{"score": s, **meta} for s, meta in scored[:limit]]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_stats() -> str:
|
||||||
|
"""Markdown inventory of every *.db in FLOWERCORE_FLEET_VECTOR_DIR."""
|
||||||
|
vectors_dir = Path(DEFAULT_VECTORS_DIR)
|
||||||
|
if not vectors_dir.is_dir():
|
||||||
|
return f"No fleet vector dir mounted at {vectors_dir}. Ask the host operator to build an index with scripts/agent-zero/build-fleet-index.sh."
|
||||||
|
|
||||||
|
dbs = sorted(vectors_dir.glob("*.db"))
|
||||||
|
if not dbs:
|
||||||
|
return f"No fleet DBs present under {vectors_dir}. Run `scripts/agent-zero/build-fleet-index.sh fleet-workstation-full` on the host."
|
||||||
|
|
||||||
|
lines = [f"**Fleet vector DB inventory** ({vectors_dir})", ""]
|
||||||
|
for db in dbs:
|
||||||
|
size_mb = db.stat().st_size / (1024 * 1024)
|
||||||
|
lines.append(f"### `{db.name}` ({size_mb:.1f} MB)")
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
idx_rows = conn.execute(
|
||||||
|
"SELECT IndexName, Dimensions, UpdatedAtUtc FROM VectorIndexes ORDER BY IndexName;"
|
||||||
|
).fetchall()
|
||||||
|
if not idx_rows:
|
||||||
|
lines.append("- (no indexes registered)")
|
||||||
|
else:
|
||||||
|
counts = dict(conn.execute(
|
||||||
|
"SELECT IndexName, COUNT(*) FROM VectorEntries GROUP BY IndexName;"
|
||||||
|
).fetchall())
|
||||||
|
for name, dim, updated in idx_rows:
|
||||||
|
count = counts.get(name, 0)
|
||||||
|
lines.append(f"- **{name}** — {count:,} chunks × {dim}d (built {updated})")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
lines.append(f"- (inspect failed: {e})")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(f"**Tool defaults:** embedding model `{EMBEDDING_MODEL}`, Ollama at `{OLLAMA_BASE_URL}`. Pick a DB with `db=<filename>`; filter by `index=<name>`/`repo=<substring>`.")
|
||||||
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _list_indexes_summary(db_path: Path) -> str:
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT IndexName, Dimensions, "
|
||||||
|
" (SELECT COUNT(*) FROM VectorEntries WHERE VectorEntries.IndexName = VectorIndexes.IndexName) "
|
||||||
|
"FROM VectorIndexes ORDER BY IndexName;"
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
return "(no indexes)"
|
||||||
|
return ", ".join(f"{r[0]}({r[2]}×{r[1]}d)" for r in rows)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
return f"(couldn't list: {e})"
|
||||||
|
|
||||||
|
intranet_search.py: |
|
||||||
|
# Intranet Vector Search Tool
|
||||||
|
# Queries the Blue Jay Lab Intranet's Shared.Indexing RAG corpus over its
|
||||||
|
# live REST API (https://intranet.iamworkin.lan/search). Returns ranked chunks
|
||||||
|
# with source file paths and scores.
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from python.helpers.tool import Tool, Response
|
||||||
|
|
||||||
|
|
||||||
|
INTRANET_BASE_URL = os.environ.get(
|
||||||
|
"FLOWERCORE_INTRANET_URL",
|
||||||
|
"https://intranet.iamworkin.lan",
|
||||||
|
)
|
||||||
|
STEPCA_ROOT_CRT = "/a0/usr/ca/stepca-root.crt"
|
||||||
|
|
||||||
|
|
||||||
|
def _ssl_ctx() -> ssl.SSLContext:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
if os.path.exists(STEPCA_ROOT_CRT):
|
||||||
|
ctx.load_verify_locations(cafile=STEPCA_ROOT_CRT)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class IntranetSearch(Tool):
|
||||||
|
async def execute(self, **kwargs) -> Response:
|
||||||
|
"""
|
||||||
|
Search the Blue Jay Lab intranet corpus (docs, project notes, dashboards).
|
||||||
|
|
||||||
|
Args (via self.args):
|
||||||
|
query (str): Search query. Required.
|
||||||
|
limit (int): Max chunks to return. Default 8.
|
||||||
|
corpus (str): Optional corpus filter (e.g. "notes", "docs").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response with ranked chunk text, source path, and score.
|
||||||
|
"""
|
||||||
|
query = self.args.get("query", "").strip()
|
||||||
|
limit = int(self.args.get("limit", 8))
|
||||||
|
corpus = self.args.get("corpus", "").strip()
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return Response(
|
||||||
|
message="Error: 'query' is required.",
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {"q": query, "topK": str(limit)}
|
||||||
|
if corpus:
|
||||||
|
params["indexName"] = corpus
|
||||||
|
url = f"{INTRANET_BASE_URL}/api/search?{urllib.parse.urlencode(params)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=20, context=_ssl_ctx()) as resp:
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
except Exception as exc:
|
||||||
|
return Response(
|
||||||
|
message=f"Intranet search failed: {exc}\nURL: {url}",
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return Response(
|
||||||
|
message=f"Intranet returned non-JSON response:\n{raw[:500]}",
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
hits = data if isinstance(data, list) else (
|
||||||
|
data.get("results") or data.get("hits") or data.get("chunks") or []
|
||||||
|
)
|
||||||
|
if not hits:
|
||||||
|
return Response(
|
||||||
|
message=f"No intranet results for query: {query!r}",
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [f"# Intranet search: {query} ({len(hits)} hits)\n"]
|
||||||
|
for i, hit in enumerate(hits[:limit], 1):
|
||||||
|
src = (
|
||||||
|
hit.get("sourceFile")
|
||||||
|
or hit.get("source")
|
||||||
|
or hit.get("path")
|
||||||
|
or hit.get("file")
|
||||||
|
or "?"
|
||||||
|
)
|
||||||
|
repo = hit.get("sourceRepo") or ""
|
||||||
|
idx = hit.get("indexName") or ""
|
||||||
|
score = hit.get("score") or hit.get("similarity") or ""
|
||||||
|
text = (
|
||||||
|
hit.get("snippet")
|
||||||
|
or hit.get("text")
|
||||||
|
or hit.get("content")
|
||||||
|
or hit.get("chunk")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if len(text) > 600:
|
||||||
|
text = text[:600] + "..."
|
||||||
|
header = f"## [{i}] {repo}/{src}" if repo else f"## [{i}] {src}"
|
||||||
|
if idx:
|
||||||
|
header += f" ({idx})"
|
||||||
|
if score:
|
||||||
|
header += f" score={score:.3f}" if isinstance(score, float) else f" score={score}"
|
||||||
|
lines.append(header)
|
||||||
|
lines.append(text)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return Response(message="\n".join(lines), break_loop=False)
|
||||||
|
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
metadata:
|
metadata:
|
||||||
name: bluejay-tools-c
|
name: bluejay-tools-c
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ spec:
|
|||||||
# dotnet.exe publish -c Release -o deploy/app \
|
# dotnet.exe publish -c Release -o deploy/app \
|
||||||
# src/FlowerCore.LlmBridge.Web/FlowerCore.LlmBridge.Web.csproj
|
# src/FlowerCore.LlmBridge.Web/FlowerCore.LlmBridge.Web.csproj
|
||||||
# podman build -t localhost/fc-llm-bridge:v<tag> -f deploy/Dockerfile.deploy deploy
|
# podman build -t localhost/fc-llm-bridge:v<tag> -f deploy/Dockerfile.deploy deploy
|
||||||
image: localhost/fc-llm-bridge:v202604231520
|
image: localhost/fc-llm-bridge:v202604300022
|
||||||
imagePullPolicy: Never
|
imagePullPolicy: Never
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
@@ -116,6 +116,10 @@ spec:
|
|||||||
value: "default"
|
value: "default"
|
||||||
- name: FlowerCore__LlmBridge__DefaultAppName
|
- name: FlowerCore__LlmBridge__DefaultAppName
|
||||||
value: "agent-zero"
|
value: "agent-zero"
|
||||||
|
- name: FlowerCore__LlmBridge__UtilModel
|
||||||
|
value: "qwen2.5:1.5b"
|
||||||
|
- name: FlowerCore__LlmBridge__EmbedModel
|
||||||
|
value: "nomic-embed-text"
|
||||||
# Per-consumer API keys — from OnePasswordItem fc-llm-bridge-api-keys.
|
# Per-consumer API keys — from OnePasswordItem fc-llm-bridge-api-keys.
|
||||||
# Each field becomes a Secret key of the same name. The key-name
|
# Each field becomes a Secret key of the same name. The key-name
|
||||||
# lands in the auth principal's `fc.app` claim for ledger scoping.
|
# lands in the auth principal's `fc.app` claim for ledger scoping.
|
||||||
|
|||||||
@@ -296,14 +296,23 @@ spec:
|
|||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
failureThreshold: 18
|
failureThreshold: 18
|
||||||
|
# Sprint E Phase 1a (kokoro stability) — 4 restarts in 2d6h with
|
||||||
|
# exit 143 traced to liveness probe `context deadline exceeded` while
|
||||||
|
# kokoro was busy synthesizing. /v1/audio/voices shares the FastAPI
|
||||||
|
# worker pool with /v1/audio/speech, so a long synth can starve the
|
||||||
|
# probe out within the prior 5s × 3 = 15s window. Bump timeoutSeconds
|
||||||
|
# 5 → 15 and failureThreshold 3 → 5 → 75s grace before kubelet kills
|
||||||
|
# the pod. The TtsCircuitBreaker on the synthesizer side (Phase 1b)
|
||||||
|
# backs this up so the FC backend stops slamming kokoro during
|
||||||
|
# recovery.
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /v1/audio/voices
|
path: /v1/audio/voices
|
||||||
port: 8880
|
port: 8880
|
||||||
initialDelaySeconds: 180
|
initialDelaySeconds: 180
|
||||||
periodSeconds: 30
|
periodSeconds: 30
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 15
|
||||||
failureThreshold: 3
|
failureThreshold: 5
|
||||||
---
|
---
|
||||||
# fc-biblical-tts — eSpeak-NG-backed Ancient Greek + Hebrew TTS with
|
# fc-biblical-tts — eSpeak-NG-backed Ancient Greek + Hebrew TTS with
|
||||||
# word-level timing for read-along playback. Companion to ttsreader-kokoro
|
# word-level timing for read-along playback. Companion to ttsreader-kokoro
|
||||||
@@ -510,7 +519,7 @@ spec:
|
|||||||
fsGroupChangePolicy: OnRootMismatch
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: localhost/fc-ttsreader-web:v202604252002
|
image: localhost/fc-ttsreader-web:v202604291817
|
||||||
imagePullPolicy: Never
|
imagePullPolicy: Never
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5217
|
- containerPort: 5217
|
||||||
@@ -573,6 +582,19 @@ spec:
|
|||||||
value: "/data/logs"
|
value: "/data/logs"
|
||||||
- name: TtsReader__Runtime__SmokeStatePath
|
- name: TtsReader__Runtime__SmokeStatePath
|
||||||
value: "/data/ops/smoke-status.json"
|
value: "/data/ops/smoke-status.json"
|
||||||
|
# Sprint E Day 8 voice-preview disk cache — writes WAVs under
|
||||||
|
# this directory. Default "data/voice-previews" resolves to
|
||||||
|
# the read-only $HOME path under runAsNonRoot=true. Pin to
|
||||||
|
# the writable PVC mount.
|
||||||
|
- name: TtsReader__Preview__CacheDirectory
|
||||||
|
value: "/data/voice-previews"
|
||||||
|
# Sprint E XXL Phase 4γ — content-addressed CDN bundle dir for
|
||||||
|
# POST /api/v1/render. Default "wwwroot/cdn" resolves under the
|
||||||
|
# read-only app filesystem, so pin to the writable PVC mount
|
||||||
|
# alongside other TtsReader runtime data. Manifests + cue audio
|
||||||
|
# land at /data/cdn/sha256/<hash>/manifest.json + cues/.
|
||||||
|
- name: TtsReader__Render__CdnDirectory
|
||||||
|
value: "/data/cdn"
|
||||||
- name: Auth__ApiKey
|
- name: Auth__ApiKey
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
@@ -465,6 +465,22 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
itemPath: vaults/IAmWorkin/items/Guacamole JSON Auth
|
itemPath: vaults/IAmWorkin/items/Guacamole JSON Auth
|
||||||
---
|
---
|
||||||
|
---
|
||||||
|
# 1Password-backed credentials for Mac mini VNC access (Phase 1 — 2026-04-28)
|
||||||
|
# The operator mints Secret 'macmini-vnc-creds' with keys: username, password, VNC Password
|
||||||
|
# Note: '1Password' field label 'VNC Password' -> K8s Secret key 'VNC Password' (space retained)
|
||||||
|
# Guacamole VNC connection password is sourced from the 'VNC Password' field.
|
||||||
|
# Actual IP is 10.0.56.115 (INFRA VLAN) — the 1P item 'IP' field is kept as backup reference.
|
||||||
|
apiVersion: onepassword.com/v1
|
||||||
|
kind: OnePasswordItem
|
||||||
|
metadata:
|
||||||
|
name: macmini-vnc-creds
|
||||||
|
namespace: guacamole
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/component: credentials
|
||||||
|
app.kubernetes.io/part-of: flowercore
|
||||||
|
spec:
|
||||||
|
itemPath: vaults/IAmWorkin/items/Mac Mini
|
||||||
# Blue Jay Branding Extension (CSS + translations)
|
# Blue Jay Branding Extension (CSS + translations)
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ spec:
|
|||||||
requests:
|
requests:
|
||||||
storage: 1Gi
|
storage: 1Gi
|
||||||
---
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: intranet-config
|
||||||
|
namespace: intranet
|
||||||
|
data:
|
||||||
|
KnowledgeApiKey: ""
|
||||||
|
TrustedHeaderSharedSecret: ""
|
||||||
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
@@ -37,7 +46,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: intranet-web
|
- name: intranet-web
|
||||||
image: localhost/fc-intranet-web:v202604242354overridefix
|
image: localhost/fc-intranet-web:v20260429-1646
|
||||||
imagePullPolicy: Never
|
imagePullPolicy: Never
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5300
|
- containerPort: 5300
|
||||||
@@ -52,6 +61,27 @@ spec:
|
|||||||
# in minutes. Memory: feedback_pi5_nomic_embed_slow.
|
# in minutes. Memory: feedback_pi5_nomic_embed_slow.
|
||||||
- name: IntranetSearch__OllamaBaseUrl
|
- name: IntranetSearch__OllamaBaseUrl
|
||||||
value: "http://10.0.56.20:11434"
|
value: "http://10.0.56.20:11434"
|
||||||
|
# Sprint E Phase 2α — JSON-file-backed PageReadingOverride persistence
|
||||||
|
# on the writable PVC at /data. Without this env var the
|
||||||
|
# intranet falls back to the in-memory store (loses state on
|
||||||
|
# pod restart). Master's PageReadingOverrideOptions binds
|
||||||
|
# PageReadingOverrides:FilePath.
|
||||||
|
- name: PageReadingOverrides__FilePath
|
||||||
|
value: "/data/page-reading-overrides.json"
|
||||||
|
- name: KnowledgeFleetSearch__BaseUrl
|
||||||
|
value: "https://knowledge.iamworkin.lan"
|
||||||
|
- name: KnowledgeFleetSearch__ApiKey
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: intranet-config
|
||||||
|
key: KnowledgeApiKey
|
||||||
|
optional: true
|
||||||
|
- name: TrustedHeaderAuthentication__SharedSecret
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: intranet-config
|
||||||
|
key: TrustedHeaderSharedSecret
|
||||||
|
optional: true
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "256Mi"
|
memory: "256Mi"
|
||||||
|
|||||||
165
apps/knowledge/README.md
Normal file
165
apps/knowledge/README.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# knowledge — FlowerCore.Knowledge.Web (Phase 2.4 K8s deploy)
|
||||||
|
|
||||||
|
**Status:** **LIVE 2026-04-27** at `https://knowledge.iamworkin.lan` —
|
||||||
|
Phase 2.4 closed. Pod running, certificate issued (step-ca-acme), PVC
|
||||||
|
bound (Longhorn 20Gi RWO), ArgoCD `infra-knowledge` synced. `/healthz`
|
||||||
|
returns 200, `/api/v1/editions` returns `[]` (initial-deploy state — no
|
||||||
|
*.db files in the PVC yet; Phase 2.5+ admin UI handles bulk
|
||||||
|
population). Phase 1 of the Agent Zero MCP rollout keeps `/healthz`
|
||||||
|
anonymous and gates `/mcp` behind `Authorization: Bearer <token>` built
|
||||||
|
from the 1Password item `FlowerCore Knowledge MCP Tokens`.
|
||||||
|
|
||||||
|
- Plan: [`../../../FlowerCore.Notes/docs/ai-agents/flowercore-knowledge-service-plan.md`](../../../FlowerCore.Notes/docs/ai-agents/flowercore-knowledge-service-plan.md)
|
||||||
|
- Sprint: [`../../../FlowerCore.Notes/docs/ai-station/sprint-e-xxl-plan.md`](../../../FlowerCore.Notes/docs/ai-station/sprint-e-xxl-plan.md) (Track B)
|
||||||
|
- Repo: `D:\git\FlowerCore\FlowerCore.Knowledge\` (private GitHub repo,
|
||||||
|
bootstrapped Sprint D batch 35)
|
||||||
|
|
||||||
|
`FlowerCore.Knowledge.Web` is the fleet-wide vector-indexing & RAG hub —
|
||||||
|
a REST + MCP service that scans `*.db` files under
|
||||||
|
`/data/vector-stores` and exposes per-edition reachability + corpus
|
||||||
|
search to the rest of the FC ecosystem (Agent Zero, Chat.Web persona
|
||||||
|
memory, AiStation embeddings explorer, TtsReader chapter context, BMO
|
||||||
|
bot, Pi nodes via `fc-index sync`).
|
||||||
|
|
||||||
|
Phase 1 MCP routing is explicit:
|
||||||
|
|
||||||
|
- in-cluster Agent Zero → `http://knowledge-web.knowledge.svc/mcp`
|
||||||
|
- workstation Agent Zero → `https://knowledge.iamworkin.lan/mcp`
|
||||||
|
- probe URL for both lanes → `/healthz`
|
||||||
|
|
||||||
|
## Deployment order (do NOT skip / reorder)
|
||||||
|
|
||||||
|
### 1. FlowerCore.DNS public A record — knowledge.iamworkin.lan -> 10.0.56.200
|
||||||
|
|
||||||
|
Required BEFORE the Certificate resource is created, or cert-manager
|
||||||
|
HTTP-01 silently backs off ~2h. Memory: `feedback_pfsense_dns_required_for_acme`.
|
||||||
|
|
||||||
|
The canonical path is FlowerCore.DNS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk https://dns.iamworkin.lan/api/v1/servers
|
||||||
|
# Find the pfSense serverId, then create the record using the host label only.
|
||||||
|
|
||||||
|
curl -sk -X POST https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iamworkin.lan/records \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"knowledge","type":"A","data":"10.0.56.200","ttl":300}'
|
||||||
|
```
|
||||||
|
|
||||||
|
If FlowerCore.DNS provider writes are failing 502 with "pfSense
|
||||||
|
diag_command.php response did not contain a `<pre>` block" (status as of
|
||||||
|
Sprint E Track B authoring 2026-04-27), add the override manually via
|
||||||
|
the pfSense web UI:
|
||||||
|
|
||||||
|
1. Log in to `https://10.0.56.1` as admin
|
||||||
|
2. Services → DNS Resolver → General Settings → Host Overrides
|
||||||
|
3. Add: Host=`knowledge`, Domain=`iamworkin.lan`, IP Address=`10.0.56.200`
|
||||||
|
4. Save + Apply Changes
|
||||||
|
|
||||||
|
Verify resolution from anywhere on LAN:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nslookup knowledge.iamworkin.lan 10.0.56.1
|
||||||
|
# Expect: 10.0.56.200
|
||||||
|
```
|
||||||
|
|
||||||
|
Or against FlowerCore.DNS once the provider is fixed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sk "https://dns.iamworkin.lan/api/v1/zones/iamworkin.lan/resolve-preflight?hostname=knowledge.iamworkin.lan"
|
||||||
|
# Expect: "resolvable": true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build + import the image to ALL RKE2 nodes
|
||||||
|
|
||||||
|
Pods may schedule on any RKE2 worker (server, agent1, agent2). The
|
||||||
|
Longhorn PVC accepts mounts from any node, so the image must be
|
||||||
|
imported to all three. Memory:
|
||||||
|
`feedback_rke2_image_import_targets_all_nodes` +
|
||||||
|
`feedback_rke2_localhost_imagepullpolicy`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From BLUEJAY-WS, in D:\git\FlowerCore\FlowerCore.Knowledge
|
||||||
|
TAG="v$(date +%Y%m%d%H%M)"
|
||||||
|
dotnet.exe publish -c Release -o deploy/app \
|
||||||
|
src/FlowerCore.Knowledge.Web/FlowerCore.Knowledge.Web.csproj
|
||||||
|
podman build -t localhost/fc-knowledge-web:$TAG -f deploy/Dockerfile.deploy deploy
|
||||||
|
podman save localhost/fc-knowledge-web:$TAG -o /tmp/fc-knowledge-web.tar
|
||||||
|
|
||||||
|
# Import to all three RKE2 nodes
|
||||||
|
for node in rke2-server rke2-agent1 rke2-agent2; do
|
||||||
|
scp /tmp/fc-knowledge-web.tar $node:/tmp/
|
||||||
|
ssh $node "sudo /var/lib/rancher/rke2/bin/ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import /tmp/fc-knowledge-web.tar"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
The repo's `scripts/deploy-knowledge.sh` automates this loop.
|
||||||
|
|
||||||
|
### 3. Bump the image tag + push
|
||||||
|
|
||||||
|
Edit `knowledge.yaml`, replace `localhost/fc-knowledge-web:v202604272200`
|
||||||
|
with the tag from step 2, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd D:/git/FlowerCore/bluejay-infra
|
||||||
|
python scripts/check-pfsense-dns.py # confirms the DNS preflight
|
||||||
|
git add apps/knowledge/
|
||||||
|
git commit -m "feat(knowledge): deploy Phase 2.4 K8s manifest"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
ArgoCD picks up within ~3 minutes and creates `infra-knowledge`.
|
||||||
|
|
||||||
|
### 4. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fcadmin_ssh noc1 '
|
||||||
|
kubectl -n argocd get application infra-knowledge
|
||||||
|
kubectl -n knowledge get certificate,pod,pvc
|
||||||
|
curl -sk -m 8 -o /dev/null -w "HTTP %{http_code}\n" https://knowledge.iamworkin.lan/healthz
|
||||||
|
curl -sk -m 8 https://knowledge.iamworkin.lan/api/v1/editions | jq
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect: Certificate `Ready: True` within ~60s, `/healthz` HTTP 200,
|
||||||
|
`/api/v1/editions` returns an empty array (no DBs in the PVC yet) on
|
||||||
|
first deploy.
|
||||||
|
|
||||||
|
## Initial-deploy state and Phase 2.5 follow-up
|
||||||
|
|
||||||
|
The Longhorn PVC is empty on first deploy. Knowledge.Web's filesystem
|
||||||
|
catalog will report zero editions until vector-store `*.db` files are
|
||||||
|
pushed into `/data/vector-stores`. Initial population is a follow-up
|
||||||
|
step (Phase 2.5+, Blazor admin UI's "Rebuild" button); for the first
|
||||||
|
deploy the goal is just to prove the pod boots, `/healthz` returns 200,
|
||||||
|
and the Traefik IngressRoute serves the Scalar UI.
|
||||||
|
|
||||||
|
To copy an existing local DB into the PVC (one-time, manual until
|
||||||
|
Phase 2.5 admin UI lands):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fcadmin_ssh noc1 '
|
||||||
|
POD=$(kubectl -n knowledge get pod -l app=knowledge-web -o jsonpath="{.items[0].metadata.name}")
|
||||||
|
kubectl -n knowledge cp /var/lib/flowercore/vector-stores/bluejay-ai.db $POD:/data/vector-stores/bluejay-ai.db
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Probes + middleware notes
|
||||||
|
|
||||||
|
- `/healthz` is mapped by `Controllers/HealthController.cs` (controller-based
|
||||||
|
attribute route). Cheap — no DB, no dependencies.
|
||||||
|
- Liveness uses `tcpSocket` as a defensive fallback in case future
|
||||||
|
middleware accidentally gates `/healthz` behind auth (memory:
|
||||||
|
`feedback_k8s_probes_behind_auth_middleware`).
|
||||||
|
- `/openapi/v1.json` and `/scalar/v1` are wired by `UseFlowerCoreApi`.
|
||||||
|
Per memory `feedback_k8s_probes_must_not_hit_openapi`, probes must NOT
|
||||||
|
point at OpenAPI documents — the `MapOpenApi` call can be slow during
|
||||||
|
cold startup.
|
||||||
|
|
||||||
|
## Resource sizing
|
||||||
|
|
||||||
|
- 256Mi memory request / 1Gi limit.
|
||||||
|
- 100m CPU request / 1000m limit.
|
||||||
|
- 20Gi Longhorn PVC initial — sufficient for the bluejay-ai 1.94Gi DB +
|
||||||
|
fleet-pi-edge 352Mi + fleet-bmo-bot 141Mi + headroom. Resize via
|
||||||
|
`kubectl -n knowledge edit pvc knowledge-vector-store` if growing
|
||||||
|
past 15Gi.
|
||||||
262
apps/knowledge/knowledge.yaml
Normal file
262
apps/knowledge/knowledge.yaml
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# FlowerCore.Knowledge.Web — fleet vector indexing & RAG hub.
|
||||||
|
#
|
||||||
|
# Phase 2.4 of the Knowledge service plan. REST + MCP service that scans
|
||||||
|
# *.db files under /data/vector-stores and exposes:
|
||||||
|
# - REST: /api/v1/editions, /api/v1/corpus/search, /healthz
|
||||||
|
# - MCP: list_editions, describe_edition, corpus_search
|
||||||
|
# - Static OpenAPI/Scalar via UseFlowerCoreApi
|
||||||
|
#
|
||||||
|
# Architecture:
|
||||||
|
# Plan: FlowerCore.Notes/docs/ai-agents/flowercore-knowledge-service-plan.md
|
||||||
|
# Sprint: FlowerCore.Notes/docs/ai-station/sprint-e-xxl-plan.md (Track B)
|
||||||
|
# Repo: D:\git\FlowerCore\FlowerCore.Knowledge\
|
||||||
|
# Shared: FlowerCore.Common -> FlowerCore.Shared.Indexing (chunkers, vector
|
||||||
|
# stores, edition profiles, ICorpusSearchService facade)
|
||||||
|
#
|
||||||
|
# Deployment order (see apps/knowledge/README.md and the bluejay-infra/README.md
|
||||||
|
# top-level checklist):
|
||||||
|
# 1. FlowerCore.DNS public A record knowledge.iamworkin.lan -> 10.0.56.200
|
||||||
|
# MUST exist BEFORE the Certificate is created, or cert-manager HTTP-01
|
||||||
|
# backs off ~2h. Memory: feedback_pfsense_dns_required_for_acme.
|
||||||
|
# 2. Build + import the image to ALL RKE2 nodes (server + both agents) since
|
||||||
|
# the Pod uses a Longhorn PVC and may schedule anywhere.
|
||||||
|
# Memory: feedback_rke2_localhost_imagepullpolicy.
|
||||||
|
# 3. Bump the image tag in this file, git push.
|
||||||
|
# 4. ArgoCD ApplicationSet picks up within ~3 minutes and creates
|
||||||
|
# infra-knowledge.
|
||||||
|
#
|
||||||
|
# Initial-deploy state:
|
||||||
|
# The Longhorn PVC is empty on first deploy. Knowledge.Web's filesystem
|
||||||
|
# catalog will report zero editions until vector-store *.db files are
|
||||||
|
# pushed into /data/vector-stores. Initial population is a follow-up step
|
||||||
|
# (Phase 2.5+, Blazor admin UI's "Rebuild" button); for the first deploy
|
||||||
|
# the goal is just to prove the pod boots, /healthz returns 200, and the
|
||||||
|
# Traefik IngressRoute serves the Scalar UI.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: knowledge
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: bluejay-infra
|
||||||
|
---
|
||||||
|
# MCP bearer token for the read-only Agent Zero Phase 1 lane. The 1Password
|
||||||
|
# item currently stores the raw token in its concealed PASSWORD field, which
|
||||||
|
# the operator syncs into the namespaced Secret key `password`.
|
||||||
|
apiVersion: onepassword.com/v1
|
||||||
|
kind: OnePasswordItem
|
||||||
|
metadata:
|
||||||
|
name: knowledge-mcp-tokens
|
||||||
|
namespace: knowledge
|
||||||
|
spec:
|
||||||
|
itemPath: "vaults/IAmWorkin/items/FlowerCore Knowledge MCP Tokens"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: knowledge-vector-store
|
||||||
|
namespace: knowledge
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: longhorn
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: knowledge-web
|
||||||
|
namespace: knowledge
|
||||||
|
labels:
|
||||||
|
app: knowledge-web
|
||||||
|
app.kubernetes.io/name: knowledge-web
|
||||||
|
app.kubernetes.io/part-of: bluejay-infra
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
revisionHistoryLimit: 3
|
||||||
|
# RWO Longhorn PVC blocks rolling updates (multi-attach error). Recreate
|
||||||
|
# is the canonical pattern (memory: feedback_rwo_pvc_blocks_rolling).
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: knowledge-web
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: knowledge-web
|
||||||
|
app.kubernetes.io/name: knowledge-web
|
||||||
|
app.kubernetes.io/part-of: bluejay-infra
|
||||||
|
annotations:
|
||||||
|
prometheus.io/scrape: "true"
|
||||||
|
prometheus.io/port: "8080"
|
||||||
|
prometheus.io/path: "/metrics"
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
fsGroup: 1654
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
containers:
|
||||||
|
- name: web
|
||||||
|
# Placeholder tag — bump to the image you built + imported to ALL
|
||||||
|
# RKE2 nodes via scripts/deploy-knowledge.sh before applying.
|
||||||
|
image: localhost/fc-knowledge-web:v20260429232635
|
||||||
|
imagePullPolicy: Never
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
args:
|
||||||
|
- |
|
||||||
|
if [ -n "${KNOWLEDGE_MCP_BEARER_TOKEN:-}" ]; then
|
||||||
|
export FlowerCore__Mcp__ApiKey__Key="Bearer ${KNOWLEDGE_MCP_BEARER_TOKEN}"
|
||||||
|
fi
|
||||||
|
exec dotnet FlowerCore.Knowledge.Web.dll
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: ASPNETCORE_URLS
|
||||||
|
value: "http://+:8080"
|
||||||
|
- name: ASPNETCORE_ENVIRONMENT
|
||||||
|
value: "Production"
|
||||||
|
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
|
||||||
|
value: "false"
|
||||||
|
# Vector-store directory + embedding model + edition profile dir.
|
||||||
|
# Profile JSON is baked into the image at /home/app/editions via the
|
||||||
|
# csproj Content-link from FlowerCore.Common/editions/.
|
||||||
|
- name: Knowledge__VectorStoresDirectory
|
||||||
|
value: "/data/vector-stores"
|
||||||
|
- name: Knowledge__EmbeddingModel
|
||||||
|
value: "nomic-embed-text"
|
||||||
|
- name: Knowledge__DefaultLimit
|
||||||
|
value: "5"
|
||||||
|
- name: Knowledge__MaxLimit
|
||||||
|
value: "50"
|
||||||
|
- name: FlowerCore__Editions__ProfileDirectory
|
||||||
|
value: "/home/app/editions"
|
||||||
|
# Embed via edge1 Pi 5 + AI HAT+ (10.0.57.17:11434). Cluster
|
||||||
|
# services do not depend on BLUEJAY-WS (private dev hardware) per
|
||||||
|
# bluejay-infra@0f9d56e. Query-time embedding is fast enough on
|
||||||
|
# edge1 (~ms per query); bulk index rebuilds (Phase 2.5+) will
|
||||||
|
# need a separate ingestion lane that can opt into the
|
||||||
|
# workstation GPU when present.
|
||||||
|
- name: FlowerCore__Ollama__BaseUrl
|
||||||
|
value: "http://10.0.57.17:11434"
|
||||||
|
- name: FlowerCore__Mcp__ApiKey__Key
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: knowledge-mcp-tokens
|
||||||
|
key: password
|
||||||
|
- name: FlowerCore__Mcp__ApiKey__HeaderName
|
||||||
|
value: "Authorization"
|
||||||
|
- name: KNOWLEDGE_MCP_BEARER_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: knowledge-mcp-tokens
|
||||||
|
key: password
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
|
# /healthz is mapped by HealthController (controller-based route).
|
||||||
|
# tcpSocket liveness is the defensive fallback in case middleware
|
||||||
|
# later gates /healthz behind auth (memory:
|
||||||
|
# feedback_k8s_probes_behind_auth_middleware).
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: 8080
|
||||||
|
periodSeconds: 10
|
||||||
|
failureThreshold: 3
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 30
|
||||||
|
failureThreshold: 3
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1654
|
||||||
|
runAsGroup: 1654
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
volumeMounts:
|
||||||
|
- name: vector-store
|
||||||
|
mountPath: /data/vector-stores
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
- name: logs
|
||||||
|
mountPath: /home/app/logs
|
||||||
|
volumes:
|
||||||
|
- name: vector-store
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: knowledge-vector-store
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
|
- name: logs
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: knowledge-web
|
||||||
|
namespace: knowledge
|
||||||
|
labels:
|
||||||
|
app: knowledge-web
|
||||||
|
app.kubernetes.io/name: knowledge-web
|
||||||
|
app.kubernetes.io/part-of: bluejay-infra
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app: knowledge-web
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 8080
|
||||||
|
---
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: knowledge-tls
|
||||||
|
namespace: knowledge
|
||||||
|
spec:
|
||||||
|
secretName: knowledge-tls
|
||||||
|
issuerRef:
|
||||||
|
name: step-ca-acme
|
||||||
|
kind: ClusterIssuer
|
||||||
|
dnsNames:
|
||||||
|
- knowledge.iamworkin.lan
|
||||||
|
duration: 2160h # 90d
|
||||||
|
renewBefore: 720h # 30d
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: knowledge
|
||||||
|
namespace: knowledge
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
routes:
|
||||||
|
- match: Host(`knowledge.iamworkin.lan`)
|
||||||
|
kind: Rule
|
||||||
|
services:
|
||||||
|
- name: knowledge-web
|
||||||
|
port: 80
|
||||||
|
tls:
|
||||||
|
secretName: knowledge-tls
|
||||||
7
apps/knowledge/kustomization.yaml
Normal file
7
apps/knowledge/kustomization.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# ArgoCD's bluejay-infra ApplicationSet uses a directory generator and does
|
||||||
|
# not require kustomization.yaml. Mirrors the fc-distribution shape so
|
||||||
|
# `kubectl kustomize` previews work from a working copy.
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- knowledge.yaml
|
||||||
@@ -219,6 +219,65 @@ spec:
|
|||||||
tls:
|
tls:
|
||||||
secretName: cockpit-tls
|
secretName: cockpit-tls
|
||||||
---
|
---
|
||||||
|
# ============================================================
|
||||||
|
# PuppetDB Dashboard - noc1:8080 (HTTP, web UI only)
|
||||||
|
# Agent-to-PuppetDB mTLS still uses port 8081 directly via Puppet CA
|
||||||
|
# (NOT via this proxy). See docs/infrastructure/cert-recovery-2026-04-28.md
|
||||||
|
# ============================================================
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: puppetdb-external
|
||||||
|
namespace: noc-proxy
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 8080
|
||||||
|
targetPort: 8080
|
||||||
|
name: http
|
||||||
|
clusterIP: None
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Endpoints
|
||||||
|
metadata:
|
||||||
|
name: puppetdb-external
|
||||||
|
namespace: noc-proxy
|
||||||
|
subsets:
|
||||||
|
- addresses:
|
||||||
|
- ip: 10.0.56.10
|
||||||
|
ports:
|
||||||
|
- port: 8080
|
||||||
|
name: http
|
||||||
|
---
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: puppetdb-tls
|
||||||
|
namespace: noc-proxy
|
||||||
|
spec:
|
||||||
|
secretName: puppetdb-tls
|
||||||
|
issuerRef:
|
||||||
|
name: step-ca-acme
|
||||||
|
kind: ClusterIssuer
|
||||||
|
dnsNames:
|
||||||
|
- puppetdb.iamworkin.lan
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: puppetdb
|
||||||
|
namespace: noc-proxy
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
routes:
|
||||||
|
- kind: Rule
|
||||||
|
match: Host(`puppetdb.iamworkin.lan`)
|
||||||
|
services:
|
||||||
|
- name: puppetdb-external
|
||||||
|
port: 8080
|
||||||
|
tls:
|
||||||
|
secretName: puppetdb-tls
|
||||||
|
---
|
||||||
# NetworkPolicy: allow Traefik ingress, allow egress to noc1
|
# NetworkPolicy: allow Traefik ingress, allow egress to noc1
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: NetworkPolicy
|
kind: NetworkPolicy
|
||||||
@@ -242,6 +301,8 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- port: 3000
|
- port: 3000
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
|
- port: 8080
|
||||||
|
protocol: TCP
|
||||||
- port: 9090
|
- port: 9090
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
- port: 9091
|
- port: 9091
|
||||||
|
|||||||
Reference in New Issue
Block a user