feat: add Plex integration, update knowledge API and config

- Add app/api/endpoints/plex.py with Plex webhook receiver (fail-closed on missing secret)
- Add app/services/plex/ with Plex API client and gardening content filter
- Wire plex router into routes.py at /plex prefix
- Add Plex + cf-video config vars to app/core/config.py and .env.example
- Rename /knowledge/ingest endpoint to /knowledge/glean (pipeline verb alignment)
- Update tests to use /knowledge/glean endpoint
- Add python-multipart to environment.yml for form/webhook body parsing
- Add dev/dev-stop/dev-logs commands to manage.sh (hot-reload without Docker)
- Fix frontend/tsconfig.node.json to extend tsconfig.json (not tsconfig.node.json)
- Add .dev-pids/ to .gitignore
- Add frontend/package-lock.json
This commit is contained in:
pyr0ball 2026-07-11 22:38:37 -07:00
parent 5f5cb99db6
commit c81fa3a93a
14 changed files with 2460 additions and 13 deletions

View file

@ -17,6 +17,13 @@ COORDINATOR_URL=http://localhost:7700
# License key (required for Paid/Premium tier features)
# CF_LICENSE_KEY=CFG-WXWG-XXXX-XXXX-XXXX
# Plex integration — Strahl library → cf-video → knowledge pipeline
# PLEX_URL=http://10.1.10.117:32400
# PLEX_TOKEN=<your-plex-token>
# PLEX_WEBHOOK_SECRET=<random-secret> # append to webhook URL: ?token=<secret> — required, fails closed if unset
# PLEX_GARDENING_SECTIONS=Garden Rescue,Gardener's World
# CF_VIDEO_URL=http://127.0.0.1:8016 # direct to cf-video on Muninn (via SSH tunnel port forward)
# Cloud mode (optional — enables per-user data isolation)
CLOUD_MODE=false
# CLOUD_DATA_ROOT=/devl/waxwing-cloud-data

1
.gitignore vendored
View file

@ -34,3 +34,4 @@ frontend/dist/
# OS
.DS_Store
*.swp
.dev-pids/

View file

@ -26,7 +26,7 @@ def _get_store() -> Store:
s.close()
@router.post("/ingest", response_model=IngestResponse)
@router.post("/glean", response_model=IngestResponse)
async def ingest_knowledge(
body: KnowledgeIngestRequest, store: Store = Depends(_get_store)
):

248
app/api/endpoints/plex.py Normal file
View file

@ -0,0 +1,248 @@
"""Plex webhook receiver — triggers the Strahl → cf-video → knowledge pipeline.
Flow:
1. Plex fires library.new for a gardening show episode on Strahl
2. We filter to configured gardening sections (Garden Rescue, Gardener's World)
3. Background task downloads the file from Plex's local HTTP API
4. File is POSTed to cf-video's /caption/upload endpoint (Muninn via coordinator)
5. Caption events are stored as knowledge_facts (instruction type, one per event)
"""
from __future__ import annotations
import hmac
import logging
import os
import tempfile
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, BackgroundTasks, Form, HTTPException, Query
from app.core.config import settings
from app.db.store import Store
from app.models.schemas.knowledge import KnowledgeIngestRequest, KnowledgeSourceSpec, SegmentKnowledge
from app.services.knowledge.ingest import ingest
from app.services.plex.client import PlexClient, PlexMediaItem, parse_webhook
logger = logging.getLogger(__name__)
router = APIRouter()
def _plex_client() -> PlexClient:
if not settings.PLEX_TOKEN:
raise HTTPException(503, detail="PLEX_TOKEN not configured")
return PlexClient(settings.PLEX_URL, settings.PLEX_TOKEN)
def _is_gardening_show(item: "PlexMediaItem") -> bool:
"""Return True if this media item is from a configured gardening show.
Plex libraries are named "Series" / "Movies" the show titles (Garden Rescue,
Gardener's World) live as grandparentTitle inside the Series section.
We match against show_title (grandparentTitle) or title for movies.
"""
allowed = {s.strip().lower() for s in settings.PLEX_GARDENING_SECTIONS}
candidate = (item.show_title or item.title).strip().lower()
return candidate in allowed
async def _run_pipeline(item: PlexMediaItem) -> None:
"""Download video from Plex, caption it via cf-video, store facts."""
client = _plex_client()
suffix = Path(item.part_key).suffix or ".mkv"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp_path = tmp.name
try:
logger.info("Plex pipeline: downloading %r from %s", item.title, item.part_key)
await client.download_file(item.part_key, tmp_path)
caption_resp = await _caption_upload(tmp_path)
if caption_resp is None:
logger.error("Plex pipeline: caption failed for %r — skipping ingest", item.title)
return
facts = _events_to_facts(caption_resp, item)
if not facts:
logger.info("Plex pipeline: no events extracted from %r", item.title)
return
_glean(item, facts)
except Exception:
logger.exception("Plex pipeline failed for %r", item.title)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
async def _caption_upload(video_path: str) -> dict[str, Any] | None:
"""POST the video file to cf-video's /caption/upload endpoint.
Uses CF_VIDEO_URL directly (point at a pre-allocated cf-video service on Muninn).
"""
if not settings.CF_VIDEO_URL:
logger.error("CF_VIDEO_URL not set — cannot send video to cf-video")
return None
url = f"{settings.CF_VIDEO_URL.rstrip('/')}/caption/upload"
try:
async with httpx.AsyncClient(timeout=600.0) as http:
with open(video_path, "rb") as fh:
resp = await http.post(url, files={"file": fh})
resp.raise_for_status()
return resp.json()
except httpx.HTTPError as exc:
logger.error("cf-video upload failed: %s", exc)
return None
def _events_to_facts(caption_resp: dict[str, Any], item: PlexMediaItem) -> list[dict]:
"""Map VideoEvent list → SegmentKnowledge dicts (instruction type, one per event).
Each event becomes one 'instruction' fact. A richer LLM extraction pass can
replace or supplement this later by reclassifying facts by type.
"""
events: list[dict] = caption_resp.get("events", [])
facts = []
for ev in events:
desc = (ev.get("description") or "").strip()
if not desc:
continue
facts.append({
"fact_type": "instruction",
"confidence": 0.7,
"payload": {
"action": desc,
"subject": item.show_title or item.title,
"timestamp_start_ms": ev.get("start"),
"timestamp_end_ms": ev.get("end"),
"source_episode": item.title,
},
})
# Also store the overall scene description as a single instruction if no events.
if not facts and caption_resp.get("caption"):
facts.append({
"fact_type": "instruction",
"confidence": 0.5,
"payload": {
"action": caption_resp["caption"],
"subject": item.show_title or item.title,
"source_episode": item.title,
},
})
return facts
def _glean(item: PlexMediaItem, facts: list[dict]) -> None:
"""Write extracted facts directly to the knowledge store (same process, no HTTP round-trip)."""
source = KnowledgeSourceSpec(
video_path=item.part_key,
presenter=None,
location=None,
source_domain="gardening",
)
knowledge_facts = [
SegmentKnowledge(
fact_type=f["fact_type"],
confidence=f.get("confidence", 1.0),
payload=f["payload"],
)
for f in facts
]
request = KnowledgeIngestRequest(source=source, facts=knowledge_facts)
store = Store(settings.DB_PATH)
try:
result = ingest(store.conn, request)
logger.info(
"Plex pipeline: gleaned %d facts (%d inserted, %d dupes) for %r",
result.facts_received,
result.facts_inserted,
result.facts_skipped_duplicate,
item.title,
)
finally:
store.close()
# ── Endpoints ──────────────────────────────────────────────────────────────────
def _verify_webhook_secret(token: str | None) -> None:
"""Reject requests that don't carry the configured PLEX_WEBHOOK_SECRET.
Uses hmac.compare_digest to prevent timing-based secret enumeration.
If PLEX_WEBHOOK_SECRET is not set in the environment the endpoint is
disabled entirely misconfiguration should fail closed, not open.
"""
expected = settings.PLEX_WEBHOOK_SECRET
if not expected:
raise HTTPException(503, detail="PLEX_WEBHOOK_SECRET not configured")
if not token or not hmac.compare_digest(token, expected):
raise HTTPException(401, detail="Invalid webhook token")
@router.post("/webhook")
async def plex_webhook(
background_tasks: BackgroundTasks,
payload: str = Form(...),
token: str | None = Query(None, alias="token"),
) -> dict[str, str]:
"""Receive Plex webhook (multipart/form-data with 'payload' JSON field).
Requires ?token=<PLEX_WEBHOOK_SECRET> in the URL configure this in Plex's
Webhooks settings: http://<host>:8522/api/v1/plex/webhook?token=<secret>
Plex fires this for library.new, media.play, and many other events.
We ack immediately and dispatch the pipeline as a background task.
"""
_verify_webhook_secret(token)
item = parse_webhook(payload)
if item is None:
# Not an event we care about — Plex fires many events, this is normal.
return {"status": "ignored"}
if not _is_gardening_show(item):
logger.debug(
"Plex webhook: skipping %r (show %r not in PLEX_GARDENING_SECTIONS)",
item.title, item.show_title,
)
return {"status": "ignored", "show": item.show_title or item.title}
logger.info(
"Plex webhook: queuing pipeline for %r (show=%r, event=%r)",
item.title, item.show_title, item.event,
)
background_tasks.add_task(_run_pipeline, item)
return {"status": "queued", "title": item.title, "show": item.show_title or item.title}
@router.get("/sections")
async def list_plex_sections() -> list[dict[str, Any]]:
"""List all Plex library sections on Strahl.
Note: PLEX_GARDENING_SECTIONS matches show titles (grandparentTitle), not
section names Strahl uses generic section names like "Series" / "Movies".
"""
client = _plex_client()
try:
sections = await client.list_sections()
allowed = {s.strip().lower() for s in settings.PLEX_GARDENING_SECTIONS}
return [
{
"key": s.get("key"),
"title": s.get("title"),
"type": s.get("type"),
"note": "filter is by show title, not section name",
"configured_shows": list(allowed),
}
for s in sections
]
except httpx.HTTPError as exc:
raise HTTPException(502, detail=f"Plex unreachable: {exc}") from exc

