From 8840809a855acd101bed82fe57723e359c81bcfa Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 08:26:35 -0700 Subject: [PATCH 01/11] chore: add .worktrees to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 3613d6b..c4024fc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ __pycache__/ .turnstone-api.pid web/node_modules/ web/dist/ +.superpowers/ +.worktrees/ From abd142addfcdf0628f81d048db0779fa2c3589eb Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 09:04:50 -0700 Subject: [PATCH 02/11] feat: add diagnose service with NL time extraction via dateparser Adds app/services/diagnose.py with parse_time_window() (dateparser-backed NL time phrase extraction with 60-min fallback) and diagnose() (layered FTS + window search returning severity/source summary). Includes 5 TDD tests. --- app/services/diagnose.py | 93 +++++++++++++++++++++++++++++++++ requirements.txt | 1 + tests/test_services_diagnose.py | 52 ++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 app/services/diagnose.py create mode 100644 tests/test_services_diagnose.py diff --git a/app/services/diagnose.py b/app/services/diagnose.py new file mode 100644 index 0000000..d4a7073 --- /dev/null +++ b/app/services/diagnose.py @@ -0,0 +1,93 @@ +"""Frictionless diagnose service — NL time extraction + layered log search.""" +from __future__ import annotations + +import re +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app.services.search import SearchResult, entries_in_window, search + +try: + from dateparser.search import search_dates as _search_dates # type: ignore[import] + _HAS_DATEPARSER = True +except ImportError: + _search_dates = None # type: ignore[assignment] + _HAS_DATEPARSER = False + + +def parse_time_window(query: str) -> tuple[str | None, str | None, str]: + """Extract a time window from a natural-language query string. + + Returns (since_iso, until_iso, keywords) where keywords is the query with + the matched time phrase stripped. Falls back to last-60-min window. + """ + if _HAS_DATEPARSER and _search_dates is not None: + results = _search_dates(query, languages=["en"], settings={"PREFER_DATES_FROM": "past"}) + if results: + phrase, dt = results[0] + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + since = (dt - timedelta(minutes=30)).isoformat() + until = (dt + timedelta(minutes=30)).isoformat() + keywords = re.sub(r"\s{2,}", " ", query.replace(phrase, " ").strip()) + return since, until, keywords or query + + return _last_n_minutes(60), _now_iso(), query + + +def diagnose( + db_path: Path, + query: str, + since: str | None = None, + until: str | None = None, +) -> dict: + """Run layered log search with NL time extraction. Returns summary + entries.""" + time_detected = since is not None or until is not None + if since is None or until is None: + parsed_since, parsed_until, keywords = parse_time_window(query) + since = since or parsed_since + until = until or parsed_until + time_detected = keywords != query + else: + keywords = query + + keyword_hits = search(db_path, query=keywords, since=since, until=until, limit=150, or_mode=True) + window_hits = entries_in_window(db_path, since=since, until=until, limit=50) + + seen: set[str] = set() + combined: list[SearchResult] = [] + for r in keyword_hits + window_hits: + if r.entry_id not in seen: + seen.add(r.entry_id) + combined.append(r) + + combined.sort(key=lambda r: (r.timestamp_iso or "\xff", r.sequence)) + combined = combined[:200] + + by_severity: dict[str, int] = {"CRITICAL": 0, "ERROR": 0, "WARN": 0, "INFO": 0} + by_source: dict[str, int] = {} + for r in combined: + sev = (r.severity or "INFO").upper() + if sev in by_severity: + by_severity[sev] += 1 + by_source[r.source_id] = by_source.get(r.source_id, 0) + 1 + + return { + "summary": { + "total": len(combined), + "window_start": since, + "window_end": until, + "time_detected": time_detected, + "by_severity": by_severity, + "by_source": by_source, + }, + "entries": combined, + } + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _last_n_minutes(n: int) -> str: + return (datetime.now(timezone.utc) - timedelta(minutes=n)).isoformat() diff --git a/requirements.txt b/requirements.txt index 9408a53..14a443b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ pydantic>=2.0.0 pyyaml>=6.0 aiofiles>=23.0.0 python-multipart>=0.0.9 +dateparser>=1.2.0 diff --git a/tests/test_services_diagnose.py b/tests/test_services_diagnose.py new file mode 100644 index 0000000..8a2fc66 --- /dev/null +++ b/tests/test_services_diagnose.py @@ -0,0 +1,52 @@ +"""Tests for app/services/diagnose.py — parse_time_window.""" +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import patch + + +from app.services.diagnose import parse_time_window + + +def test_no_time_phrase_falls_back_to_last_60_min(): + with patch("app.services.diagnose._HAS_DATEPARSER", True), \ + patch("app.services.diagnose._search_dates", return_value=None): + since, until, keywords = parse_time_window("plex stopped playing audio") + assert since is not None and until is not None + assert keywords == "plex stopped playing audio" + diff = (datetime.fromisoformat(until) - datetime.fromisoformat(since)).total_seconds() + assert abs(diff - 3600) < 5 + + +def test_time_phrase_detected_produces_30min_window(): + dt = datetime(2026, 5, 11, 14, 0, tzinfo=timezone.utc) + with patch("app.services.diagnose._HAS_DATEPARSER", True), \ + patch("app.services.diagnose._search_dates", return_value=[("around 2pm", dt)]): + since, until, keywords = parse_time_window("plex stopped audio around 2pm") + diff = (datetime.fromisoformat(until) - datetime.fromisoformat(since)).total_seconds() + assert abs(diff - 3600) < 5 + + +def test_time_phrase_stripped_from_keywords(): + dt = datetime(2026, 5, 11, 14, 0, tzinfo=timezone.utc) + with patch("app.services.diagnose._HAS_DATEPARSER", True), \ + patch("app.services.diagnose._search_dates", return_value=[("around 2pm", dt)]): + _, _, keywords = parse_time_window("plex stopped audio around 2pm") + assert "around 2pm" not in keywords + assert "plex" in keywords + + +def test_no_dateparser_falls_back_to_60min(): + with patch("app.services.diagnose._HAS_DATEPARSER", False): + since, until, keywords = parse_time_window("plex stopped playing audio") + assert keywords == "plex stopped playing audio" + diff = (datetime.fromisoformat(until) - datetime.fromisoformat(since)).total_seconds() + assert abs(diff - 3600) < 5 + + +def test_keywords_cleaned_of_extra_spaces(): + dt = datetime(2026, 5, 11, 14, 0, tzinfo=timezone.utc) + with patch("app.services.diagnose._HAS_DATEPARSER", True), \ + patch("app.services.diagnose._search_dates", return_value=[("around 2pm", dt)]): + _, _, keywords = parse_time_window("plex stopped audio around 2pm extra") + assert " " not in keywords From ca0cb1361e5083bd3fe5445b7bba4f4c95a175dc Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 09:08:24 -0700 Subject: [PATCH 03/11] fix: correct time_detected logic, immutable sort pattern, add diagnose() test --- app/services/diagnose.py | 23 +++++++++++++++-------- tests/test_services_diagnose.py | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/app/services/diagnose.py b/app/services/diagnose.py index d4a7073..8665b15 100644 --- a/app/services/diagnose.py +++ b/app/services/diagnose.py @@ -1,12 +1,16 @@ """Frictionless diagnose service — NL time extraction + layered log search.""" from __future__ import annotations +import logging import re from datetime import datetime, timedelta, timezone from pathlib import Path +from typing import Any from app.services.search import SearchResult, entries_in_window, search +logger = logging.getLogger(__name__) + try: from dateparser.search import search_dates as _search_dates # type: ignore[import] _HAS_DATEPARSER = True @@ -22,7 +26,11 @@ def parse_time_window(query: str) -> tuple[str | None, str | None, str]: the matched time phrase stripped. Falls back to last-60-min window. """ if _HAS_DATEPARSER and _search_dates is not None: - results = _search_dates(query, languages=["en"], settings={"PREFER_DATES_FROM": "past"}) + try: + results = _search_dates(query, languages=["en"], settings={"PREFER_DATES_FROM": "past"}) + except Exception: + logger.warning("dateparser failed on query %r — falling back to 60-min window", query) + results = None if results: phrase, dt = results[0] if dt.tzinfo is None: @@ -40,10 +48,10 @@ def diagnose( query: str, since: str | None = None, until: str | None = None, -) -> dict: +) -> dict[str, Any]: """Run layered log search with NL time extraction. Returns summary + entries.""" - time_detected = since is not None or until is not None - if since is None or until is None: + time_detected = since is not None and until is not None + if not time_detected: parsed_since, parsed_until, keywords = parse_time_window(query) since = since or parsed_since until = until or parsed_until @@ -55,14 +63,13 @@ def diagnose( window_hits = entries_in_window(db_path, since=since, until=until, limit=50) seen: set[str] = set() - combined: list[SearchResult] = [] + merged: list[SearchResult] = [] for r in keyword_hits + window_hits: if r.entry_id not in seen: seen.add(r.entry_id) - combined.append(r) + merged.append(r) - combined.sort(key=lambda r: (r.timestamp_iso or "\xff", r.sequence)) - combined = combined[:200] + combined = sorted(merged, key=lambda r: (r.timestamp_iso or "\xff", r.sequence))[:200] by_severity: dict[str, int] = {"CRITICAL": 0, "ERROR": 0, "WARN": 0, "INFO": 0} by_source: dict[str, int] = {} diff --git a/tests/test_services_diagnose.py b/tests/test_services_diagnose.py index 8a2fc66..2cb0e01 100644 --- a/tests/test_services_diagnose.py +++ b/tests/test_services_diagnose.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from unittest.mock import patch -from app.services.diagnose import parse_time_window +from app.services.diagnose import diagnose, parse_time_window def test_no_time_phrase_falls_back_to_last_60_min(): @@ -18,7 +18,7 @@ def test_no_time_phrase_falls_back_to_last_60_min(): assert abs(diff - 3600) < 5 -def test_time_phrase_detected_produces_30min_window(): +def test_time_phrase_detected_produces_60min_window(): dt = datetime(2026, 5, 11, 14, 0, tzinfo=timezone.utc) with patch("app.services.diagnose._HAS_DATEPARSER", True), \ patch("app.services.diagnose._search_dates", return_value=[("around 2pm", dt)]): @@ -37,7 +37,8 @@ def test_time_phrase_stripped_from_keywords(): def test_no_dateparser_falls_back_to_60min(): - with patch("app.services.diagnose._HAS_DATEPARSER", False): + with patch("app.services.diagnose._HAS_DATEPARSER", False), \ + patch("app.services.diagnose._search_dates", None): since, until, keywords = parse_time_window("plex stopped playing audio") assert keywords == "plex stopped playing audio" diff = (datetime.fromisoformat(until) - datetime.fromisoformat(since)).total_seconds() @@ -50,3 +51,13 @@ def test_keywords_cleaned_of_extra_spaces(): patch("app.services.diagnose._search_dates", return_value=[("around 2pm", dt)]): _, _, keywords = parse_time_window("plex stopped audio around 2pm extra") assert " " not in keywords + + +def test_diagnose_with_explicit_window_sets_time_detected(tmp_path): + from app.ingest.pipeline import ensure_schema + db = tmp_path / "test.db" + ensure_schema(db) + result = diagnose(db, query="plex", since="2026-05-11T14:00:00+00:00", until="2026-05-11T15:00:00+00:00") + assert result["summary"]["time_detected"] is True + assert result["summary"]["total"] == 0 # empty DB + assert result["summary"]["window_start"] == "2026-05-11T14:00:00+00:00" From 05ad314ed5676b98f9d809861cfe8fc9b5cb56f3 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 09:10:58 -0700 Subject: [PATCH 04/11] feat: add POST /api/diagnose and GET/PATCH /api/settings endpoints --- app/rest.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/app/rest.py b/app/rest.py index 9da8a62..98d8461 100644 --- a/app/rest.py +++ b/app/rest.py @@ -39,8 +39,10 @@ from app.services.search import ( stats_summary as _stats, format_results, ) +from app.services.diagnose import diagnose as _diagnose DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db")) +PREFS_PATH = DB_PATH.parent / "preferences.json" DIST_DIR = Path(__file__).parent.parent / "web" / "dist" SOURCE_HOST = os.environ.get("TURNSTONE_SOURCE_HOST", "unknown") BUNDLE_ENDPOINT = os.environ.get("TURNSTONE_BUNDLE_ENDPOINT", "") @@ -50,7 +52,7 @@ app = FastAPI(title="Turnstone API", version="0.1.0", docs_url="/turnstone/docs" app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_methods=["GET", "POST", "DELETE"], + allow_methods=["GET", "POST", "DELETE", "PATCH"], allow_headers=["*"], ) @@ -60,6 +62,29 @@ def _startup() -> None: ensure_schema(DB_PATH) +def _load_prefs() -> dict[str, str]: + if PREFS_PATH.exists(): + try: + return json.loads(PREFS_PATH.read_text()) + except (json.JSONDecodeError, OSError): + pass + return {"entry_point_style": "topbar"} + + +def _save_prefs(data: dict[str, str]) -> None: + PREFS_PATH.write_text(json.dumps(data)) + + +class DiagnoseRequest(BaseModel): + query: str + since: str | None = None + until: str | None = None + + +class SettingsBody(BaseModel): + entry_point_style: str + + class IncidentCreate(BaseModel): label: str issue_type: str = "" @@ -167,6 +192,38 @@ def diagnose( } +@router.post("/api/diagnose") +def diagnose_post(body: DiagnoseRequest) -> dict: + if not body.query.strip(): + return { + "summary": { + "total": 0, "window_start": None, "window_end": None, + "time_detected": False, "by_severity": {}, "by_source": {}, + }, + "entries": [], + } + result = _diagnose(DB_PATH, query=body.query, since=body.since, until=body.until) + return { + "summary": result["summary"], + "entries": [dataclasses.asdict(r) for r in result["entries"]], + } + + +@router.get("/api/settings") +def get_settings() -> dict: + return _load_prefs() + + +@router.patch("/api/settings") +def patch_settings(body: SettingsBody) -> dict: + if body.entry_point_style not in ("topbar", "fab"): + raise HTTPException(status_code=422, detail="entry_point_style must be 'topbar' or 'fab'") + prefs = _load_prefs() + prefs["entry_point_style"] = body.entry_point_style + _save_prefs(prefs) + return prefs + + @router.get("/api/sources") def list_sources() -> dict: return {"sources": _list_sources(DB_PATH)} From 6f17f74fe15d1a4a57aede2a030a2aea9c807ee5 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 09:15:25 -0700 Subject: [PATCH 05/11] feat: add QuickCapture and IncidentForm components --- web/src/components/IncidentForm.vue | 222 ++++++++++++++++++++++++ web/src/components/QuickCapture.vue | 250 ++++++++++++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 web/src/components/IncidentForm.vue create mode 100644 web/src/components/QuickCapture.vue diff --git a/web/src/components/IncidentForm.vue b/web/src/components/IncidentForm.vue new file mode 100644 index 0000000..ea6b466 --- /dev/null +++ b/web/src/components/IncidentForm.vue @@ -0,0 +1,222 @@ + + + diff --git a/web/src/components/QuickCapture.vue b/web/src/components/QuickCapture.vue new file mode 100644 index 0000000..76dd91e --- /dev/null +++ b/web/src/components/QuickCapture.vue @@ -0,0 +1,250 @@ + + + From ba6995e2790673649b0218d75fb8ec5b06677af4 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 09:16:33 -0700 Subject: [PATCH 06/11] feat: add QuickCaptureBar and QuickCaptureFab entry point components --- web/src/components/QuickCaptureBar.vue | 31 +++++++++++++ web/src/components/QuickCaptureFab.vue | 60 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 web/src/components/QuickCaptureBar.vue create mode 100644 web/src/components/QuickCaptureFab.vue diff --git a/web/src/components/QuickCaptureBar.vue b/web/src/components/QuickCaptureBar.vue new file mode 100644 index 0000000..1fa80cf --- /dev/null +++ b/web/src/components/QuickCaptureBar.vue @@ -0,0 +1,31 @@ + + + diff --git a/web/src/components/QuickCaptureFab.vue b/web/src/components/QuickCaptureFab.vue new file mode 100644 index 0000000..90306b6 --- /dev/null +++ b/web/src/components/QuickCaptureFab.vue @@ -0,0 +1,60 @@ + + + From 3b00ad8b47b528774665dd23d65689dbdd8cb824 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 09:19:38 -0700 Subject: [PATCH 07/11] fix: surface save errors in QuickCapture via error.value --- web/src/components/QuickCapture.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/components/QuickCapture.vue b/web/src/components/QuickCapture.vue index 76dd91e..8f41c3e 100644 --- a/web/src/components/QuickCapture.vue +++ b/web/src/components/QuickCapture.vue @@ -197,12 +197,14 @@ async function run() { async function saveQuick() { saving.value = true try { await postIncident('medium', '') } + catch (e) { error.value = e instanceof Error ? e.message : 'Failed to save incident' } finally { saving.value = false } } async function saveWithDetails() { saving.value = true try { await postIncident(detailSeverity.value, detailNotes.value) } + catch (e) { error.value = e instanceof Error ? e.message : 'Failed to save incident' } finally { saving.value = false } } From bf276aeb508db5d2eed24a57322828a517c55b38 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 09:20:47 -0700 Subject: [PATCH 08/11] feat: add Quick/Structured tabs to DiagnoseView --- web/src/views/DiagnoseView.vue | 131 ++++++++++++--------------------- 1 file changed, 46 insertions(+), 85 deletions(-) diff --git a/web/src/views/DiagnoseView.vue b/web/src/views/DiagnoseView.vue index a56dcfa..8872260 100644 --- a/web/src/views/DiagnoseView.vue +++ b/web/src/views/DiagnoseView.vue @@ -1,108 +1,69 @@ From 728134321f04c28ce3153ca6bfd52e508f15dea3 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 09:25:49 -0700 Subject: [PATCH 11/11] feat: wire QuickCaptureBar/FAB into App.vue, add Settings route --- web/src/App.vue | 36 +++++++++++++++++++++++++++++++----- web/src/router/index.ts | 2 ++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/web/src/App.vue b/web/src/App.vue index cd42978..688a38d 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -12,11 +12,15 @@ :to="link.to" class="px-3 py-1 rounded text-sm text-text-muted hover:text-text-primary hover:bg-surface-raised transition-colors" active-class="text-accent bg-accent-muted" - > - {{ link.label }} - + >{{ link.label }}
+
+ + + + + + + diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 42dbafe..ba7c788 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -5,6 +5,7 @@ import DiagnoseView from '@/views/DiagnoseView.vue' import SourcesView from '@/views/SourcesView.vue' import IncidentsView from '@/views/IncidentsView.vue' import BundlesView from '@/views/BundlesView.vue' +import SettingsView from '@/views/SettingsView.vue' export default createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -16,5 +17,6 @@ export default createRouter({ { path: '/incidents', component: IncidentsView }, { path: '/bundles', component: BundlesView }, { path: '/sources', component: SourcesView }, + { path: '/settings', component: SettingsView }, ], })