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:
parent
5f5cb99db6
commit
c81fa3a93a
14 changed files with 2460 additions and 13 deletions
|
|
@ -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
1
.gitignore
vendored
|
|
@ -34,3 +34,4 @@ frontend/dist/
|
|||
# OS
|
||||
.DS_Store
|
||||
*.swp
|
||||
.dev-pids/
|
||||
|
|
|
|||
|
|
@ -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
248
app/api/endpoints/plex.py
Normal 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
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
0
app/services/plex/__init__.py
Normal file
0
app/services/plex/__init__.py
Normal file
157
app/services/plex/client.py
Normal file
157
app/services/plex/client.py
Normal 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 {}
|
||||
|
|
@ -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
1968
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.node.json",
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"include": ["vite.config.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
|
|
|||
50
manage.sh
50
manage.sh
|
|
@ -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
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue