feat: bootstrap Waxwing Phase 1 scaffold

Plant registry, grow event calendar, and gardening knowledge base
populated via cf-harvest ingest endpoint. All 23 tests passing.

Stack:
- FastAPI + SQLite (cf-core migrations) on port 8522
- Vue 3 + Vite + Pinia SPA on port 8521
- 5 SQL migrations: locations → plants → grow_events, knowledge_sources → knowledge_facts
- Earthy green theme (--color-primary: #4a7c59) matching CF theme system

Knowledge ingest contract:
- POST /api/v1/knowledge/ingest — idempotent on fact_hash (sha256 of type+payload+video_path)
- 8 fact types: companion_planting, soil_amendment, propagation, pest_diagnosis,
  harvest_timing, instruction, medicinal, history_context
- Partial batch failure: malformed facts collected in rejected[], valid facts inserted
- Re-ingesting same video batch: facts_skipped_duplicate > 0, no duplicates

Wired views: RegistryView (plant CRUD), KnowledgeView (facts + type filter chips)
Stub views: CalendarView, SettingsView
This commit is contained in:
pyr0ball 2026-06-10 00:03:41 -07:00
parent c097d8ed7b
commit 5f5cb99db6
65 changed files with 3101 additions and 0 deletions

22
.env.example Normal file
View file

@ -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

36
.gitignore vendored Normal file
View file

@ -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
<MagicMock*
# Node / frontend build
frontend/node_modules/
frontend/dist/
# Docker
.dockerignore
# OS
.DS_Store
*.swp

24
Dockerfile Normal file
View file

@ -0,0 +1,24 @@
FROM continuumio/miniconda3:latest
WORKDIR /app
# Install circuitforge-core from sibling directory
COPY circuitforge-core/ ./circuitforge-core/
RUN conda run -n base pip install --no-cache-dir -e ./circuitforge-core
# Create waxwing conda env (named cf for consistency with host convention)
COPY waxwing/environment.yml .
RUN conda env create -f environment.yml || conda run -n cf pip install --no-cache-dir \
"fastapi>=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"]

0
app/__init__.py Normal file
View file

0
app/api/__init__.py Normal file
View file

View file

View file

@ -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]

View file

@ -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"}

View file

@ -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

View file

@ -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()

View file

@ -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()

10
app/api/routes.py Normal file
View file

@ -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"])

0
app/core/__init__.py Normal file
View file

37
app/core/config.py Normal file
View file

@ -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()

0
app/db/__init__.py Normal file
View file

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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'))
);

View file

@ -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);

26
app/db/store.py Normal file
View file

@ -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)

53
app/main.py Normal file
View file

@ -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"}

0
app/models/__init__.py Normal file
View file

View file

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

0
app/services/__init__.py Normal file
View file

View file

View file

@ -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,
)

24
app/tiers.py Normal file
View file

@ -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

21
compose.yml Normal file
View file

@ -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

12
docker/web/Dockerfile Normal file
View file

@ -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;"]

15
docker/web/nginx.conf Normal file
View file

@ -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;
}
}

15
environment.yml Normal file
View file

@ -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

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Waxwing — your garden knowledge companion" />
<title>Waxwing</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

25
frontend/package.json Normal file
View file

@ -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"
}
}

182
frontend/src/App.vue Normal file
View file