View file

@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.endpoints import health, plants, locations, grow_events, knowledge
from app.api.endpoints import health, plants, locations, grow_events, knowledge, plex
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
@ -8,3 +8,4 @@ api_router.include_router(plants.router, prefix="/plants", tags=["plants"])
api_router.include_router(locations.router, prefix="/locations", tags=["locations"])
api_router.include_router(grow_events.router, prefix="/grow-events", tags=["grow-events"])
api_router.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"])
api_router.include_router(plex.router, prefix="/plex", tags=["plex"])

View file

@ -26,6 +26,22 @@ class Settings:
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
# Plex integration (Strahl → cf-video → knowledge pipeline)
PLEX_URL: str = os.environ.get("PLEX_URL", "http://10.1.10.117:32400")
PLEX_TOKEN: str | None = os.environ.get("PLEX_TOKEN")
# Shared secret appended to the Plex webhook URL as ?token=<secret>
# Required — endpoint returns 503 if unset (fail closed)
PLEX_WEBHOOK_SECRET: str | None = os.environ.get("PLEX_WEBHOOK_SECRET")
# Comma-separated show titles (grandparentTitle in Plex webhooks) — matched case-insensitively.
# Strahl uses generic section names ("Series", "Movies"); individual show titles are used here.
PLEX_GARDENING_SECTIONS: list[str] = [
s.strip()
for s in os.environ.get("PLEX_GARDENING_SECTIONS", "Garden Rescue,Gardener's World").split(",")
if s.strip()
]
# Direct URL to a running cf-video service (http://127.0.0.1:8016 on Muninn, or via tunnel)
CF_VIDEO_URL: str | None = os.environ.get("CF_VIDEO_URL")
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "false").lower() in ("1", "true", "yes")
CLOUD_DATA_ROOT: Path = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/waxwing-cloud-data"))

