waxwing/app/services/knowledge/ingest.py
pyr0ball 5f5cb99db6 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
2026-06-10 00:03:41 -07:00

121 lines
4.1 KiB
Python

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