@ -0,0 +1,182 @@
<template>
<div id="waxwing-app">
<nav class="sidebar" role="navigation" aria-label="Main navigation">
<div class="sidebar-header">
<span class="wordmark-waxwing" aria-label="Waxwing">Waxwing</span>
</div>
<ul class="sidebar-nav" role="list">
<li v-for="item in navItems" :key="item.path">
<RouterLink
:to="item.path"
class="sidebar-item"
:class="{ active: route.path.startsWith(item.path) }"
:aria-label="item.label"
>
<span class="sidebar-icon" aria-hidden="true">{{ item.icon }}</span>
<span class="sidebar-label">{{ item.label }}</span>
</RouterLink>
</li>
</ul>
</nav>
<main class="main-content" id="main-content" tabindex="-1">
<RouterView />
</main>
<nav class="bottom-nav" role="navigation" aria-label="Mobile navigation">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: route.path.startsWith(item.path) }"
:aria-label="item.label"
>
<span class="nav-icon" aria-hidden="true">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</RouterLink>
</nav>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const navItems = [
{ path: '/registry', label: 'Garden', icon: '🌱' },
{ path: '/knowledge', label: 'Knowledge', icon: '📖' },
{ path: '/calendar', label: 'Calendar', icon: '📅' },
{ path: '/settings', label: 'Settings', icon: '⚙️' },
]
</script>
<style scoped>
#waxwing-app {
display: flex;
min-height: 100vh;
}
/* Sidebar — desktop */
.sidebar {
width: 200px;
flex-shrink: 0;
background: var(--color-bg-secondary);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 100;
}
.sidebar-header {
padding: var(--spacing-lg) var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.wordmark-waxwing {
font-family: var(--font-display);
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--color-primary-light);
letter-spacing: -0.02em;
}
.sidebar-nav {
list-style: none;
padding: var(--spacing-sm) 0;
flex: 1;
}
.sidebar-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
color: var(--color-text-secondary);
text-decoration: none;
border-radius: var(--radius-md);
margin: 2px var(--spacing-xs);
transition: all 0.15s ease;
font-size: var(--font-size-sm);
font-weight: 500;
}
.sidebar-item:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
text-decoration: none;
}
.sidebar-item.active {
background: var(--color-success-bg);
color: var(--color-primary-light);
font-weight: 600;
border: 1px solid var(--color-success-border);
}
.sidebar-icon { font-size: 1.1em; }
.sidebar-label { font-size: var(--font-size-sm); }
/* Main content */
.main-content {
margin-left: 200px;
flex: 1;
padding: var(--spacing-lg);
min-height: 100vh;
}
/* Bottom nav — mobile only */
.bottom-nav {
display: none;
}
/* Mobile layout */
@media (max-width: 768px) {
.sidebar { display: none; }
.main-content {
margin-left: 0;
padding: var(--spacing-md);
padding-bottom: calc(var(--spacing-md) + 64px);
}
.bottom-nav {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
z-index: 100;
height: 64px;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: var(--color-text-muted);
text-decoration: none;
font-size: var(--font-size-xs);
transition: color 0.15s ease;
}
.nav-item:hover,
.nav-item.active {
color: var(--color-primary-light);
text-decoration: none;
}
.nav-icon { font-size: 1.4em; }
.nav-label { font-size: 0.65rem; }
}
</style>

View file

@ -0,0 +1,65 @@
<template>
<article class="card card-sm knowledge-card fade-in" :aria-label="`${fact.fact_type} fact`">
<header class="flex-between mb-sm">
<div class="flex gap-sm align-center">
<span :class="['status-badge', typeClass]">{{ factLabel }}</span>
<span v-if="fact.subject" class="text-sm font-semibold text-primary">{{ fact.subject }}</span>
</div>
<span class="text-xs text-muted">{{ confidencePct }}%</span>
</header>
<div class="payload-summary text-sm text-secondary mb-sm">
{{ summary }}
</div>
<footer class="flex-between">
<div class="flex gap-sm text-xs text-muted">
<span v-if="fact.presenter">{{ fact.presenter }}</span>
<span v-if="fact.location">· {{ fact.location }}</span>
</div>
</footer>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { KnowledgeFact } from '@/stores/knowledge'
const props = defineProps<{ fact: KnowledgeFact }>()
const TYPE_META: Record<string, { label: string; cls: string }> = {
companion_planting: { label: 'Companions', cls: 'status-success' },
soil_amendment: { label: 'Soil', cls: 'status-info' },
propagation: { label: 'Propagation', cls: 'status-success' },
pest_diagnosis: { label: 'Pest', cls: 'status-warning' },
harvest_timing: { label: 'Harvest', cls: 'status-info' },
instruction: { label: 'How-to', cls: 'status-neutral' },
medicinal: { label: 'Medicinal', cls: 'status-success' },
history_context: { label: 'History', cls: 'status-neutral' },
}
const meta = computed(() => TYPE_META[props.fact.fact_type] ?? { label: props.fact.fact_type, cls: 'status-neutral' })
const factLabel = computed(() => meta.value.label)
const typeClass = computed(() => meta.value.cls)
const confidencePct = computed(() => Math.round(props.fact.confidence * 100))
const summary = computed(() => {
const p = props.fact.payload
switch (props.fact.fact_type) {
case 'companion_planting': return `${(p.plants as string[])?.join(' + ')}${p.relationship}`
case 'soil_amendment': return `${p.amendment}: ${p.purpose}`
case 'propagation': return `${p.plant} by ${p.method} (${p.timing})`
case 'pest_diagnosis': return `${p.pest_or_disease}: ${p.treatment}`
case 'harvest_timing': return `${p.plant}: ${(p.indicators as string[])?.join(', ')}`
case 'instruction': return `${p.action} ${p.subject}${p.timing ? `${p.timing}` : ''}`
case 'medicinal': return `${p.plant}: ${p.use}`
case 'history_context': return `${p.subject}: ${p.summary}`
default: return JSON.stringify(p).slice(0, 120)
}
})
</script>
<style scoped>
.knowledge-card { cursor: default; }
.payload-summary { line-height: 1.5; }
</style>

View file

@ -0,0 +1,53 @@
<template>
<article class="card card-sm plant-card" :aria-label="plant.common_name">
<header class="flex-between mb-sm">
<div>
<h3 class="plant-name text-base font-semibold">{{ plant.common_name }}</h3>
<p v-if="plant.variety" class="text-xs text-muted">{{ plant.variety }}</p>
<p v-if="plant.latin_binomial" class="text-xs text-secondary" style="font-style: italic">
{{ plant.latin_binomial }}
</p>
</div>
<div class="flex gap-xs">
<button class="btn-icon" @click="$emit('edit', plant)" title="Edit plant" aria-label="Edit plant">
</button>
<button
class="btn-icon btn-icon-danger"
@click="$emit('delete', plant.id)"
title="Delete plant"
aria-label="Delete plant"
>
🗑
</button>
</div>
</header>
<div class="plant-meta flex-wrap gap-xs">
<span v-if="plant.planting_date" class="status-badge status-info text-xs">
Planted {{ plant.planting_date }}
</span>
<span v-if="plant.days_to_maturity" class="status-badge status-neutral text-xs">
{{ plant.days_to_maturity }}d to maturity
</span>
</div>
<p v-if="plant.notes" class="text-sm text-secondary mt-sm">{{ plant.notes }}</p>
</article>
</template>
<script setup lang="ts">
import type { Plant } from '@/stores/plants'
defineProps<{ plant: Plant }>()
defineEmits<{
edit: [plant: Plant]
delete: [id: number]
}>()
</script>
<style scoped>
.plant-card { transition: box-shadow 0.2s ease; }
.plant-card:hover { box-shadow: var(--shadow-lg); }
.plant-name { color: var(--color-text-primary); }
</style>

View file

@ -0,0 +1,91 @@
<template>
<div class="modal-backdrop" @click.self="$emit('cancel')" role="dialog" aria-modal="true" :aria-label="plant ? 'Edit plant' : 'Add plant'">
<div class="modal-panel card slide-up">
<h2 class="section-title mb-lg">{{ plant ? 'Edit Plant' : 'Add Plant' }}</h2>
<form @submit.prevent="submit">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="common_name">Common Name *</label>
<input id="common_name" v-model="form.common_name" class="form-input" required />
</div>
<div class="form-group">
<label class="form-label" for="variety">Variety</label>
<input id="variety" v-model="form.variety" class="form-input" placeholder="e.g. Brandywine" />
</div>
</div>
<div class="form-group">
<label class="form-label" for="latin_binomial">Latin Name</label>
<input id="latin_binomial" v-model="form.latin_binomial" class="form-input" placeholder="e.g. Solanum lycopersicum" />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="planting_date">Planting Date</label>
<input id="planting_date" v-model="form.planting_date" class="form-input" type="date" />
</div>
<div class="form-group">
<label class="form-label" for="days_to_maturity">Days to Maturity</label>
<input id="days_to_maturity" v-model.number="form.days_to_maturity" class="form-input" type="number" min="1" max="365" />
</div>
</div>
<div class="form-group">
<label class="form-label" for="notes">Notes</label>
<textarea id="notes" v-model="form.notes" class="form-textarea" rows="3" placeholder="Any growing notes..."></textarea>
</div>
<div class="flex-end gap-sm mt-md">
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">Cancel</button>
<button type="submit" class="btn btn-primary">{{ plant ? 'Save Changes' : 'Add Plant' }}</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import type { Plant, CreatePlantInput } from '@/stores/plants'
const props = defineProps<{ plant?: Plant | null }>()
const emit = defineEmits<{
save: [data: CreatePlantInput]
cancel: []
}>()
const form = reactive<CreatePlantInput>({
common_name: props.plant?.common_name ?? '',
variety: props.plant?.variety ?? null,
latin_binomial: props.plant?.latin_binomial ?? null,
planting_date: props.plant?.planting_date ?? null,
days_to_maturity: props.plant?.days_to_maturity ?? null,
notes: props.plant?.notes ?? null,
location_id: props.plant?.location_id ?? null,
})
function submit() {
emit('save', { ...form })
}
</script>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 560px;
max-height: 90vh;
overflow-y: auto;
}
</style>