View file

157
app/services/plex/client.py Normal file
View file

@ -0,0 +1,157 @@
"""Plex Media Server HTTP client for Waxwing knowledge ingestion.
Responsibilities:
- Parse Plex webhook payloads (multipart/form-data, single 'payload' field)
- Fetch media file bytes from Plex's local HTTP API
- List library sections so callers can identify gardening content
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
import httpx
# Plex part keys are always /library/parts/<numeric id>/file — nothing else.
# Rejecting anything outside this pattern prevents SSRF via forged webhook payloads.
_PART_KEY_RE = re.compile(r"^/library/parts/\d+/file/?$")
logger = logging.getLogger(__name__)
# Plex sends webhooks for many events; only these matter for knowledge ingestion.
INGEST_EVENTS: frozenset[str] = frozenset({"library.new", "media.play"})
# Library types that may contain gardening video content.
_VIDEO_TYPES: frozenset[str] = frozenset({"show", "movie"})
@dataclass(frozen=True)
class PlexMediaItem:
"""Minimal metadata extracted from a Plex webhook payload."""
event: str
rating_key: str # Plex item ID
part_key: str # path suffix for file download: /library/parts/{id}/file
title: str
show_title: str | None # None for movies
library_section: str
duration_ms: int | None
thumb: str | None # poster path, unused but useful for future UI
def parse_webhook(raw_payload: str) -> PlexMediaItem | None:
"""Parse the JSON string from Plex's multipart 'payload' field.
Returns None for events we don't act on (playback progress, rating, etc.).
"""
try:
data: dict[str, Any] = json.loads(raw_payload)
except json.JSONDecodeError:
logger.warning("Plex webhook: invalid JSON payload")
return None
event = data.get("event", "")
if event not in INGEST_EVENTS:
return None
metadata: dict[str, Any] = data.get("Metadata", {})
media_type = metadata.get("type", "")
if media_type not in _VIDEO_TYPES:
logger.debug("Plex webhook: skipping non-video type %r", media_type)
return None
# Plex nests file parts under Media[0].Part[0]
parts: list[dict] = []
for media in metadata.get("Media", []):
parts.extend(media.get("Part", []))
if not parts:
logger.warning("Plex webhook: no media parts found for %r", metadata.get("title"))
return None
part = parts[0]
part_key = part.get("key", "")
if not part_key:
logger.warning("Plex webhook: part has no key for %r", metadata.get("title"))
return None
return PlexMediaItem(
event=event,
rating_key=str(metadata.get("ratingKey", "")),
part_key=part_key,
title=metadata.get("title", "Unknown"),
show_title=metadata.get("grandparentTitle") or metadata.get("parentTitle"),
library_section=metadata.get("librarySectionTitle", ""),
duration_ms=metadata.get("duration"),
thumb=metadata.get("thumb"),
)
class PlexClient:
"""Thin async client for Plex Media Server's local HTTP API."""
def __init__(self, base_url: str, token: str) -> None:
# base_url: e.g. "http://10.1.10.117:32400"
self._base = base_url.rstrip("/")
self._token = token
self._headers = {
"X-Plex-Token": token,
"Accept": "application/json",
}
async def list_sections(self) -> list[dict[str, Any]]:
"""Return all library sections (useful for discovering gardening libraries)."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{self._base}/library/sections",
headers=self._headers,
)
resp.raise_for_status()
data = resp.json()
return data.get("MediaContainer", {}).get("Directory", [])
async def download_file(self, part_key: str, dest_path: str) -> int:
"""Stream a media file from Plex to dest_path on the local filesystem.
part_key comes from PlexMediaItem.part_key (e.g. /library/parts/12345/file).
Returns the number of bytes written.
Raises ValueError if part_key does not match the expected Plex path format
this guards against SSRF via a forged webhook payload supplying an arbitrary URL.
"""
if not _PART_KEY_RE.match(part_key):
raise ValueError(f"Rejected unsafe part_key: {part_key!r}")
url = f"{self._base}{part_key}"
# Sanity-check: constructed URL must stay on the configured Plex host.
if urlparse(url).hostname != urlparse(self._base).hostname:
raise ValueError(f"part_key would redirect off Plex host: {url!r}")
params = {"X-Plex-Token": self._token}
written = 0
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("GET", url, params=params) as resp:
resp.raise_for_status()
with open(dest_path, "wb") as fh:
async for chunk in resp.aiter_bytes(chunk_size=1 << 20): # 1 MB
fh.write(chunk)
written += len(chunk)
logger.info("Plex download: %d bytes → %s", written, dest_path)
return written
async def get_item_metadata(self, rating_key: str) -> dict[str, Any]:
"""Fetch full metadata for a single library item."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{self._base}/library/metadata/{rating_key}",
headers=self._headers,
)
resp.raise_for_status()
data = resp.json()
items = data.get("MediaContainer", {}).get("Metadata", [])
return items[0] if items else {}

View file

@ -10,6 +10,7 @@ dependencies:
- uvicorn[standard]>=0.27
- pydantic>=2.5
- httpx>=0.27
- python-multipart>=0.0.9
- pyyaml>=6.0
- pytest>=8.0
- pytest-asyncio>=0.23

1968
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"extends": "@vue/tsconfig/tsconfig.json",
"include": ["vite.config.ts"],
"compilerOptions": {
"composite": true,

View file

@ -9,9 +9,16 @@ COMPOSE_FILE="compose.yml"
OVERRIDE_FLAG=""
[[ -f "compose.override.yml" ]] && OVERRIDE_FLAG="-f compose.override.yml"
DEV_DIR=".dev-pids"
DEV_API_PID="$DEV_DIR/api.pid"
DEV_WEB_PID="$DEV_DIR/web.pid"
DEV_API_LOG="$DEV_DIR/api.log"
DEV_WEB_LOG="$DEV_DIR/web.log"
usage() {
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test|update}"
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test|update|dev|dev-stop|dev-logs}"
echo ""
echo "Docker commands:"
echo " start Build (if needed) and start all services"
echo " stop Stop and remove containers"
echo " restart Stop then start"
@ -21,6 +28,11 @@ usage() {
echo " build Rebuild Docker images without cache"
echo " test Run pytest test suite"
echo " update git pull + rebuild + restart"
echo ""
echo "Dev server commands (no Docker):"
echo " dev Start FastAPI + Vite dev server (hot reload)"
echo " dev-stop Stop dev servers"
echo " dev-logs Tail dev server logs"
exit 1
}
@ -63,6 +75,42 @@ case "$cmd" in
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
echo "Waxwing updated → http://localhost:${WEB_PORT}"
;;
dev)
mkdir -p "$DEV_DIR"
if [[ -f "$DEV_API_PID" ]] && kill -0 "$(cat "$DEV_API_PID")" 2>/dev/null; then
echo "Dev API already running (PID $(cat "$DEV_API_PID"))"
else
conda run --no-capture-output -n cf uvicorn app.main:app \
--host 0.0.0.0 --port "$API_PORT" --reload \
> "$DEV_API_LOG" 2>&1 &
echo $! > "$DEV_API_PID"
echo "API started (PID $!) → http://localhost:${API_PORT}"
fi
if [[ -f "$DEV_WEB_PID" ]] && kill -0 "$(cat "$DEV_WEB_PID")" 2>/dev/null; then
echo "Dev web already running (PID $(cat "$DEV_WEB_PID"))"
else
(cd frontend && npm run dev -- --host 0.0.0.0 --port "$WEB_PORT" \
> "../$DEV_WEB_LOG" 2>&1) &
echo $! > "$DEV_WEB_PID"
echo "Web started (PID $!) → http://localhost:${WEB_PORT}"
fi
echo "Logs: $DEV_API_LOG $DEV_WEB_LOG"
;;
dev-stop)
for pidfile in "$DEV_API_PID" "$DEV_WEB_PID"; do
if [[ -f "$pidfile" ]]; then
pid=$(cat "$pidfile")
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" && echo "Stopped PID $pid"
fi
rm -f "$pidfile"
fi
done
echo "Dev servers stopped."
;;
dev-logs)
tail -f "$DEV_API_LOG" "$DEV_WEB_LOG"
;;
*)
usage
;;

