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:
parent
c097d8ed7b
commit
5f5cb99db6
65 changed files with 3101 additions and 0 deletions
22
.env.example
Normal file
22
.env.example
Normal 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
36
.gitignore
vendored
Normal 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
24
Dockerfile
Normal 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
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/endpoints/__init__.py
Normal file
0
app/api/endpoints/__init__.py
Normal file
51
app/api/endpoints/grow_events.py
Normal file
51
app/api/endpoints/grow_events.py
Normal 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]
|
||||
8
app/api/endpoints/health.py
Normal file
8
app/api/endpoints/health.py
Normal 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"}
|
||||
100
app/api/endpoints/knowledge.py
Normal file
100
app/api/endpoints/knowledge.py
Normal 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
|
||||
51
app/api/endpoints/locations.py
Normal file
51
app/api/endpoints/locations.py
Normal 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()
|
||||
97
app/api/endpoints/plants.py
Normal file
97
app/api/endpoints/plants.py
Normal 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
10
app/api/routes.py
Normal 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
0
app/core/__init__.py
Normal file
37
app/core/config.py
Normal file
37
app/core/config.py
Normal 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
0
app/db/__init__.py
Normal file
15
app/db/migrations/001_locations.sql
Normal file
15
app/db/migrations/001_locations.sql
Normal 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);
|
||||
20
app/db/migrations/002_plants.sql
Normal file
20
app/db/migrations/002_plants.sql
Normal 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);
|
||||
21
app/db/migrations/003_grow_events.sql
Normal file
21
app/db/migrations/003_grow_events.sql
Normal 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);
|
||||
10
app/db/migrations/004_knowledge_sources.sql
Normal file
10
app/db/migrations/004_knowledge_sources.sql
Normal 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'))
|
||||
);
|
||||
24
app/db/migrations/005_knowledge_facts.sql
Normal file
24
app/db/migrations/005_knowledge_facts.sql
Normal 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
26
app/db/store.py
Normal 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
53
app/main.py
Normal 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
0
app/models/__init__.py
Normal file
0
app/models/schemas/__init__.py
Normal file
0
app/models/schemas/__init__.py
Normal file
30
app/models/schemas/grow_event.py
Normal file
30
app/models/schemas/grow_event.py
Normal 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
|
||||
68
app/models/schemas/knowledge.py
Normal file
68
app/models/schemas/knowledge.py
Normal 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
|
||||
30
app/models/schemas/location.py
Normal file
30
app/models/schemas/location.py
Normal 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
|
||||
45
app/models/schemas/plant.py
Normal file
45
app/models/schemas/plant.py
Normal 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
0
app/services/__init__.py
Normal file
0
app/services/knowledge/__init__.py
Normal file
0
app/services/knowledge/__init__.py
Normal file
121
app/services/knowledge/ingest.py
Normal file
121
app/services/knowledge/ingest.py
Normal 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
24
app/tiers.py
Normal 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
21
compose.yml
Normal 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
12
docker/web/Dockerfile
Normal 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
15
docker/web/nginx.conf
Normal 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
15
environment.yml
Normal 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
14
frontend/index.html
Normal 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
25
frontend/package.json
Normal 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
182
frontend/src/App.vue
Normal 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>
|
||||
65
frontend/src/components/KnowledgeFactCard.vue
Normal file
65
frontend/src/components/KnowledgeFactCard.vue
Normal 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>
|
||||
53
frontend/src/components/PlantCard.vue
Normal file
53
frontend/src/components/PlantCard.vue
Normal 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>
|
||||
91
frontend/src/components/PlantForm.vue
Normal file
91
frontend/src/components/PlantForm.vue
Normal 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
11
frontend/src/main.ts
Normal 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')
|
||||
18
frontend/src/router/index.ts
Normal file
18
frontend/src/router/index.ts
Normal 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
|
||||
16
frontend/src/services/api.ts
Normal file
16
frontend/src/services/api.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
57
frontend/src/stores/knowledge.ts
Normal file
57
frontend/src/stores/knowledge.ts
Normal 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 }
|
||||
})
|
||||
85
frontend/src/stores/plants.ts
Normal file
85
frontend/src/stores/plants.ts
Normal 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
141
frontend/src/style.css
Normal 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
627
frontend/src/theme.css
Normal 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;
|
||||
}
|
||||
9
frontend/src/views/CalendarView.vue
Normal file
9
frontend/src/views/CalendarView.vue
Normal 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>
|
||||
77
frontend/src/views/KnowledgeView.vue
Normal file
77
frontend/src/views/KnowledgeView.vue
Normal 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>
|
||||
86
frontend/src/views/RegistryView.vue
Normal file
86
frontend/src/views/RegistryView.vue
Normal 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>
|
||||
42
frontend/src/views/SettingsView.vue
Normal file
42
frontend/src/views/SettingsView.vue
Normal 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>
|
||||
13
frontend/tsconfig.app.json
Normal file
13
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
8
frontend/tsconfig.node.json
Normal file
8
frontend/tsconfig.node.json
Normal 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
20
frontend/vite.config.ts
Normal 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
69
manage.sh
Executable 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
31
pyproject.toml
Normal 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
0
tests/__init__.py
Normal file
35
tests/conftest.py
Normal file
35
tests/conftest.py
Normal 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
6
tests/test_health.py
Normal 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"
|
||||
144
tests/test_knowledge_ingest.py
Normal file
144
tests/test_knowledge_ingest.py
Normal 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"
|
||||
95
tests/test_knowledge_query.py
Normal file
95
tests/test_knowledge_query.py
Normal 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
88
tests/test_plants_crud.py
Normal 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"
|
||||
Loading…
Reference in a new issue