11
frontend/src/main.ts Normal file
View file

@ -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')

View file

@ -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

View file

@ -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)
}
)

View file

@ -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<string, unknown>
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<KnowledgeFact[]>([])
const loading = ref(false)
const error = ref<string | null>(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 }
})

View file

@ -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<Plant[]>([])
const loading = ref(false)
const error = ref<string | null>(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<Plant | null> {
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<CreatePlantInput>): Promise<Plant | null> {
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<boolean> {
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 }
})

141
frontend/src/style.css Normal file
View file

@ -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);
}

627
frontend/src/theme.css Normal file
View file

@ -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;
}

View file

@ -0,0 +1,9 @@
<template>
<div class="calendar-view fade-in">
<h1 class="section-title mb-lg">Garden Calendar</h1>
<div class="card text-center p-xl">
<p class="text-muted mb-sm">Grow event calendar coming in Phase 2.</p>
<p class="text-xs text-muted">Track sowings, waterings, harvests and more.</p>
</div>
</div>
</template>

View file

@ -0,0 +1,77 @@
<template>
<div class="knowledge-view fade-in">
<header class="view-header mb-lg">
<h1 class="section-title mb-md">Garden Knowledge</h1>
<div class="filter-chip-row" role="toolbar" aria-label="Filter by fact type">
<button
v-for="ft in factTypes"
:key="ft.value ?? 'all'"
class="btn-chip"
:class="{ active: activeType === ft.value }"
@click="setFilter(ft.value)"
>
{{ ft.label }}
</button>
</div>
</header>
<div v-if="store.loading" class="flex-center p-xl">
<div class="spinner" role="status" aria-label="Loading knowledge"></div>
</div>
<div v-else-if="store.error" class="card card-error mb-md">
<p class="text-error text-sm">{{ store.error }}</p>
</div>
<div v-else-if="store.facts.length === 0" class="empty-state card text-center">
<p class="text-muted">No knowledge facts yet. Process a gardening video with cf-harvest to populate this library.</p>
</div>
<div v-else class="grid-auto">
<KnowledgeFactCard
v-for="fact in store.facts"
:key="fact.id"
:fact="fact"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useKnowledgeStore, type FactType } from '@/stores/knowledge'
import KnowledgeFactCard from '@/components/KnowledgeFactCard.vue'
const store = useKnowledgeStore()
const activeType = ref<FactType | undefined>(undefined)
const factTypes = [
{ value: undefined as FactType | undefined, label: 'All' },
{ value: 'companion_planting' as FactType, label: 'Companions' },
{ value: 'soil_amendment' as FactType, label: 'Soil' },
{ value: 'propagation' as FactType, label: 'Propagation' },
{ value: 'pest_diagnosis' as FactType, label: 'Pests' },
{ value: 'harvest_timing' as FactType, label: 'Harvest' },
{ value: 'instruction' as FactType, label: 'How-to' },
{ value: 'medicinal' as FactType, label: 'Medicinal' },
{ value: 'history_context' as FactType, label: 'History' },
]
onMounted(() => store.fetchFacts())
function setFilter(type: FactType | undefined) {
activeType.value = type
store.fetchFacts(type ? { fact_type: type } : {})
}
</script>
<style scoped>
.view-header h1 {
font-size: var(--font-size-xl);
}
.empty-state {
max-width: 480px;
margin: var(--spacing-2xl) auto;
}
</style>

