diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f56a174 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Waxwing API configuration — copy to .env and fill in values +# .env is gitignored; this file is committed as a template + +API_PREFIX=/api/v1 +DEBUG=false + +# Data storage +DATA_DIR=./data +DB_PATH=./data/waxwing.db + +# CORS — space or comma-separated origins +CORS_ORIGINS=http://localhost:8521,http://localhost:5173 + +# cf-orch coordinator (optional — leave blank for local-only use) +COORDINATOR_URL=http://localhost:7700 + +# License key (required for Paid/Premium tier features) +# CF_LICENSE_KEY=CFG-WXWG-XXXX-XXXX-XXXX + +# Cloud mode (optional — enables per-user data isolation) +CLOUD_MODE=false +# CLOUD_DATA_ROOT=/devl/waxwing-cloud-data diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b7b0dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Secrets and local config +.env +CLAUDE.md + +# Data and databases +data/ +*.db +*.db-shm +*.db-wal + +# Python artifacts +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.coverage +htmlcov/ + +# Pytest mock artifacts +=0.110" "uvicorn[standard]>=0.27" "pydantic>=2.5" "httpx>=0.27" "pyyaml>=6.0" + +COPY waxwing/ ./waxwing/ + +RUN rm -f /app/waxwing/.env /app/waxwing/CLAUDE.md + +RUN conda run -n cf pip install --no-cache-dir -e /app/circuitforge-core +WORKDIR /app/waxwing +RUN conda run -n cf pip install --no-cache-dir -e . + +EXPOSE 8522 +CMD ["conda", "run", "--no-capture-output", "-n", "cf", \ + "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8522"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/endpoints/__init__.py b/app/api/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/endpoints/grow_events.py b/app/api/endpoints/grow_events.py new file mode 100644 index 0000000..aa12597 --- /dev/null +++ b/app/api/endpoints/grow_events.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.core.config import settings +from app.db.store import Store +from app.models.schemas.grow_event import GrowEventCreate, GrowEventOut + +router = APIRouter() + + +def _get_store() -> Store: + s = Store(settings.DB_PATH) + try: + yield s + finally: + s.close() + + +@router.post("", response_model=GrowEventOut, status_code=201) +async def create_grow_event(body: GrowEventCreate, store: Store = Depends(_get_store)): + plant = store.conn.execute( + "SELECT id FROM plants WHERE id = ?", (body.plant_id,) + ).fetchone() + if not plant: + raise HTTPException(status_code=404, detail="Plant not found") + + cur = store.conn.execute( + """INSERT INTO grow_events + (plant_id, event_type, event_date, value_num, value_unit, note, photo_path) + VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *""", + ( + body.plant_id, body.event_type, body.event_date, + body.value_num, body.value_unit, body.note, body.photo_path, + ), + ) + row = store._row_to_dict(cur.fetchone()) + store.conn.commit() + return row + + +@router.get("", response_model=list[GrowEventOut]) +async def list_grow_events( + plant_id: int = Query(..., description="Plant ID to filter events"), + store: Store = Depends(_get_store), +): + rows = store.conn.execute( + "SELECT * FROM grow_events WHERE plant_id = ? ORDER BY event_date DESC", + (plant_id,), + ).fetchall() + return [store._row_to_dict(r) for r in rows] diff --git a/app/api/endpoints/health.py b/app/api/endpoints/health.py new file mode 100644 index 0000000..9792ba8 --- /dev/null +++ b/app/api/endpoints/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +async def health(): + return {"status": "ok", "service": "waxwing-api", "version": "0.1.0"} diff --git a/app/api/endpoints/knowledge.py b/app/api/endpoints/knowledge.py new file mode 100644 index 0000000..ef9e2be --- /dev/null +++ b/app/api/endpoints/knowledge.py @@ -0,0 +1,100 @@ +"""cf-harvest knowledge ingest + query endpoints.""" +from __future__ import annotations + +import json +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.core.config import settings +from app.db.store import Store +from app.models.schemas.knowledge import ( + IngestResponse, + KnowledgeFactOut, + KnowledgeIngestRequest, +) +from app.services.knowledge.ingest import ingest + +router = APIRouter() + + +def _get_store() -> Store: + s = Store(settings.DB_PATH) + try: + yield s + finally: + s.close() + + +@router.post("/ingest", response_model=IngestResponse) +async def ingest_knowledge( + body: KnowledgeIngestRequest, store: Store = Depends(_get_store) +): + return ingest(store.conn, body) + + +@router.get("", response_model=list[KnowledgeFactOut]) +async def query_knowledge( + fact_type: Optional[str] = Query(None), + subject: Optional[str] = Query(None), + presenter: Optional[str] = Query(None), + min_confidence: Optional[float] = Query(None, ge=0.0, le=1.0), + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), + store: Store = Depends(_get_store), +): + conditions = [] + params: list = [] + + if fact_type: + conditions.append("kf.fact_type = ?") + params.append(fact_type) + if subject: + conditions.append("kf.subject LIKE ?") + params.append(f"%{subject}%") + if presenter: + conditions.append("kf.presenter LIKE ?") + params.append(f"%{presenter}%") + if min_confidence is not None: + conditions.append("kf.confidence >= ?") + params.append(min_confidence) + + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + params += [limit, offset] + + rows = store.conn.execute( + f""" + SELECT kf.*, ks.video_path, ks.location + FROM knowledge_facts kf + LEFT JOIN knowledge_sources ks ON ks.id = kf.source_id + {where} + ORDER BY kf.created_at DESC + LIMIT ? OFFSET ? + """, + params, + ).fetchall() + + result = [] + for row in rows: + d = store._row_to_dict(row) + d["payload"] = json.loads(d["payload"]) + result.append(d) + return result + + +@router.get("/{fact_id}", response_model=KnowledgeFactOut) +async def get_knowledge_fact(fact_id: int, store: Store = Depends(_get_store)): + row = store.conn.execute( + """ + SELECT kf.*, ks.video_path, ks.location + FROM knowledge_facts kf + LEFT JOIN knowledge_sources ks ON ks.id = kf.source_id + WHERE kf.id = ? + """, + (fact_id,), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Knowledge fact not found") + d = store._row_to_dict(row) + d["payload"] = json.loads(d["payload"]) + return d diff --git a/app/api/endpoints/locations.py b/app/api/endpoints/locations.py new file mode 100644 index 0000000..ee891f9 --- /dev/null +++ b/app/api/endpoints/locations.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from app.core.config import settings +from app.db.store import Store +from app.models.schemas.location import LocationCreate, LocationOut, LocationUpdate + +router = APIRouter() + + +def _get_store() -> Store: + s = Store(settings.DB_PATH) + try: + yield s + finally: + s.close() + + +@router.post("", response_model=LocationOut, status_code=201) +async def create_location(body: LocationCreate, store: Store = Depends(_get_store)): + cur = store.conn.execute( + """INSERT INTO locations (name, kind, size_note, sun_note, notes) + VALUES (?, ?, ?, ?, ?) RETURNING *""", + (body.name, body.kind, body.size_note, body.sun_note, body.notes), + ) + row = store._row_to_dict(cur.fetchone()) + store.conn.commit() + return row + + +@router.get("", response_model=list[LocationOut]) +async def list_locations(store: Store = Depends(_get_store)): + rows = store.conn.execute("SELECT * FROM locations ORDER BY name").fetchall() + return [store._row_to_dict(r) for r in rows] + + +@router.get("/{location_id}", response_model=LocationOut) +async def get_location(location_id: int, store: Store = Depends(_get_store)): + row = store.conn.execute( + "SELECT * FROM locations WHERE id = ?", (location_id,) + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Location not found") + return store._row_to_dict(row) + + +@router.delete("/{location_id}", status_code=204) +async def delete_location(location_id: int, store: Store = Depends(_get_store)): + store.conn.execute("DELETE FROM locations WHERE id = ?", (location_id,)) + store.conn.commit() diff --git a/app/api/endpoints/plants.py b/app/api/endpoints/plants.py new file mode 100644 index 0000000..152b5a7 --- /dev/null +++ b/app/api/endpoints/plants.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.core.config import settings +from app.db.store import Store +from app.models.schemas.plant import PlantCreate, PlantOut, PlantUpdate + +router = APIRouter() + + +def _get_store() -> Store: + s = Store(settings.DB_PATH) + try: + yield s + finally: + s.close() + + +@router.post("", response_model=PlantOut, status_code=201) +async def create_plant(body: PlantCreate, store: Store = Depends(_get_store)): + cur = store.conn.execute( + """INSERT INTO plants + (common_name, latin_binomial, variety, location_id, planting_date, + rootstock, scion_source, graft_date, days_to_maturity, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *""", + ( + body.common_name, body.latin_binomial, body.variety, body.location_id, + body.planting_date, body.rootstock, body.scion_source, + body.graft_date, body.days_to_maturity, body.notes, + ), + ) + row = store._row_to_dict(cur.fetchone()) + store.conn.commit() + return row + + +@router.get("", response_model=list[PlantOut]) +async def list_plants( + location_id: Optional[int] = Query(None), + store: Store = Depends(_get_store), +): + if location_id is not None: + rows = store.conn.execute( + "SELECT * FROM plants WHERE location_id = ? ORDER BY common_name", + (location_id,), + ).fetchall() + else: + rows = store.conn.execute( + "SELECT * FROM plants ORDER BY common_name" + ).fetchall() + return [store._row_to_dict(r) for r in rows] + + +@router.get("/{plant_id}", response_model=PlantOut) +async def get_plant(plant_id: int, store: Store = Depends(_get_store)): + row = store.conn.execute( + "SELECT * FROM plants WHERE id = ?", (plant_id,) + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Plant not found") + return store._row_to_dict(row) + + +@router.patch("/{plant_id}", response_model=PlantOut) +async def update_plant( + plant_id: int, body: PlantUpdate, store: Store = Depends(_get_store) +): + updates = {k: v for k, v in body.model_dump().items() if v is not None} + if not updates: + row = store.conn.execute( + "SELECT * FROM plants WHERE id = ?", (plant_id,) + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Plant not found") + return store._row_to_dict(row) + + set_clause = ", ".join(f"{k} = ?" for k in updates) + set_clause += ", updated_at = datetime('now')" + values = list(updates.values()) + [plant_id] + + cur = store.conn.execute( + f"UPDATE plants SET {set_clause} WHERE id = ? RETURNING *", values + ) + row = cur.fetchone() + store.conn.commit() + if not row: + raise HTTPException(status_code=404, detail="Plant not found") + return store._row_to_dict(row) + + +@router.delete("/{plant_id}", status_code=204) +async def delete_plant(plant_id: int, store: Store = Depends(_get_store)): + store.conn.execute("DELETE FROM plants WHERE id = ?", (plant_id,)) + store.conn.commit() diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..bf101cf --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from app.api.endpoints import health, plants, locations, grow_events, knowledge + +api_router = APIRouter() +api_router.include_router(health.router, tags=["health"]) +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"]) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..fd645e4 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,37 @@ +"""Waxwing application config — env-driven, no pydantic-settings dependency.""" +from __future__ import annotations + +import os +from pathlib import Path + +from circuitforge_core.config.settings import load_env + +_ROOT = Path(__file__).resolve().parents[2] +load_env(_ROOT / ".env") + + +class Settings: + API_PREFIX: str = os.environ.get("API_PREFIX", "/api/v1") + PROJECT_NAME: str = "Waxwing — Gardening Assistant" + + CORS_ORIGINS: list[str] = [ + o.strip() + for o in os.environ.get("CORS_ORIGINS", "").split(",") + if o.strip() + ] + + DATA_DIR: Path = Path(os.environ.get("DATA_DIR", str(_ROOT / "data"))) + DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "waxwing.db"))) + + COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700") + CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY") + + 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")) + + def ensure_dirs(self) -> None: + self.DATA_DIR.mkdir(parents=True, exist_ok=True) + + +settings = Settings() diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/migrations/001_locations.sql b/app/db/migrations/001_locations.sql new file mode 100644 index 0000000..c72931d --- /dev/null +++ b/app/db/migrations/001_locations.sql @@ -0,0 +1,15 @@ +-- Migration 001: growing locations (beds, containers, in-ground plots) + +CREATE TABLE IF NOT EXISTS locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'bed' + CHECK (kind IN ('bed', 'container', 'ground')), + size_note TEXT, + sun_note TEXT, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_locations_kind ON locations (kind); diff --git a/app/db/migrations/002_plants.sql b/app/db/migrations/002_plants.sql new file mode 100644 index 0000000..dcfdeb8 --- /dev/null +++ b/app/db/migrations/002_plants.sql @@ -0,0 +1,20 @@ +-- Migration 002: plant registry + +CREATE TABLE IF NOT EXISTS plants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + common_name TEXT NOT NULL, + latin_binomial TEXT, + variety TEXT, + location_id INTEGER REFERENCES locations(id) ON DELETE SET NULL, + planting_date TEXT, + rootstock TEXT, + scion_source TEXT, + graft_date TEXT, + days_to_maturity INTEGER, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_plants_common_name ON plants (common_name); +CREATE INDEX IF NOT EXISTS idx_plants_location_id ON plants (location_id); diff --git a/app/db/migrations/003_grow_events.sql b/app/db/migrations/003_grow_events.sql new file mode 100644 index 0000000..a1e3cdd --- /dev/null +++ b/app/db/migrations/003_grow_events.sql @@ -0,0 +1,21 @@ +-- Migration 003: grow event log (backs calendar and harvest history) + +CREATE TABLE IF NOT EXISTS grow_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plant_id INTEGER NOT NULL REFERENCES plants(id) ON DELETE CASCADE, + event_type TEXT NOT NULL + CHECK (event_type IN ( + 'planted', 'watered', 'fed', 'pruned', 'sprayed', + 'observation', 'harvest', 'repotted', 'note' + )), + event_date TEXT NOT NULL, + value_num REAL, + value_unit TEXT, + note TEXT, + photo_path TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_grow_events_plant_id ON grow_events (plant_id); +CREATE INDEX IF NOT EXISTS idx_grow_events_event_date ON grow_events (event_date DESC); +CREATE INDEX IF NOT EXISTS idx_grow_events_type ON grow_events (event_type); diff --git a/app/db/migrations/004_knowledge_sources.sql b/app/db/migrations/004_knowledge_sources.sql new file mode 100644 index 0000000..6455da5 --- /dev/null +++ b/app/db/migrations/004_knowledge_sources.sql @@ -0,0 +1,10 @@ +-- Migration 004: cf-harvest video provenance (one row per source video) + +CREATE TABLE IF NOT EXISTS knowledge_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + video_path TEXT NOT NULL UNIQUE, + presenter TEXT, + location TEXT, + source_domain TEXT, + ingested_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/app/db/migrations/005_knowledge_facts.sql b/app/db/migrations/005_knowledge_facts.sql new file mode 100644 index 0000000..23859d7 --- /dev/null +++ b/app/db/migrations/005_knowledge_facts.sql @@ -0,0 +1,24 @@ +-- Migration 005: structured facts extracted by cf-harvest +-- payload stores the type-specific JSON verbatim — no field flattening. +-- fact_hash deduplicates identical facts across re-ingests. + +CREATE TABLE IF NOT EXISTS knowledge_facts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL REFERENCES knowledge_sources(id) ON DELETE CASCADE, + fact_type TEXT NOT NULL + CHECK (fact_type IN ( + 'companion_planting', 'soil_amendment', 'propagation', + 'pest_diagnosis', 'harvest_timing', 'instruction', + 'medicinal', 'history_context' + )), + subject TEXT, + payload TEXT NOT NULL, + presenter TEXT, + confidence REAL, + fact_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_knowledge_facts_type ON knowledge_facts (fact_type); +CREATE INDEX IF NOT EXISTS idx_knowledge_facts_subject ON knowledge_facts (subject); +CREATE INDEX IF NOT EXISTS idx_knowledge_facts_source_id ON knowledge_facts (source_id); diff --git a/app/db/store.py b/app/db/store.py new file mode 100644 index 0000000..e761264 --- /dev/null +++ b/app/db/store.py @@ -0,0 +1,26 @@ +"""SQLite data store for Waxwing — cf-core migrations + WAL mode.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from typing import Any + +from circuitforge_core.db.base import get_connection +from circuitforge_core.db.migrations import run_migrations + +MIGRATIONS_DIR = Path(__file__).parent / "migrations" + + +class Store: + def __init__(self, db_path: Path | str) -> None: + self.conn: sqlite3.Connection = get_connection(Path(db_path)) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA foreign_keys=ON") + run_migrations(self.conn, MIGRATIONS_DIR) + + def close(self) -> None: + self.conn.close() + + def _row_to_dict(self, row: sqlite3.Row) -> dict[str, Any]: + return dict(row) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..08fad29 --- /dev/null +++ b/app/main.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# app/main.py +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.routes import api_router +from app.core.config import settings + +logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s") +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Starting Waxwing API...") + settings.ensure_dirs() + + from app.db.store import Store + _s = Store(settings.DB_PATH) + _s.close() + logger.info("Migrations applied. DB ready at %s", settings.DB_PATH) + + yield + + logger.info("Waxwing API shutting down.") + + +app = FastAPI( + title=settings.PROJECT_NAME, + description="Local-first gardening assistant", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router, prefix=settings.API_PREFIX) + + +@app.get("/") +async def root(): + return {"service": "waxwing-api", "docs": "/docs"} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/schemas/__init__.py b/app/models/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/schemas/grow_event.py b/app/models/schemas/grow_event.py new file mode 100644 index 0000000..8f600ca --- /dev/null +++ b/app/models/schemas/grow_event.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from typing import Literal, Optional +from pydantic import BaseModel + +EventType = Literal[ + "planted", "watered", "fed", "pruned", "sprayed", + "observation", "harvest", "repotted", "note", +] + + +class GrowEventCreate(BaseModel): + plant_id: int + event_type: EventType + event_date: str + value_num: Optional[float] = None + value_unit: Optional[str] = None + note: Optional[str] = None + photo_path: Optional[str] = None + + +class GrowEventOut(BaseModel): + id: int + plant_id: int + event_type: str + event_date: str + value_num: Optional[float] + value_unit: Optional[str] + note: Optional[str] + photo_path: Optional[str] + created_at: str diff --git a/app/models/schemas/knowledge.py b/app/models/schemas/knowledge.py new file mode 100644 index 0000000..964ea16 --- /dev/null +++ b/app/models/schemas/knowledge.py @@ -0,0 +1,68 @@ +"""Pydantic schemas for the cf-harvest knowledge ingest contract.""" +from __future__ import annotations + +from typing import Any, Literal, Optional +from pydantic import BaseModel, field_validator + +FactType = Literal[ + "companion_planting", + "soil_amendment", + "propagation", + "pest_diagnosis", + "harvest_timing", + "instruction", + "medicinal", + "history_context", +] + + +class KnowledgeSourceSpec(BaseModel): + video_path: str + presenter: Optional[str] = None + location: Optional[str] = None + source_domain: Optional[str] = "gardening" + + +class SegmentKnowledge(BaseModel): + """One extracted fact from cf-harvest.""" + fact_type: FactType + confidence: float = 1.0 + payload: dict[str, Any] + + @field_validator("confidence") + @classmethod + def clamp_confidence(cls, v: float) -> float: + return max(0.0, min(1.0, v)) + + +class KnowledgeIngestRequest(BaseModel): + source: KnowledgeSourceSpec + facts: list[SegmentKnowledge] + + +class RejectedFact(BaseModel): + index: int + reason: str + + +class IngestResponse(BaseModel): + source_id: int + video_path: str + facts_received: int + facts_inserted: int + facts_skipped_duplicate: int + rejected: list[RejectedFact] = [] + + +class KnowledgeFactOut(BaseModel): + id: int + source_id: int + fact_type: str + subject: Optional[str] + payload: dict[str, Any] + presenter: Optional[str] + confidence: Optional[float] + fact_hash: str + created_at: str + video_path: Optional[str] = None + location: Optional[str] = None diff --git a/app/models/schemas/location.py b/app/models/schemas/location.py new file mode 100644 index 0000000..f73b0db --- /dev/null +++ b/app/models/schemas/location.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from typing import Literal, Optional +from pydantic import BaseModel + + +class LocationCreate(BaseModel): + name: str + kind: Literal["bed", "container", "ground"] = "bed" + size_note: Optional[str] = None + sun_note: Optional[str] = None + notes: Optional[str] = None + + +class LocationUpdate(BaseModel): + name: Optional[str] = None + kind: Optional[Literal["bed", "container", "ground"]] = None + size_note: Optional[str] = None + sun_note: Optional[str] = None + notes: Optional[str] = None + + +class LocationOut(BaseModel): + id: int + name: str + kind: str + size_note: Optional[str] + sun_note: Optional[str] + notes: Optional[str] + created_at: str + updated_at: str diff --git a/app/models/schemas/plant.py b/app/models/schemas/plant.py new file mode 100644 index 0000000..dc7f63c --- /dev/null +++ b/app/models/schemas/plant.py @@ -0,0 +1,45 @@ +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel + + +class PlantCreate(BaseModel): + common_name: str + latin_binomial: Optional[str] = None + variety: Optional[str] = None + location_id: Optional[int] = None + planting_date: Optional[str] = None + rootstock: Optional[str] = None + scion_source: Optional[str] = None + graft_date: Optional[str] = None + days_to_maturity: Optional[int] = None + notes: Optional[str] = None + + +class PlantUpdate(BaseModel): + common_name: Optional[str] = None + latin_binomial: Optional[str] = None + variety: Optional[str] = None + location_id: Optional[int] = None + planting_date: Optional[str] = None + rootstock: Optional[str] = None + scion_source: Optional[str] = None + graft_date: Optional[str] = None + days_to_maturity: Optional[int] = None + notes: Optional[str] = None + + +class PlantOut(BaseModel): + id: int + common_name: str + latin_binomial: Optional[str] + variety: Optional[str] + location_id: Optional[int] + planting_date: Optional[str] + rootstock: Optional[str] + scion_source: Optional[str] + graft_date: Optional[str] + days_to_maturity: Optional[int] + notes: Optional[str] + created_at: str + updated_at: str diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/knowledge/__init__.py b/app/services/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/knowledge/ingest.py b/app/services/knowledge/ingest.py new file mode 100644 index 0000000..1b8e144 --- /dev/null +++ b/app/services/knowledge/ingest.py @@ -0,0 +1,121 @@ +"""Map cf-harvest SegmentKnowledge → KnowledgeFact rows. + +Idempotent: re-ingesting the same video batch is a no-op (INSERT OR IGNORE on fact_hash). +Partial success: malformed individual facts are collected in rejected[] without +aborting the batch — the caller receives a per-item rejection report. +""" +from __future__ import annotations + +import hashlib +import json +import logging +import sqlite3 +from typing import Any + +from app.models.schemas.knowledge import ( + IngestResponse, + KnowledgeIngestRequest, + RejectedFact, +) + +logger = logging.getLogger(__name__) + +# Maps fact_type → how to derive the searchable subject from the payload. +# Subject is stored denormalized for fast filtering; it is not the ground truth +# — the full payload is authoritative. +_SUBJECT_EXTRACTORS: dict[str, Any] = { + "companion_planting": lambda p: (p.get("plants") or [None])[0], + "soil_amendment": lambda p: (p.get("target_plants") or [None])[0] or p.get("amendment"), + "propagation": lambda p: p.get("plant"), + "pest_diagnosis": lambda p: p.get("pest_or_disease"), + "harvest_timing": lambda p: p.get("plant"), + "instruction": lambda p: p.get("subject") or p.get("action"), + "medicinal": lambda p: p.get("plant"), + "history_context": lambda p: p.get("subject"), +} + + +def _derive_subject(fact_type: str, payload: dict) -> str | None: + extractor = _SUBJECT_EXTRACTORS.get(fact_type) + if extractor is None: + return None + try: + result = extractor(payload) + return str(result).strip() if result else None + except Exception: + return None + + +def _fact_hash(fact_type: str, payload: dict, video_path: str) -> str: + canonical = json.dumps( + {"t": fact_type, "p": payload, "v": video_path}, + sort_keys=True, + ensure_ascii=True, + ) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def ingest(conn: sqlite3.Connection, request: KnowledgeIngestRequest) -> IngestResponse: + """Upsert source, then insert facts. Returns counts and per-item rejections.""" + src = request.source + + # Upsert knowledge_source — idempotent on video_path + conn.execute( + """ + INSERT INTO knowledge_sources (video_path, presenter, location, source_domain) + VALUES (?, ?, ?, ?) + ON CONFLICT(video_path) DO UPDATE SET + presenter = excluded.presenter, + location = excluded.location, + source_domain = excluded.source_domain + """, + (src.video_path, src.presenter, src.location, src.source_domain), + ) + source_id: int = conn.execute( + "SELECT id FROM knowledge_sources WHERE video_path = ?", (src.video_path,) + ).fetchone()["id"] + + inserted = 0 + skipped = 0 + rejected: list[RejectedFact] = [] + + for idx, fact in enumerate(request.facts): + try: + subject = _derive_subject(fact.fact_type, fact.payload) + fhash = _fact_hash(fact.fact_type, fact.payload, src.video_path) + payload_json = json.dumps(fact.payload, ensure_ascii=False) + + cursor = conn.execute( + """ + INSERT OR IGNORE INTO knowledge_facts + (source_id, fact_type, subject, payload, presenter, confidence, fact_hash) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + source_id, + fact.fact_type, + subject, + payload_json, + src.presenter, + fact.confidence, + fhash, + ), + ) + if cursor.rowcount == 1: + inserted += 1 + else: + skipped += 1 + except Exception as exc: + logger.warning("Rejected fact %d (%s): %s", idx, fact.fact_type, exc) + rejected.append(RejectedFact(index=idx, reason=str(exc))) + + conn.commit() + + return IngestResponse( + source_id=source_id, + video_path=src.video_path, + facts_received=len(request.facts), + facts_inserted=inserted, + facts_skipped_duplicate=skipped, + rejected=rejected, + ) diff --git a/app/tiers.py b/app/tiers.py new file mode 100644 index 0000000..105afd9 --- /dev/null +++ b/app/tiers.py @@ -0,0 +1,24 @@ +"""Tier gate decorators for Waxwing. + +Tiers: Free, Paid, Premium. Never reference Ultra. +Gate enforcement is stubbed in Phase 1 — all routes are accessible. +License validation against cf-licensing will be wired in Phase 2. +""" +from __future__ import annotations + +import functools +import logging + +logger = logging.getLogger(__name__) + +_TIER_ORDER = {"free": 0, "paid": 1, "premium": 2} + + +def require_tier(minimum: str): + """Decorator stub — always passes in Phase 1 (license validation deferred).""" + def decorator(func): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + return await func(*args, **kwargs) + return wrapper + return decorator diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..f754207 --- /dev/null +++ b/compose.yml @@ -0,0 +1,21 @@ +services: + api: + build: + context: .. + dockerfile: waxwing/Dockerfile + network_mode: host + env_file: .env + volumes: + - ./data:/app/waxwing/data + - ${HOME}/.config/circuitforge:/root/.config/circuitforge:ro + restart: unless-stopped + + web: + build: + context: . + dockerfile: docker/web/Dockerfile + ports: + - "8521:80" + restart: unless-stopped + depends_on: + - api diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile new file mode 100644 index 0000000..a076dc2 --- /dev/null +++ b/docker/web/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM nginx:alpine +COPY docker/web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/web/nginx.conf b/docker/web/nginx.conf new file mode 100644 index 0000000..fd9e1bc --- /dev/null +++ b/docker/web/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://localhost:8522; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..ccf1ed7 --- /dev/null +++ b/environment.yml @@ -0,0 +1,15 @@ +name: cf +channels: + - conda-forge + - defaults +dependencies: + - python=3.11 + - pip + - pip: + - fastapi>=0.110 + - uvicorn[standard]>=0.27 + - pydantic>=2.5 + - httpx>=0.27 + - pyyaml>=6.0 + - pytest>=8.0 + - pytest-asyncio>=0.23 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7657a7c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Waxwing + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..77ed17b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "waxwing-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.7", + "pinia": "^3.0.3", + "vue": "^3.5.22", + "vue-router": "^4.6.3" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.9.3", + "vite": "^7.1.7", + "vue-tsc": "^3.1.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..cf9500d --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/frontend/src/components/KnowledgeFactCard.vue b/frontend/src/components/KnowledgeFactCard.vue new file mode 100644 index 0000000..760b071 --- /dev/null +++ b/frontend/src/components/KnowledgeFactCard.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/src/components/PlantCard.vue b/frontend/src/components/PlantCard.vue new file mode 100644 index 0000000..a345e8e --- /dev/null +++ b/frontend/src/components/PlantCard.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/frontend/src/components/PlantForm.vue b/frontend/src/components/PlantForm.vue new file mode 100644 index 0000000..584c1f2 --- /dev/null +++ b/frontend/src/components/PlantForm.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..8cbe0fa --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './style.css' +import './theme.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..7e70050 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,18 @@ +import { createRouter, createWebHistory } from 'vue-router' +import RegistryView from '@/views/RegistryView.vue' +import KnowledgeView from '@/views/KnowledgeView.vue' +import CalendarView from '@/views/CalendarView.vue' +import SettingsView from '@/views/SettingsView.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', redirect: '/registry' }, + { path: '/registry', name: 'registry', component: RegistryView }, + { path: '/knowledge', name: 'knowledge', component: KnowledgeView }, + { path: '/calendar', name: 'calendar', component: CalendarView }, + { path: '/settings', name: 'settings', component: SettingsView }, + ], +}) + +export default router diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..51e3fdc --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,16 @@ +import axios from 'axios' + +export const api = axios.create({ + baseURL: '/api/v1', + headers: { 'Content-Type': 'application/json' }, + timeout: 15_000, +}) + +api.interceptors.response.use( + r => r, + err => { + const msg = err?.response?.data?.detail ?? err?.message ?? 'Unknown error' + console.error('[waxwing api]', msg, err?.config?.url) + return Promise.reject(err) + } +) diff --git a/frontend/src/stores/knowledge.ts b/frontend/src/stores/knowledge.ts new file mode 100644 index 0000000..6742e06 --- /dev/null +++ b/frontend/src/stores/knowledge.ts @@ -0,0 +1,57 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '@/services/api' + +export type FactType = + | 'companion_planting' + | 'soil_amendment' + | 'propagation' + | 'pest_diagnosis' + | 'harvest_timing' + | 'instruction' + | 'medicinal' + | 'history_context' + +export interface KnowledgeFact { + id: number + source_id: number + fact_type: FactType + subject: string | null + payload: Record + presenter: string | null + confidence: number + fact_hash: string + created_at: string + video_path: string | null + location: string | null +} + +export interface KnowledgeFilters { + fact_type?: FactType + subject?: string + presenter?: string + min_confidence?: number + limit?: number + offset?: number +} + +export const useKnowledgeStore = defineStore('knowledge', () => { + const facts = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchFacts(filters: KnowledgeFilters = {}) { + loading.value = true + error.value = null + try { + const resp = await api.get('/knowledge', { params: filters }) + facts.value = resp.data + } catch (e: any) { + error.value = e?.response?.data?.detail ?? 'Failed to load knowledge' + } finally { + loading.value = false + } + } + + return { facts, loading, error, fetchFacts } +}) diff --git a/frontend/src/stores/plants.ts b/frontend/src/stores/plants.ts new file mode 100644 index 0000000..0013bb8 --- /dev/null +++ b/frontend/src/stores/plants.ts @@ -0,0 +1,85 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '@/services/api' + +export interface Plant { + id: number + common_name: string + latin_binomial: string | null + variety: string | null + location_id: number | null + planting_date: string | null + rootstock: string | null + scion_source: string | null + graft_date: string | null + days_to_maturity: number | null + notes: string | null + created_at: string + updated_at: string +} + +export interface CreatePlantInput { + common_name: string + latin_binomial?: string | null + variety?: string | null + location_id?: number | null + planting_date?: string | null + days_to_maturity?: number | null + notes?: string | null +} + +export const usePlantsStore = defineStore('plants', () => { + const plants = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchAll(locationId?: number) { + loading.value = true + error.value = null + try { + const params = locationId ? { location_id: locationId } : {} + const resp = await api.get('/plants', { params }) + plants.value = resp.data + } catch (e: any) { + error.value = e?.response?.data?.detail ?? 'Failed to load plants' + } finally { + loading.value = false + } + } + + async function create(input: CreatePlantInput): Promise { + try { + const resp = await api.post('/plants', input) + plants.value.push(resp.data) + return resp.data + } catch (e: any) { + error.value = e?.response?.data?.detail ?? 'Failed to create plant' + return null + } + } + + async function update(id: number, patch: Partial): Promise { + try { + const resp = await api.patch(`/plants/${id}`, patch) + const idx = plants.value.findIndex(p => p.id === id) + if (idx !== -1) plants.value[idx] = resp.data + return resp.data + } catch (e: any) { + error.value = e?.response?.data?.detail ?? 'Failed to update plant' + return null + } + } + + async function remove(id: number): Promise { + try { + await api.delete(`/plants/${id}`) + plants.value = plants.value.filter(p => p.id !== id) + return true + } catch (e: any) { + error.value = e?.response?.data?.detail ?? 'Failed to delete plant' + return false + } + } + + return { plants, loading, error, fetchAll, create, update, remove } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..484accf --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,141 @@ +/* Base reset and global styles for Waxwing */ + +:root { + /* Garden-earth palette */ + --color-primary: #4a7c59; + --color-primary-dark: #3a6347; + --color-primary-light: #6a9c79; + + /* Background layers (dark earthy theme) */ + --color-bg-primary: #1a1e1a; + --color-bg-secondary: #1f241f; + --color-bg-card: #242924; + --color-bg-elevated: #2a302a; + --color-bg-input: #1f241f; + + /* Text */ + --color-text-primary: #e8f0e8; + --color-text-secondary: #a8bca8; + --color-text-muted: #687868; + + /* Border */ + --color-border: #2f372f; + + /* Semantic colours */ + --color-success: #4a7c59; + --color-success-dark: #3a6347; + --color-success-light: #8aba8a; + --color-success-bg: rgba(74, 124, 89, 0.12); + --color-success-border: rgba(74, 124, 89, 0.3); + + --color-warning: #c8a020; + --color-warning-dark: #a88010; + --color-warning-light: #e8c060; + --color-warning-bg: rgba(200, 160, 32, 0.1); + --color-warning-border: rgba(200, 160, 32, 0.28); + + --color-error: #c0534a; + --color-error-dark: #a04038; + --color-error-light: #e08a82; + --color-error-bg: rgba(192, 83, 74, 0.1); + --color-error-border: rgba(192, 83, 74, 0.28); + + --color-info: #4a7ca8; + --color-info-dark: #3a6488; + --color-info-light: #7aaad0; + --color-info-bg: rgba(74, 124, 168, 0.1); + --color-info-border: rgba(74, 124, 168, 0.28); + + /* Gradients */ + --gradient-primary: linear-gradient(135deg, #4a7c59 0%, #6a9c79 100%); + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6); + --shadow-green: 0 4px 16px rgba(74, 124, 89, 0.3); + + /* Radii */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-pill: 999px; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + --spacing-2xl: 48px; + + /* Typography */ + --font-body: 'Inter', system-ui, -apple-system, sans-serif; + --font-display: 'Georgia', 'Times New Roman', serif; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 1.875rem; +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-body); + font-size: var(--font-size-base); + background: var(--color-bg-primary); + color: var(--color-text-primary); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + min-height: 100vh; +} + +a { + color: var(--color-primary-light); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +h1, h2, h3, h4, h5, h6 { + line-height: 1.3; + color: var(--color-text-primary); +} + +/* App layout */ +#app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: var(--color-bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} diff --git a/frontend/src/theme.css b/frontend/src/theme.css new file mode 100644 index 0000000..6ba9b8a --- /dev/null +++ b/frontend/src/theme.css @@ -0,0 +1,627 @@ +/** + * Central Theme System for Waxwing + * + * Theme-aware, responsive utility classes. Components should use these + * rather than inline styles. All colours reference CSS custom properties + * defined in style.css so dark/light mode switches require only one edit. + */ + +/* ============================================ + ACCESSIBILITY UTILITIES + ============================================ */ + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +.form-input:focus-visible, +.form-select:focus-visible, +.form-textarea:focus-visible { + outline: none; +} + +/* ============================================ + LAYOUT UTILITIES + ============================================ */ + +.grid-responsive { + display: grid; + gap: var(--spacing-md); +} + +.grid-auto { + display: grid; + gap: var(--spacing-md); + grid-template-columns: 1fr; +} + +.grid-stats { + display: grid; + gap: var(--spacing-md); + grid-template-columns: 1fr; +} + +.grid-1 { grid-template-columns: 1fr; } +.grid-2 { grid-template-columns: repeat(2, 1fr); } +.grid-3 { grid-template-columns: repeat(3, 1fr); } +.grid-4 { grid-template-columns: repeat(4, 1fr); } + +/* ============================================ + FLEXBOX UTILITIES + ============================================ */ + +.flex { display: flex; } +.flex-col { display: flex; flex-direction: column; } +.flex-wrap { flex-wrap: wrap; } +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} +.flex-between { + display: flex; + justify-content: space-between; + align-items: center; +} +.flex-start { + display: flex; + justify-content: flex-start; + align-items: center; +} +.flex-end { + display: flex; + justify-content: flex-end; + align-items: center; +} +.align-center { align-items: center; } + +.flex-responsive { + display: flex; + gap: var(--spacing-md); + flex-wrap: wrap; +} + +/* ============================================ + SPACING UTILITIES + ============================================ */ + +.gap-xs { gap: var(--spacing-xs); } +.gap-sm { gap: var(--spacing-sm); } +.gap-md { gap: var(--spacing-md); } +.gap-lg { gap: var(--spacing-lg); } +.gap-xl { gap: var(--spacing-xl); } + +.p-0 { padding: 0; } +.p-xs { padding: var(--spacing-xs); } +.p-sm { padding: var(--spacing-sm); } +.p-md { padding: var(--spacing-md); } +.p-lg { padding: var(--spacing-lg); } +.p-xl { padding: var(--spacing-xl); } + +.m-0 { margin: 0; } +.mt-xs { margin-top: var(--spacing-xs); } +.mt-sm { margin-top: var(--spacing-sm); } +.mt-md { margin-top: var(--spacing-md); } +.mb-xs { margin-bottom: var(--spacing-xs); } +.mb-sm { margin-bottom: var(--spacing-sm); } +.mb-md { margin-bottom: var(--spacing-md); } +.mb-lg { margin-bottom: var(--spacing-lg); } +.ml-xs { margin-left: var(--spacing-xs); } +.ml-md { margin-left: var(--spacing-md); } +.mr-md { margin-right: var(--spacing-md); } + +.pt-md { padding-top: var(--spacing-md); } +.pb-md { padding-bottom: var(--spacing-md); } +.pl-md { padding-left: var(--spacing-md); } +.pr-md { padding-right: var(--spacing-md); } + +/* ============================================ + CARD COMPONENTS + ============================================ */ + +.card { + background: var(--color-bg-card); + border-radius: var(--radius-xl); + padding: var(--spacing-lg); + box-shadow: var(--shadow-md); + border: 1px solid var(--color-border); + transition: box-shadow 0.2s ease; +} + +.card:hover { + box-shadow: var(--shadow-lg); +} + +.card-sm { + background: var(--color-bg-card); + border-radius: var(--radius-lg); + padding: var(--spacing-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--color-border); +} + +.card-secondary { + background: var(--color-bg-secondary); + border-radius: var(--radius-lg); + padding: var(--spacing-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--color-border); +} + +.card-success { border-left: 3px solid var(--color-success); } +.card-warning { border-left: 3px solid var(--color-warning); } +.card-error { border-left: 3px solid var(--color-error); } +.card-info { border-left: 3px solid var(--color-info); } + +/* ============================================ + BUTTON COMPONENTS + ============================================ */ + +.btn { + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid transparent; + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-weight: 600; + font-family: var(--font-body); + cursor: pointer; + transition: all 0.18s ease; + white-space: nowrap; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-xs); +} + +.btn:hover { transform: translateY(-1px); } +.btn:active { transform: translateY(0); } +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; +} + +.btn-primary { + background: var(--gradient-primary); + color: #f0f8f0; + border: none; + font-weight: 700; + box-shadow: var(--shadow-green); +} + +.btn-primary:hover:not(:disabled) { + box-shadow: 0 6px 20px rgba(74, 124, 89, 0.4); +} + +.btn-success { + background: var(--color-success); + color: white; +} + +.btn-success:hover:not(:disabled) { + background: var(--color-success-dark); +} + +.btn-error { + background: var(--color-error); + color: white; +} + +.btn-error:hover:not(:disabled) { + background: var(--color-error-dark); +} + +.btn-secondary { + background: var(--color-bg-elevated); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-bg-primary); + border-color: var(--color-primary); + color: var(--color-primary-light); +} + +.btn-secondary.active { + background: var(--color-primary); + color: #f0f8f0; + border-color: var(--color-primary); + font-weight: 700; +} + +.btn-chip { + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + font-size: var(--font-size-xs); + font-weight: 500; + font-family: var(--font-body); + background: var(--color-bg-elevated); + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.18s ease; + white-space: nowrap; +} + +.btn-chip:hover { + border-color: var(--color-primary); + color: var(--color-primary-light); +} + +.btn-chip.active { + background: var(--color-primary); + color: #f0f8f0; + border-color: var(--color-primary); + font-weight: 700; +} + +.btn-sm { + padding: var(--spacing-xs) var(--spacing-sm); + font-size: var(--font-size-xs); +} + +.btn-lg { + padding: var(--spacing-md) var(--spacing-xl); + font-size: var(--font-size-base); +} + +.btn-icon { + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: all 0.18s ease; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.btn-icon:hover { + background: var(--color-bg-primary); + color: var(--color-text-primary); + transform: none; +} + +.btn-icon.btn-icon-danger:hover { color: var(--color-error); } +.btn-icon.btn-icon-success:hover { color: var(--color-success); } + +/* ============================================ + FORM COMPONENTS + ============================================ */ + +.form-group { margin-bottom: var(--spacing-md); } + +.form-label { + display: block; + margin-bottom: var(--spacing-xs); + font-weight: 600; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-input); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-family: var(--font-body); + transition: border-color 0.18s ease, box-shadow 0.18s ease; + box-sizing: border-box; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-success-bg); +} + +.form-textarea { + resize: vertical; + min-height: 80px; +} + +.form-hint { + display: block; + margin-top: var(--spacing-xs); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.form-row { + display: grid; + gap: var(--spacing-md); + grid-template-columns: 1fr; +} + +.filter-chip-row { + display: flex; + gap: var(--spacing-xs); + overflow-x: auto; + padding-bottom: var(--spacing-xs); + scrollbar-width: none; + min-width: 0; + width: 100%; +} + +.filter-chip-row::-webkit-scrollbar { display: none; } + +.tab-bar-scroll { + display: flex; + gap: var(--spacing-xs); + overflow-x: auto; + overflow-y: visible; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + min-width: 0; + width: 100%; + flex-wrap: nowrap; + padding-bottom: 2px; +} + +.tab-bar-scroll::-webkit-scrollbar { display: none; } + +/* ============================================ + TEXT UTILITIES + ============================================ */ + +.text-xs { font-size: var(--font-size-xs); } +.text-sm { font-size: var(--font-size-sm); } +.text-base { font-size: var(--font-size-base); } +.text-lg { font-size: var(--font-size-lg); } +.text-xl { font-size: var(--font-size-xl); } +.text-2xl { font-size: var(--font-size-2xl); } + +.text-display { font-family: var(--font-display); } +.text-mono { font-family: var(--font-mono); } + +.text-primary { color: var(--color-text-primary); } +.text-secondary { color: var(--color-text-secondary); } +.text-muted { color: var(--color-text-muted); } +.text-success { color: var(--color-success-light); } +.text-warning { color: var(--color-warning-light); } +.text-error { color: var(--color-error-light); } +.text-info { color: var(--color-info-light); } +.text-green { color: var(--color-primary-light); } + +.text-center { text-align: center; } +.text-left { text-align: left; } +.text-right { text-align: right; } + +.font-bold { font-weight: 700; } +.font-semibold { font-weight: 600; } +.font-normal { font-weight: 400; } + +/* ============================================ + STATUS BADGES + ============================================ */ + +.status-badge { + display: inline-flex; + align-items: center; + padding: 3px var(--spacing-sm); + border-radius: var(--radius-pill); + font-size: var(--font-size-xs); + font-weight: 600; + font-family: var(--font-mono); + letter-spacing: 0.02em; +} + +.status-success { + background: var(--color-success-bg); + color: var(--color-success-light); + border: 1px solid var(--color-success-border); +} + +.status-warning { + background: var(--color-warning-bg); + color: var(--color-warning-light); + border: 1px solid var(--color-warning-border); +} + +.status-error { + background: var(--color-error-bg); + color: var(--color-error-light); + border: 1px solid var(--color-error-border); +} + +.status-info { + background: var(--color-info-bg); + color: var(--color-info-light); + border: 1px solid var(--color-info-border); +} + +.status-neutral { + background: rgba(74, 124, 89, 0.06); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +/* ============================================ + RESPONSIVE UTILITIES + ============================================ */ + +.mobile-only { display: none; } +.desktop-only { display: block; } + +.w-full { width: 100%; } +.w-auto { width: auto; } +.h-full { height: 100%; } +.h-auto { height: auto; } + +/* ============================================ + ANIMATION UTILITIES + ============================================ */ + +.fade-in { animation: fadeIn 0.3s ease-in; } + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.slide-up { animation: slideUp 0.3s ease-out; } + +@keyframes slideUp { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ============================================ + LOADING UTILITIES + ============================================ */ + +.spinner { + border: 2px solid var(--color-border); + border-top: 2px solid var(--color-primary); + border-radius: 50%; + width: 36px; + height: 36px; + animation: spin 0.9s linear infinite; + margin: 0 auto; +} + +.spinner-sm { + width: 18px; + height: 18px; + border-width: 2px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* ============================================ + DIVIDER + ============================================ */ + +.divider { + height: 1px; + background: var(--color-border); + margin: var(--spacing-lg) 0; +} + +.divider-md { margin: var(--spacing-md) 0; } + +/* ============================================ + SECTION HEADERS + ============================================ */ + +.section-title { + font-family: var(--font-display); + font-weight: 600; + color: var(--color-text-primary); + margin: 0; +} + +/* ============================================ + BREAKPOINTS + ============================================ */ + +@media (max-width: 480px) { + .mobile-only { display: block; } + .desktop-only { display: none; } + + .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr !important; } + .flex-responsive { flex-direction: column; } + .btn-mobile-full { width: 100%; min-width: 100%; } + .card { padding: var(--spacing-md); border-radius: var(--radius-lg); } + .card-sm { padding: var(--spacing-sm); } + .btn { white-space: normal; text-align: center; } +} + +@media (min-width: 481px) and (max-width: 768px) { + .grid-3, .grid-4 { grid-template-columns: repeat(2, 1fr); } + .grid-auto { grid-template-columns: repeat(2, 1fr); } + .grid-stats { grid-template-columns: repeat(2, 1fr); } + .form-row { grid-template-columns: 1fr 1fr; } +} + +@media (min-width: 769px) and (max-width: 1024px) { + .grid-auto { grid-template-columns: repeat(2, 1fr); } + .grid-stats { grid-template-columns: repeat(4, 1fr); } + .grid-4 { grid-template-columns: repeat(3, 1fr); } +} + +@media (min-width: 1025px) { + .grid-auto { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } + .grid-stats { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); } + .form-row { grid-template-columns: 1fr 1fr; } +} + +/* ============================================ + EASTER EGG — WAXWING FLOCK MODE + Activated via Konami code + ============================================ */ + +body.flock-mode .card, +body.flock-mode .card-sm, +body.flock-mode .card-secondary { + box-shadow: + 0 0 0 1px rgba(74, 124, 89, 0.4), + 0 0 14px rgba(74, 124, 89, 0.2), + 0 2px 24px rgba(106, 156, 121, 0.12); +} + +body.flock-mode .btn-primary { + box-shadow: 0 0 20px rgba(74, 124, 89, 0.6), 0 0 40px rgba(106, 156, 121, 0.3); +} + +body.flock-mode .wordmark-waxwing { + text-shadow: + 0 0 10px rgba(74, 124, 89, 0.8), + 0 0 24px rgba(106, 156, 121, 0.5); + animation: foliageShimmer 4s ease-in-out infinite; +} + +@keyframes foliageShimmer { + 0%, 100% { opacity: 1; filter: hue-rotate(0deg); } + 33% { opacity: 0.92; filter: hue-rotate(15deg); } + 66% { opacity: 0.96; filter: hue-rotate(-10deg); } +} + +body.flock-mode::after { + content: ''; + position: fixed; + inset: 0; + background: radial-gradient(ellipse at 50% 100%, rgba(74, 124, 89, 0.06) 0%, transparent 70%); + pointer-events: none; + z-index: 9998; +} diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue new file mode 100644 index 0000000..a684144 --- /dev/null +++ b/frontend/src/views/CalendarView.vue @@ -0,0 +1,9 @@ + diff --git a/frontend/src/views/KnowledgeView.vue b/frontend/src/views/KnowledgeView.vue new file mode 100644 index 0000000..59d460b --- /dev/null +++ b/frontend/src/views/KnowledgeView.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/views/RegistryView.vue b/frontend/src/views/RegistryView.vue new file mode 100644 index 0000000..1c90e7a --- /dev/null +++ b/frontend/src/views/RegistryView.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..f6cf203 --- /dev/null +++ b/frontend/src/views/SettingsView.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..f409956 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,13 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["src/**/*"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..856c95f --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "@vue/tsconfig/tsconfig.node.json", + "include": ["vite.config.ts"], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..ca86cb5 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8522', + changeOrigin: true, + }, + }, + }, +}) diff --git a/manage.sh b/manage.sh new file mode 100755 index 0000000..2dc0c5f --- /dev/null +++ b/manage.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVICE=waxwing +WEB_PORT=8521 +API_PORT=8522 +COMPOSE_FILE="compose.yml" + +OVERRIDE_FLAG="" +[[ -f "compose.override.yml" ]] && OVERRIDE_FLAG="-f compose.override.yml" + +usage() { + echo "Usage: $0 {start|stop|restart|status|logs|open|build|test|update}" + echo "" + echo " start Build (if needed) and start all services" + echo " stop Stop and remove containers" + echo " restart Stop then start" + echo " status Show running containers" + echo " logs [svc] Follow logs (api | web — defaults to all)" + echo " open Open web UI in browser" + echo " build Rebuild Docker images without cache" + echo " test Run pytest test suite" + echo " update git pull + rebuild + restart" + exit 1 +} + +cmd="${1:-help}" +shift || true + +case "$cmd" in + start) + docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build + echo "Waxwing running → http://localhost:${WEB_PORT}" + ;; + stop) + docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG down + ;; + restart) + docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG down + docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build + echo "Waxwing running → http://localhost:${WEB_PORT}" + ;; + status) + docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG ps + ;; + logs) + svc="${1:-}" + docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG logs -f ${svc} + ;; + open) + xdg-open "http://localhost:${WEB_PORT}" 2>/dev/null \ + || open "http://localhost:${WEB_PORT}" 2>/dev/null \ + || echo "Open http://localhost:${WEB_PORT} in your browser" + ;; + build) + docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG build --no-cache + ;; + test) + conda run -n cf python -m pytest tests/ -v + ;; + update) + git pull + docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build + echo "Waxwing updated → http://localhost:${WEB_PORT}" + ;; + *) + usage + ;; +esac diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9cffdb3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "waxwing" +version = "0.1.0" +description = "Local-first gardening assistant — plant registry, grow calendar, knowledge base" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "pydantic>=2.5", + "httpx>=0.27", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "httpx>=0.27", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = ["unit", "integration"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5930ff3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +"""Shared test fixtures for Waxwing.""" +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.db.store import Store + + +@pytest.fixture +def tmp_db(tmp_path: Path) -> Path: + """Return a path to a fresh, migration-applied SQLite database.""" + db_path = tmp_path / "test.db" + s = Store(db_path) + s.close() + return db_path + + +@pytest.fixture +def store(tmp_db: Path) -> Store: + s = Store(tmp_db) + yield s + s.close() + + +@pytest.fixture +def client(tmp_db: Path, monkeypatch) -> TestClient: + """TestClient with a temp database injected via settings.""" + from app.core import config as cfg_module + monkeypatch.setattr(cfg_module.settings, "DB_PATH", tmp_db) + from app.main import app + return TestClient(app) diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..9971c03 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,6 @@ +def test_health_returns_ok(client): + resp = client.get("/api/v1/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["service"] == "waxwing-api" diff --git a/tests/test_knowledge_ingest.py b/tests/test_knowledge_ingest.py new file mode 100644 index 0000000..04da6ba --- /dev/null +++ b/tests/test_knowledge_ingest.py @@ -0,0 +1,144 @@ +"""cf-harvest knowledge ingest tests — critical path. + +Covers: all 8 fact types, idempotent re-ingest, partial batch failure, +subject extraction, and fact_hash deduplication. +""" +import pytest + + +_SOURCE = { + "video_path": "/media/GardenersWorld/S59E01.mkv", + "presenter": "Monty Don", + "location": "Longmeadow", + "source_domain": "gardening", +} + +_ALL_FACT_TYPES = [ + ("companion_planting", { + "plants": ["carrot", "spring onion"], + "relationship": "beneficial", + "benefit": "deters carrot root fly", + "spacing": "alternate rows", + "notes": None, + }), + ("soil_amendment", { + "amendment": "pine bark mulch", + "purpose": "suppress weeds, retain moisture", + "application_rate": "5cm layer", + "timing": "early spring", + "target_plants": ["all beds"], + }), + ("propagation", { + "plant": "raspberry", + "method": "cane", + "timing": "autumn", + "steps": ["cut cane to 30cm", "push into prepared soil", "firm in"], + "success_indicators": ["new leaves at base"], + "notes": None, + }), + ("pest_diagnosis", { + "pest_or_disease": "couch grass", + "affected_plants": ["border perennials"], + "symptoms": ["white rhizomes through roots", "vigorous re-sprouting"], + "treatment": "fork out every rhizome fragment", + "prevention": "mulch to reduce light", + "organic": True, + }), + ("harvest_timing", { + "plant": "raspberry", + "indicators": ["deep red colour", "comes free with gentle pull"], + "harvest_method": "gentle pull", + "post_harvest": "refrigerate within 24h", + }), + ("instruction", { + "action": "mulch", + "subject": "border", + "steps": ["clear weeds", "apply 5cm pine bark"], + "timing": "early spring", + "notes": None, + }), + ("medicinal", { + "plant": "Calendula officinalis", + "use": "wound healing, anti-inflammatory skin salve", + "preparation": "infused oil", + "cautions": "avoid during pregnancy", + }), + ("history_context", { + "subject": "Narcissus triandrus", + "origin": "Iberian Peninsula", + "period": None, + "summary": "Angel's Tears — a small, nodding daffodil native to rocky hillsides.", + }), +] + + +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}) + assert resp.status_code == 200 + data = resp.json() + assert data["facts_received"] == 8 + assert data["facts_inserted"] == 8 + assert data["facts_skipped_duplicate"] == 0 + assert data["rejected"] == [] + + +def test_ingest_idempotent(client): + facts = [{"fact_type": "instruction", "confidence": 0.8, "payload": { + "action": "water", "subject": "tomatoes", "steps": [], "timing": "morning", + }}] + payload = {"source": _SOURCE, "facts": facts} + + first = client.post("/api/v1/knowledge/ingest", json=payload).json() + assert first["facts_inserted"] == 1 + + second = client.post("/api/v1/knowledge/ingest", json=payload).json() + assert second["facts_inserted"] == 0 + assert second["facts_skipped_duplicate"] == 1 + + +def test_ingest_partial_batch_continues_on_bad_fact(client): + facts = [ + {"fact_type": "instruction", "confidence": 0.9, "payload": { + "action": "prune", "subject": "roses", "steps": [], "timing": "spring", + }}, + {"fact_type": "unknown_type", "confidence": 0.5, "payload": {}}, + {"fact_type": "medicinal", "confidence": 0.7, "payload": { + "plant": "Lavender", "use": "calming", "preparation": "tea", "cautions": None, + }}, + ] + resp = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts}) + assert resp.status_code == 422 + + +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}) + assert resp.status_code == 200 + assert resp.json()["facts_inserted"] == 1 + + +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() + assert r1["source_id"] == r2["source_id"] + + +def test_ingest_subject_extracted_for_companion_planting(client): + facts = [{"fact_type": "companion_planting", "confidence": 0.95, "payload": { + "plants": ["basil", "tomato"], "relationship": "beneficial", + "benefit": "repels aphids", "spacing": None, "notes": None, + }}] + resp = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts}) + assert resp.status_code == 200 + + query = client.get("/api/v1/knowledge?fact_type=companion_planting") + assert query.status_code == 200 + facts_out = query.json() + assert len(facts_out) == 1 + assert facts_out[0]["subject"] == "basil" diff --git a/tests/test_knowledge_query.py b/tests/test_knowledge_query.py new file mode 100644 index 0000000..1e0b167 --- /dev/null +++ b/tests/test_knowledge_query.py @@ -0,0 +1,95 @@ +"""Knowledge query endpoint tests.""" + +_SOURCE_A = { + "video_path": "/media/GW/s59e01.mkv", + "presenter": "Monty Don", + "location": "Longmeadow", + "source_domain": "gardening", +} +_SOURCE_B = { + "video_path": "/media/GW/s59e02.mkv", + "presenter": "Adam Frost", + "location": "Adam's Garden", + "source_domain": "gardening", +} + + +def _ingest(client, source, facts): + return client.post("/api/v1/knowledge/ingest", json={"source": source, "facts": facts}).json() + + +def test_query_all_returns_inserted_facts(client): + _ingest(client, _SOURCE_A, [ + {"fact_type": "instruction", "confidence": 0.9, "payload": {"action": "mulch", "subject": "border", "steps": [], "timing": None}}, + ]) + resp = client.get("/api/v1/knowledge") + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + +def test_query_filter_by_fact_type(client): + _ingest(client, _SOURCE_A, [ + {"fact_type": "companion_planting", "confidence": 0.9, "payload": {"plants": ["tomato", "basil"], "relationship": "beneficial", "benefit": None, "spacing": None}}, + {"fact_type": "instruction", "confidence": 0.8, "payload": {"action": "prune", "subject": "roses", "steps": [], "timing": None}}, + ]) + resp = client.get("/api/v1/knowledge?fact_type=companion_planting") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["fact_type"] == "companion_planting" + + +def test_query_filter_by_presenter(client): + _ingest(client, _SOURCE_A, [ + {"fact_type": "instruction", "confidence": 0.9, "payload": {"action": "water", "subject": "seedlings", "steps": [], "timing": None}}, + ]) + _ingest(client, _SOURCE_B, [ + {"fact_type": "soil_amendment", "confidence": 0.85, "payload": {"amendment": "compost", "purpose": "feed soil", "application_rate": "2cm", "timing": "spring", "target_plants": []}}, + ]) + resp = client.get("/api/v1/knowledge?presenter=Adam") + assert resp.status_code == 200 + data = resp.json() + assert all(d["presenter"] == "Adam Frost" for d in data) + + +def test_query_filter_by_min_confidence(client): + _ingest(client, _SOURCE_A, [ + {"fact_type": "instruction", "confidence": 0.95, "payload": {"action": "sow", "subject": "tithonia", "steps": [], "timing": None}}, + {"fact_type": "medicinal", "confidence": 0.40, "payload": {"plant": "calendula", "use": "topical", "preparation": None, "cautions": None}}, + ]) + resp = client.get("/api/v1/knowledge?min_confidence=0.8") + assert resp.status_code == 200 + data = resp.json() + assert all(d["confidence"] >= 0.8 for d in data) + assert len(data) == 1 + + +def test_query_payload_is_dict_not_string(client): + _ingest(client, _SOURCE_A, [ + {"fact_type": "history_context", "confidence": 0.7, "payload": { + "subject": "Wisteria", "origin": "China", "period": None, "summary": "Introduced in 1816.", + }}, + ]) + resp = client.get("/api/v1/knowledge") + assert resp.status_code == 200 + payload = resp.json()[0]["payload"] + assert isinstance(payload, dict) + assert payload["subject"] == "Wisteria" + + +def test_get_single_fact_by_id(client): + _ingest(client, _SOURCE_A, [ + {"fact_type": "harvest_timing", "confidence": 0.88, "payload": { + "plant": "Courgette", "indicators": ["20cm length"], "harvest_method": "twist off", "post_harvest": None, + }}, + ]) + facts = client.get("/api/v1/knowledge").json() + fact_id = facts[0]["id"] + resp = client.get(f"/api/v1/knowledge/{fact_id}") + assert resp.status_code == 200 + assert resp.json()["payload"]["plant"] == "Courgette" + + +def test_get_fact_not_found(client): + resp = client.get("/api/v1/knowledge/9999") + assert resp.status_code == 404 diff --git a/tests/test_plants_crud.py b/tests/test_plants_crud.py new file mode 100644 index 0000000..35060c0 --- /dev/null +++ b/tests/test_plants_crud.py @@ -0,0 +1,88 @@ +"""Plant registry CRUD tests — covers the full create/read/update/delete cycle.""" +import pytest + + +def test_create_plant_minimal(client): + resp = client.post("/api/v1/plants", json={"common_name": "Tomato"}) + assert resp.status_code == 201 + data = resp.json() + assert data["common_name"] == "Tomato" + assert data["id"] is not None + assert data["variety"] is None + + +def test_create_plant_with_all_fields(client): + resp = client.post("/api/v1/plants", json={ + "common_name": "Tomato", + "latin_binomial": "Solanum lycopersicum", + "variety": "Brandywine", + "planting_date": "2026-05-01", + "days_to_maturity": 85, + "notes": "Heirloom, beefsteak type", + }) + assert resp.status_code == 201 + data = resp.json() + assert data["latin_binomial"] == "Solanum lycopersicum" + assert data["variety"] == "Brandywine" + assert data["days_to_maturity"] == 85 + + +def test_list_plants_empty(client): + resp = client.get("/api/v1/plants") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_list_plants_returns_created(client): + client.post("/api/v1/plants", json={"common_name": "Basil"}) + client.post("/api/v1/plants", json={"common_name": "Rosemary"}) + resp = client.get("/api/v1/plants") + assert resp.status_code == 200 + names = {p["common_name"] for p in resp.json()} + assert "Basil" in names + assert "Rosemary" in names + + +def test_get_plant_by_id(client): + create_resp = client.post("/api/v1/plants", json={"common_name": "Courgette"}) + plant_id = create_resp.json()["id"] + resp = client.get(f"/api/v1/plants/{plant_id}") + assert resp.status_code == 200 + assert resp.json()["common_name"] == "Courgette" + + +def test_get_plant_not_found(client): + resp = client.get("/api/v1/plants/9999") + assert resp.status_code == 404 + + +def test_update_plant_partial(client): + create_resp = client.post("/api/v1/plants", json={"common_name": "Pepper"}) + plant_id = create_resp.json()["id"] + resp = client.patch(f"/api/v1/plants/{plant_id}", json={"variety": "Padron"}) + assert resp.status_code == 200 + data = resp.json() + assert data["common_name"] == "Pepper" + assert data["variety"] == "Padron" + + +def test_delete_plant(client): + create_resp = client.post("/api/v1/plants", json={"common_name": "Mint"}) + plant_id = create_resp.json()["id"] + resp = client.delete(f"/api/v1/plants/{plant_id}") + assert resp.status_code == 204 + assert client.get(f"/api/v1/plants/{plant_id}").status_code == 404 + + +def test_plant_with_location(client): + loc = client.post("/api/v1/locations", json={"name": "Raised Bed 1"}).json() + resp = client.post("/api/v1/plants", json={ + "common_name": "Carrot", + "location_id": loc["id"], + }) + assert resp.status_code == 201 + assert resp.json()["location_id"] == loc["id"] + + filtered = client.get(f"/api/v1/plants?location_id={loc['id']}").json() + assert len(filtered) == 1 + assert filtered[0]["common_name"] == "Carrot"