View file

@ -74,7 +74,7 @@ _ALL_FACT_TYPES = [
def test_ingest_all_eight_fact_types(client):
facts = [{"fact_type": ft, "confidence": 0.9, "payload": p} for ft, p in _ALL_FACT_TYPES]
resp = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts})
resp = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts})
assert resp.status_code == 200
data = resp.json()
assert data["facts_received"] == 8
@ -89,10 +89,10 @@ def test_ingest_idempotent(client):
}}]
payload = {"source": _SOURCE, "facts": facts}
first = client.post("/api/v1/knowledge/ingest", json=payload).json()
first = client.post("/api/v1/knowledge/glean", json=payload).json()
assert first["facts_inserted"] == 1
second = client.post("/api/v1/knowledge/ingest", json=payload).json()
second = client.post("/api/v1/knowledge/glean", json=payload).json()
assert second["facts_inserted"] == 0
assert second["facts_skipped_duplicate"] == 1
@ -107,7 +107,7 @@ def test_ingest_partial_batch_continues_on_bad_fact(client):
"plant": "Lavender", "use": "calming", "preparation": "tea", "cautions": None,
}},
]
resp = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts})
resp = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts})
assert resp.status_code == 422
@ -115,7 +115,7 @@ def test_ingest_valid_and_empty_payload(client):
facts = [{"fact_type": "history_context", "confidence": 0.6, "payload": {
"subject": "Wisteria", "origin": "China", "period": "Tang Dynasty", "summary": "Introduced to Europe in 1816.",
}}]
resp = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts})
resp = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts})
assert resp.status_code == 200
assert resp.json()["facts_inserted"] == 1
@ -124,8 +124,8 @@ def test_ingest_source_is_upserted_across_calls(client):
facts = [{"fact_type": "instruction", "confidence": 0.8, "payload": {
"action": "dig", "subject": "bed", "steps": [], "timing": "autumn",
}}]
r1 = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts}).json()
r2 = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts}).json()
r1 = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts}).json()
r2 = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts}).json()
assert r1["source_id"] == r2["source_id"]
@ -134,7 +134,7 @@ def test_ingest_subject_extracted_for_companion_planting(client):
"plants": ["basil", "tomato"], "relationship": "beneficial",
"benefit": "repels aphids", "spacing": None, "notes": None,
}}]
resp = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts})
resp = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts})
assert resp.status_code == 200
query = client.get("/api/v1/knowledge?fact_type=companion_planting")

View file

@ -15,7 +15,7 @@ _SOURCE_B = {
def _ingest(client, source, facts):
return client.post("/api/v1/knowledge/ingest", json={"source": source, "facts": facts}).json()
return client.post("/api/v1/knowledge/glean", json={"source": source, "facts": facts}).json()
def test_query_all_returns_inserted_facts(client):