View file

@ -0,0 +1,86 @@
<template>
<div class="registry-view fade-in">
<header class="view-header flex-between mb-lg">
<h1 class="section-title">Garden Registry</h1>
<button class="btn btn-primary btn-sm" @click="showAddForm = true">
+ Add Plant
</button>
</header>
<div v-if="store.loading" class="flex-center p-xl">
<div class="spinner" role="status" aria-label="Loading plants"></div>
</div>
<div v-else-if="store.error" class="card card-error mb-md">
<p class="text-error text-sm">{{ store.error }}</p>
</div>
<div v-else-if="store.plants.length === 0" class="empty-state card text-center">
<p class="text-muted mb-md">No plants yet. Add your first to get started.</p>
<button class="btn btn-primary" @click="showAddForm = true">Add Plant</button>
</div>
<div v-else class="grid-auto">
<PlantCard
v-for="plant in store.plants"
:key="plant.id"
:plant="plant"
@edit="editPlant"
@delete="deletePlant"
/>
</div>
<PlantForm
v-if="showAddForm || editTarget"
:plant="editTarget"
@save="onSave"
@cancel="closeForm"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { usePlantsStore, type Plant } from '@/stores/plants'
import PlantCard from '@/components/PlantCard.vue'
import PlantForm from '@/components/PlantForm.vue'
const store = usePlantsStore()
const showAddForm = ref(false)
const editTarget = ref<Plant | null>(null)
onMounted(() => store.fetchAll())
function editPlant(plant: Plant) {
editTarget.value = plant
}
async function deletePlant(id: number) {
await store.remove(id)
}
async function onSave(data: any) {
if (editTarget.value) {
await store.update(editTarget.value.id, data)
} else {
await store.create(data)
}
closeForm()
}
function closeForm() {
showAddForm.value = false
editTarget.value = null
}
</script>
<style scoped>
.view-header h1 {
font-size: var(--font-size-xl);
}
.empty-state {
max-width: 400px;
margin: var(--spacing-2xl) auto;
}
</style>

View file

@ -0,0 +1,42 @@
<template>
<div class="settings-view fade-in">
<h1 class="section-title mb-lg">Settings</h1>
<div class="card">
<div class="form-group">
<label class="form-label" for="coordinator-url">Coordinator URL</label>
<input
id="coordinator-url"
v-model="coordinatorUrl"
class="form-input"
type="url"
placeholder="http://localhost:7700"
/>
<span class="form-hint">cf-orch coordinator endpoint for LLM inference scheduling</span>
</div>
<div class="form-group">
<label class="form-label" for="license-key">License Key</label>
<input
id="license-key"
v-model="licenseKey"
class="form-input"
type="text"
placeholder="CFG-WXWG-XXXX-XXXX-XXXX"
/>
<span class="form-hint">Unlocks Paid and Premium features</span>
</div>
<button class="btn btn-primary" @click="save">Save</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const coordinatorUrl = ref(localStorage.getItem('waxwing_coordinator_url') ?? '')
const licenseKey = ref(localStorage.getItem('waxwing_license_key') ?? '')
function save() {
localStorage.setItem('waxwing_coordinator_url', coordinatorUrl.value)
localStorage.setItem('waxwing_license_key', licenseKey.value)
}
</script>

View file

@ -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/*"]
}
}
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": ["vite.config.ts"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

20
frontend/vite.config.ts Normal file
View file

@ -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,
},
},
},
})

69
manage.sh Executable file
View file

@ -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

31
pyproject.toml Normal file
View file

@ -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"]

0
tests/__init__.py Normal file
View file

35
tests/conftest.py Normal file
View file

@ -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)

6
tests/test_health.py Normal file
View file

@ -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"

View file

@ -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"

View file

@ -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

88
tests/test_plants_crud.py Normal file
View file

@ -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"