Add Projects, Milestones, RAG auto-inject, push notifications, PWA, tag normalisation

## Projects & Milestones (Phases A + G)
- New models: Project, Milestone (Project → Milestone → Task hierarchy)
- notes table: project_id + milestone_id FKs; parent_id FK constraint activated
- Migrations: 0017 (projects), 0018 (push_subscriptions), 0019 (events), 0020 (milestones)
- Services: projects.py, milestones.py (CRUD + progress tracking)
- Routes: /api/projects + /api/projects/<id>/milestones
- LLM tools: create/list/get/update project; create/list milestone; project + milestone + parent_task params on note/task tools
- Frontend: ProjectListView (stacked milestone bars), ProjectView (milestone-grouped kanban), ProjectSelector, MilestoneSelector, NoteEditorView + TaskEditorView updated

## RAG Auto-injection (Phase B)
- Notes ≥0.60 cosine similarity auto-injected into system prompt (max 3, 800 chars each)
- excluded_note_ids param; ChatView "Auto-included" sidebar section

## Summarisation improvements (Phase C)
- Threshold 20→30, keep-recent 6→8, max_tokens 200→400
- Two-pass summarisation for histories >50 messages

## Browser push notifications (Phase E)
- PushSubscription model + migration; pywebpush dependency
- /api/push routes; VAPID config; fire-and-forget on generation complete
- Frontend: sw.js, push store, Settings toggle

## PWA manifest (Phase F)
- manifest.json, Apple meta tags, service worker registration in main.ts

## Tag normalisation
- All tags lowercased + deduplicated at backend (create_note/update_note) and frontend (TagInput sanitize)
- Note/Task types gain project_id + milestone_id fields; store signatures updated

## CalDAV
- Radicale embedded server reverted; back to user-configured external CalDAV

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:52:21 -05:00
parent 3d7be5888e
commit 012eb1d46b
52 changed files with 4319 additions and 62 deletions
+8 -8
View File
@@ -16,13 +16,13 @@ CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "cald
async def get_caldav_config(user_id: int) -> dict[str, str]:
"""Read CalDAV settings for the given user."""
"""Return the user's CalDAV config from their settings."""
all_settings = await get_all_settings(user_id)
return {k: all_settings.get(k, "") for k in CALDAV_SETTING_KEYS}
async def is_caldav_configured(user_id: int) -> bool:
"""Check if CalDAV URL, username, and password are all set."""
"""Check if the user has configured an external CalDAV server."""
config = await get_caldav_config(user_id)
return bool(config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password"))
@@ -55,8 +55,8 @@ def _make_client(config: dict[str, str]) -> caldav.DAVClient:
"""Create a CalDAV client from config dict."""
return caldav.DAVClient(
url=config["caldav_url"],
username=config["caldav_username"],
password=config["caldav_password"],
username=config.get("caldav_username") or None,
password=config.get("caldav_password") or None,
)
@@ -158,8 +158,8 @@ def _add_attendees(event: icalendar.Event, attendees: list[str]) -> None:
def _check_config(config: dict[str, str]) -> None:
"""Raise if CalDAV is not configured."""
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
raise ValueError("CalDAV is not configured. Go to Settings -> Calendar to set it up.")
if not config.get("caldav_url"):
raise ValueError("CalDAV is not configured. Go to Settings Calendar to enter your server URL.")
async def create_event(
@@ -707,8 +707,8 @@ async def delete_todo(
async def test_connection(user_id: int) -> dict:
"""Test the CalDAV connection and return status."""
config = await get_caldav_config(user_id)
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
return {"success": False, "error": "CalDAV is not configured. Please fill in URL, username, and password."}
if not config.get("caldav_url"):
return {"success": False, "error": "CalDAV is not configured."}
def _test():
client = _make_client(config)
@@ -0,0 +1,304 @@
"""Calendar sync service: bridges the DB Event table with local Radicale.
Each user's calendars live at: http://radicale:5232/user_{user_id}/calendar/
The app connects without auth (Radicale runs with auth.type = none inside Docker).
External clients (iOS, macOS, Thunderbird) connect directly to Radicale on port 5232.
Their CalDAV URL is: http://<host>:5232/user_{user_id}/calendar/
"""
import asyncio
import logging
import uuid
from datetime import datetime, timezone
import httpx
import icalendar
from sqlalchemy import select
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.event import Event
logger = logging.getLogger(__name__)
# Internal Radicale URL (Docker service name)
_RADICALE_INTERNAL = "http://radicale:5232"
def _user_calendar_path(user_id: int) -> str:
return f"/user_{user_id}/calendar/"
def _user_calendar_url(user_id: int) -> str:
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
return f"{base}/user_{user_id}/calendar/"
async def setup_user_calendar(user_id: int) -> bool:
"""Create Radicale collection for user if it doesn't exist.
Returns True on success or if already exists, False on error.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
user_url = f"{base}/user_{user_id}/"
cal_url = f"{base}/user_{user_id}/calendar/"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Create user home collection
await client.request(
"MKCOL",
user_url,
headers={"Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<set><prop><resourcetype><collection/></resourcetype></prop></set>
</mkcol>""",
)
# Create calendar collection
resp = await client.request(
"MKCOL",
cal_url,
headers={"Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<set>
<prop>
<resourcetype><collection/><C:calendar/></resourcetype>
<displayname>Fabled Assistant</displayname>
</prop>
</set>
</mkcol>""",
)
if resp.status_code in (201, 405): # 405 = already exists
logger.info("Calendar collection ready for user %d", user_id)
return True
logger.warning("Unexpected status %d creating calendar for user %d", resp.status_code, user_id)
return False
except Exception:
logger.warning("Failed to setup Radicale calendar for user %d", user_id, exc_info=True)
return False
def _make_vcalendar(event: Event) -> str:
"""Build iCalendar string from DB Event."""
cal = icalendar.Calendar()
cal.add("prodid", "-//FabledAssistant//EN")
cal.add("version", "2.0")
vevent = icalendar.Event()
vevent.add("uid", event.uid)
vevent.add("summary", event.title)
if event.all_day:
vevent.add("dtstart", event.start_dt.date())
if event.end_dt:
vevent.add("dtend", event.end_dt.date())
else:
vevent.add("dtstart", event.start_dt)
if event.end_dt:
vevent.add("dtend", event.end_dt)
if event.description:
vevent.add("description", event.description)
if event.location:
vevent.add("location", event.location)
if event.recurrence:
rrule_parts: dict = {}
for part in event.recurrence.split(";"):
if "=" in part:
key, value = part.split("=", 1)
rrule_parts[key.strip().lower()] = value.strip()
if rrule_parts:
vevent.add("rrule", rrule_parts)
vevent.add("dtstamp", datetime.now(timezone.utc))
cal.add_component(vevent)
return cal.to_ical().decode("utf-8")
async def push_event_to_radicale(user_id: int, event: Event) -> bool:
"""Write a DB Event to Radicale. Creates or updates the resource.
Returns True on success.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{event.uid}.ics"
ical_data = _make_vcalendar(event)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.put(
resource_url,
content=ical_data.encode("utf-8"),
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
if resp.status_code in (200, 201, 204):
return True
logger.warning("Radicale PUT returned %d for event %s", resp.status_code, event.uid)
return False
except Exception:
logger.warning("Failed to push event %s to Radicale", event.uid, exc_info=True)
return False
async def delete_event_from_radicale(user_id: int, uid: str) -> bool:
"""Delete an event from Radicale by UID."""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{uid}.ics"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.delete(resource_url)
return resp.status_code in (200, 204, 404) # 404 is ok (already gone)
except Exception:
logger.warning("Failed to delete event %s from Radicale", uid, exc_info=True)
return False
async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
"""Fetch an event from Radicale and upsert into the DB Event table.
Returns the upserted Event or None on error.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{ical_uid}.ics"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(resource_url)
if resp.status_code == 404:
return None
resp.raise_for_status()
ical_data = resp.text
except Exception:
logger.warning("Failed to fetch event %s from Radicale", ical_uid, exc_info=True)
return None
try:
cal = icalendar.Calendar.from_ical(ical_data)
for component in cal.walk():
if component.name != "VEVENT":
continue
title = str(component.get("SUMMARY", ""))
dtstart = component.get("DTSTART")
dtend = component.get("DTEND")
description = str(component.get("DESCRIPTION", ""))
location = str(component.get("LOCATION", ""))
rrule = component.get("RRULE")
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
all_day = False
if dtstart and isinstance(dtstart.dt, datetime):
start_dt = dtstart.dt if dtstart.dt.tzinfo else dtstart.dt.replace(tzinfo=timezone.utc)
elif dtstart:
from datetime import date
d = dtstart.dt
start_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
all_day = True
else:
continue
end_dt = None
if dtend and isinstance(dtend.dt, datetime):
end_dt = dtend.dt if dtend.dt.tzinfo else dtend.dt.replace(tzinfo=timezone.utc)
elif dtend:
from datetime import date
d = dtend.dt
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
)
existing = result.scalars().first()
if existing:
existing.title = title
existing.start_dt = start_dt
existing.end_dt = end_dt
existing.all_day = all_day
existing.description = description
existing.location = location
existing.recurrence = recurrence
existing.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(existing)
return existing
else:
event_obj = Event(
user_id=user_id,
uid=ical_uid,
title=title,
start_dt=start_dt,
end_dt=end_dt,
all_day=all_day,
description=description,
location=location,
recurrence=recurrence,
)
session.add(event_obj)
await session.commit()
await session.refresh(event_obj)
return event_obj
except Exception:
logger.warning("Failed to parse/upsert Radicale event %s", ical_uid, exc_info=True)
return None
async def sync_all_events_from_radicale(user_id: int) -> int:
"""Full sync: fetch all events from Radicale calendar and upsert into DB.
Returns number of events synced.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
cal_url = f"{base}/user_{user_id}/calendar/"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
# PROPFIND to get all .ics resources
resp = await client.request(
"PROPFIND",
cal_url,
headers={"Depth": "1", "Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:"><prop><getetag/><getcontenttype/></prop></propfind>""",
)
if resp.status_code == 404:
logger.info("No calendar found for user %d — setting up", user_id)
await setup_user_calendar(user_id)
return 0
if resp.status_code != 207:
logger.warning("PROPFIND returned %d for user %d", resp.status_code, user_id)
return 0
# Extract UIDs from href paths
import re
hrefs = re.findall(r"<[Dd]:[Hh]ref>([^<]+\.ics)</[Dd]:[Hh]ref>", resp.text)
uids = [h.rstrip("/").split("/")[-1].removesuffix(".ics") for h in hrefs]
except Exception:
logger.warning("Failed to list Radicale events for user %d", user_id, exc_info=True)
return 0
synced = 0
for uid in uids:
if uid:
event = await sync_event_to_db(user_id, uid)
if event:
synced += 1
logger.info("Synced %d events from Radicale for user %d", synced, user_id)
return synced
def get_external_caldav_url(user_id: int) -> str:
"""Return the CalDAV URL for external clients (iOS, macOS, Thunderbird).
Uses BASE_URL from Config with port 5232 substituted, or RADICALE_EXTERNAL_URL.
"""
external = Config.RADICALE_EXTERNAL_URL
if external:
return f"{external.rstrip('/')}/user_{user_id}/calendar/"
# Fall back to BASE_URL host with port 5232
from urllib.parse import urlparse
parsed = urlparse(Config.BASE_URL)
host = parsed.hostname or "localhost"
return f"http://{host}:5232/user_{user_id}/calendar/"
+3 -2
View File
@@ -78,11 +78,12 @@ async def semantic_search_notes(
query: str,
exclude_ids: set[int] | None = None,
limit: int = 8,
threshold: float = _SIMILARITY_THRESHOLD,
) -> list[tuple[float, Note]]:
"""Return up to *limit* (score, note) pairs most relevant to *query*.
Scores are cosine similarities in [-1, 1]; only notes at or above
_SIMILARITY_THRESHOLD are returned, sorted highest-first.
*threshold* are returned, sorted highest-first.
Returns an empty list if the embedding model is unavailable or on any error.
"""
try:
@@ -114,7 +115,7 @@ async def semantic_search_notes(
sim = _cosine_similarity(query_vec, ne.embedding)
except Exception:
continue
if sim >= _SIMILARITY_THRESHOLD:
if sim >= threshold:
scored.append((sim, note))
scored.sort(key=lambda x: x[0], reverse=True)
@@ -147,6 +147,7 @@ async def run_generation(
user_content: str,
context_note_id: int | None = None,
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
think: bool = False,
) -> None:
"""Stream LLM response into buffer with periodic DB flushes."""
@@ -166,7 +167,7 @@ async def run_generation(
# Phase 2: Summarize long conversation history if needed.
history_to_use = history
history_summary: str | None = None
if len(history) > 20: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
if len(history) > 30: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
buf.append_event("status", {"status": "Summarizing conversation history..."})
history_to_use, history_summary = await summarize_history_for_context(history, model)
@@ -177,6 +178,7 @@ async def run_generation(
user_id, history_to_use, context_note_id, user_content,
history_summary=history_summary,
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
))
messages, context_meta = await context_task
@@ -348,6 +350,22 @@ async def run_generation(
buf.finished_at = time.monotonic()
buf.append_event("done", {"done": True, "message_id": msg_id, "timing": timing})
# Fire push notification when complete (non-critical, fire-and-forget)
try:
from fabledassistant.services.push import send_push_notification, vapid_enabled
if vapid_enabled():
preview = buf.content_so_far[:120].rstrip()
if len(buf.content_so_far) > 120:
preview += ""
asyncio.create_task(send_push_notification(
user_id,
title="Response ready",
body=preview,
url=f"/chat/{conv_id}",
))
except Exception:
logger.debug("Failed to schedule push notification", exc_info=True)
# Title generation is non-critical — fire-and-forget so done fires immediately
non_system = [m for m in messages if m["role"] != "system"]
msg_count = len(non_system)
+1
View File
@@ -132,6 +132,7 @@ Rules:
- create_event: appointments, meetings, scheduled occurrences with a date/time ("dentist Friday 2pm", "team meeting next Tuesday")
- update_note: updating, editing, appending to an existing note or task ("add to my shopping list: eggs", "mark buy milk done", "append to my meeting notes", "update my project note")
- research_topic: user wants a comprehensive research note from web sources ("research X", "look up X and make a note", "find everything about X", "compile a note on X")
- create_project: user wants to create a project, initiative, or campaign ("new project X", "start a project called Y", "create a project for Z")
- create_note: everything else — ideas, observations, links, excerpts, longer text
- For create_task / create_event: extract a concise title; put any extra detail in "body"
- For create_note: use a short descriptive title (≤60 chars); put the FULL original text as "body"
+110 -19
View File
@@ -27,6 +27,10 @@ STOP_WORDS = frozenset({
"but", "if", "so", "than", "too", "very", "just", "about", "up",
})
RAG_AUTO_THRESHOLD = 0.60
RAG_AUTO_LIMIT = 3
RAG_AUTO_SNIPPET = 800
async def get_installed_models() -> set[str]:
"""Return set of installed Ollama model names (with and without :latest)."""
@@ -302,8 +306,8 @@ def _find_urls(text: str) -> list[str]:
# History summarization thresholds
_HISTORY_SUMMARY_THRESHOLD = 20 # total messages before summarizing
_HISTORY_KEEP_RECENT = 6 # verbatim tail to preserve (3 exchanges)
_HISTORY_SUMMARY_THRESHOLD = 30 # total messages before summarizing
_HISTORY_KEEP_RECENT = 8 # verbatim tail to preserve (4 exchanges)
async def summarize_history_for_context(
@@ -324,13 +328,55 @@ async def summarize_history_for_context(
to_summarize = history[:-_HISTORY_KEEP_RECENT]
recent = history[-_HISTORY_KEEP_RECENT:]
lines: list[str] = []
for m in to_summarize:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
lines.append(f"{label}: {content[:400]}")
# Two-pass for very long histories: summarize first half, combine with second half
if len(to_summarize) > 50:
mid = len(to_summarize) // 2
first_half = to_summarize[:mid]
second_half = to_summarize[mid:]
# Summarize first half
first_lines = []
for m in first_half:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
first_lines.append(f"{label}: {content[:400]}")
if first_lines:
try:
first_summary_messages = [
{"role": "system", "content": "Summarize this conversation in 3-4 sentences covering topics, notes/tasks created, and key decisions."},
{"role": "user", "content": "\n".join(first_lines)},
]
summary_a = await generate_completion(first_summary_messages, model, max_tokens=300)
summary_a = summary_a.strip()
except Exception:
summary_a = ""
else:
summary_a = ""
# Build lines for final pass from second half
second_lines = []
for m in second_half:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
second_lines.append(f"{label}: {content[:400]}")
if summary_a:
lines = [f"[Earlier summary: {summary_a}]"] + second_lines
else:
lines = second_lines
else:
lines: list[str] = []
for m in to_summarize:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
lines.append(f"{label}: {content[:400]}")
if not lines:
return history, None
@@ -339,17 +385,19 @@ async def summarize_history_for_context(
{
"role": "system",
"content": (
"Summarize this conversation history in 3-5 concise sentences. "
"Capture: topics discussed, any notes/tasks/events created or modified, "
"decisions made, and context needed to continue the conversation naturally. "
"Be specific and factual. Output only the summary, nothing else."
"Summarize this conversation history. Capture: "
"(1) All notes, tasks, and projects created or modified — include their exact names. "
"(2) Key decisions made and conclusions reached. "
"(3) Open questions and next steps mentioned. "
"(4) The overall topic arc so the conversation can continue naturally. "
"Be specific and factual. Output 4-8 concise sentences. Nothing else."
),
},
{"role": "user", "content": "\n".join(lines)},
]
try:
summary = await generate_completion(prompt_messages, model, max_tokens=200)
summary = await generate_completion(prompt_messages, model, max_tokens=400)
summary = summary.strip()
if summary:
logger.info(
@@ -371,6 +419,7 @@ async def build_context(
exclude_note_ids: list[int] | None = None,
history_summary: str | None = None,
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
@@ -426,6 +475,7 @@ async def build_context(
"context_note_id": None,
"context_note_title": None,
"auto_notes": [],
"auto_injected_notes": [],
}
# Include current note context if provided — full body, no truncation
@@ -441,10 +491,9 @@ async def build_context(
f"--- End Note ---"
)
# Search for related notes to populate sidebar candidates.
# Results are NOT injected into the system prompt automatically — this keeps
# the system prompt prefix stable so Ollama's KV cache can reuse prefill state.
# Users can explicitly include notes via the sidebar (include_note_ids).
# Search for related notes. High-confidence results (>=0.60) are auto-injected
# into the system prompt; lower-confidence results populate the sidebar only.
# Users can also explicitly include notes via the sidebar (include_note_ids).
search_exclude = set(exclude_set)
if current_note_id:
search_exclude.add(current_note_id)
@@ -473,12 +522,54 @@ async def build_context(
except Exception:
logger.warning("Failed to search notes for context", exc_info=True)
# Populate sidebar candidates (never auto-injected into system prompt).
# Separate high-confidence results for auto-injection vs sidebar display
excluded_inject_set = set(excluded_note_ids or [])
auto_inject: list[tuple[float, object]] = []
sidebar_only: list[tuple[float | None, object]] = []
for score, n in found_scored:
if (
score is not None
and score >= RAG_AUTO_THRESHOLD
and len(auto_inject) < RAG_AUTO_LIMIT
and n.id not in excluded_inject_set
):
auto_inject.append((score, n))
else:
sidebar_only.append((score, n))
# Inject high-scoring notes into system prompt
if auto_inject:
snippets = []
for score, n in auto_inject:
body_snippet = (n.body or "")[:RAG_AUTO_SNIPPET]
snippets.append(f"**{n.title}** (relevance: {round(score * 100)}%)\n{body_snippet}")
context_meta["auto_injected_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2),
})
system_parts.append(
"\n\n--- Relevant Notes ---\n"
+ "\n\n".join(snippets)
+ "\n--- End Relevant Notes ---"
)
# Populate sidebar candidates (auto-injected notes also appear here for reference,
# but sidebar_only are the ones not yet in the prompt)
for score, n in auto_inject:
context_meta["auto_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2) if score is not None else None,
"auto_injected": True,
})
for score, n in sidebar_only:
context_meta["auto_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2) if score is not None else None,
"auto_injected": False,
})
context_meta["auto_note_ids"] = [n.id for _, n in found_scored]
+145
View File
@@ -0,0 +1,145 @@
"""Milestone management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from fabledassistant.models import async_session
from fabledassistant.models.milestone import Milestone
from fabledassistant.models.note import Note
logger = logging.getLogger(__name__)
async def create_milestone(
user_id: int,
project_id: int,
title: str,
description: str | None = None,
order_index: int = 0,
) -> Milestone:
async with async_session() as session:
milestone = Milestone(
user_id=user_id,
project_id=project_id,
title=title,
description=description,
status="active",
order_index=order_index,
)
session.add(milestone)
await session.commit()
await session.refresh(milestone)
return milestone
async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
return result.scalars().first()
async def get_milestone_by_title(user_id: int, project_id: int, title: str) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
func.lower(Milestone.title) == func.lower(title.strip()),
).limit(1)
)
return result.scalars().first()
async def get_or_create_milestone(user_id: int, project_id: int, title: str) -> Milestone:
milestone = await get_milestone_by_title(user_id, project_id, title)
if milestone:
return milestone
return await create_milestone(user_id, project_id, title=title)
async def list_milestones(
user_id: int, project_id: int, status: str | None = None
) -> list[Milestone]:
async with async_session() as session:
query = select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
)
if status:
query = query.where(Milestone.status == status)
query = query.order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
milestone = result.scalars().first()
if milestone is None:
return None
for key, value in fields.items():
if hasattr(milestone, key):
setattr(milestone, key, value)
milestone.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(milestone)
return milestone
async def delete_milestone(user_id: int, milestone_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
milestone = result.scalars().first()
if milestone is None:
return False
await session.delete(milestone)
await session.commit()
return True
async def get_milestone_progress(milestone_id: int) -> dict:
"""Return task completion stats for a milestone."""
async with async_session() as session:
rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(Note.milestone_id == milestone_id, Note.status.isnot(None))
.group_by(Note.status)
)
status_counts: dict[str, int] = {}
for status, count in rows.fetchall():
status_counts[status] = count
total = sum(status_counts.values())
completed = status_counts.get("done", 0)
pct = round(completed / total * 100, 1) if total > 0 else 0.0
return {
"total": total,
"completed": completed,
"pct": pct,
"status_counts": {
"todo": status_counts.get("todo", 0),
"in_progress": status_counts.get("in_progress", 0),
"done": status_counts.get("done", 0),
},
}
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
"""Return ordered list of milestones with their progress stats."""
milestones = await list_milestones(user_id, project_id)
result = []
for m in milestones:
progress = await get_milestone_progress(m.id)
entry = m.to_dict()
entry.update(progress)
result.append(entry)
return result
+29 -1
View File
@@ -9,12 +9,26 @@ from fabledassistant.models.note import Note, TaskPriority, TaskStatus
logger = logging.getLogger(__name__)
def _normalize_tags(tags: list[str]) -> list[str]:
"""Lowercase, strip, deduplicate, and drop empty tags."""
seen: set[str] = set()
out: list[str] = []
for t in tags:
normalized = t.strip().lower()
if normalized and normalized not in seen:
seen.add(normalized)
out.append(normalized)
return out
async def create_note(
user_id: int,
title: str = "",
body: str = "",
tags: list[str] | None = None,
parent_id: int | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
status: str | None = None,
priority: str | None = None,
due_date: date | None = None,
@@ -24,8 +38,10 @@ async def create_note(
user_id=user_id,
title=title,
body=body,
tags=tags or [],
tags=_normalize_tags(tags or []),
parent_id=parent_id,
project_id=project_id,
milestone_id=milestone_id,
status=status,
priority=priority,
due_date=due_date,
@@ -53,6 +69,8 @@ async def list_notes(
priority: str | None = None,
due_before: date | None = None,
due_after: date | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
sort: str = "updated_at",
order: str = "desc",
limit: int = 50,
@@ -105,6 +123,14 @@ async def list_notes(
query = query.where(Note.due_date >= due_after)
count_query = count_query.where(Note.due_date >= due_after)
if project_id is not None:
query = query.where(Note.project_id == project_id)
count_query = count_query.where(Note.project_id == project_id)
if milestone_id is not None:
query = query.where(Note.milestone_id == milestone_id)
count_query = count_query.where(Note.milestone_id == milestone_id)
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
query = query.order_by(sort_col.asc())
@@ -153,6 +179,8 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
value = TaskStatus(value).value
elif key == "priority" and isinstance(value, str):
value = TaskPriority(value).value
elif key == "tags" and isinstance(value, list):
value = _normalize_tags(value)
setattr(note, key, value)
note.updated_at = datetime.now(timezone.utc)
await session.commit()
+146
View File
@@ -0,0 +1,146 @@
"""Project management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
logger = logging.getLogger(__name__)
async def create_project(
user_id: int,
title: str,
description: str = "",
goal: str = "",
color: str | None = None,
) -> Project:
async with async_session() as session:
project = Project(
user_id=user_id,
title=title,
description=description,
goal=goal,
color=color,
status="active",
)
session.add(project)
await session.commit()
await session.refresh(project)
return project
async def get_project(user_id: int, project_id: int) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
return result.scalars().first()
async def get_project_by_title(user_id: int, title: str) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(
Project.user_id == user_id,
func.lower(Project.title) == func.lower(title.strip()),
).limit(1)
)
return result.scalars().first()
async def get_or_create_project(user_id: int, title: str) -> Project:
project = await get_project_by_title(user_id, title)
if project:
return project
return await create_project(user_id, title=title)
async def list_projects(user_id: int, status: str | None = None) -> list[Project]:
async with async_session() as session:
query = select(Project).where(Project.user_id == user_id)
if status:
query = query.where(Project.status == status)
query = query.order_by(Project.updated_at.desc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
project = result.scalars().first()
if project is None:
return None
for key, value in fields.items():
if hasattr(project, key):
setattr(project, key, value)
project.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(project)
return project
async def delete_project(user_id: int, project_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
project = result.scalars().first()
if project is None:
return False
# Unlink notes (cascade handled by DB ON DELETE SET NULL, but we delete project here)
await session.delete(project)
await session.commit()
return True
async def get_project_summary(user_id: int, project_id: int) -> dict:
"""Return task counts by status, note count, and last activity."""
async with async_session() as session:
# Task counts by status
task_rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.isnot(None),
)
.group_by(Note.status)
)
task_counts: dict[str, int] = {}
for status, count in task_rows.fetchall():
task_counts[status] = count
# Note count (non-tasks)
note_count_result = await session.scalar(
select(func.count(Note.id)).where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.is_(None),
)
)
note_count = note_count_result or 0
# Last activity
last_activity_result = await session.scalar(
select(func.max(Note.updated_at)).where(
Note.user_id == user_id,
Note.project_id == project_id,
)
)
from fabledassistant.services.milestones import get_project_milestone_summary
milestone_summary = await get_project_milestone_summary(user_id, project_id)
return {
"task_counts": task_counts,
"note_count": note_count,
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
"milestone_summary": milestone_summary,
}
+149
View File
@@ -0,0 +1,149 @@
"""Browser push notification service using VAPID/pywebpush."""
import asyncio
import json
import logging
from datetime import datetime, timezone
from functools import lru_cache
from sqlalchemy import delete, select
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.push_subscription import PushSubscription
logger = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def _get_webpush():
"""Lazy import to avoid startup errors if pywebpush is not installed."""
try:
from pywebpush import WebPusher
return WebPusher
except ImportError:
return None
def vapid_enabled() -> bool:
return bool(Config.VAPID_PRIVATE_KEY and Config.VAPID_PUBLIC_KEY)
async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription:
"""Upsert a push subscription by endpoint."""
endpoint = subscription_json.get("endpoint", "")
keys = subscription_json.get("keys", {})
p256dh = keys.get("p256dh", "")
auth = keys.get("auth", "")
async with async_session() as session:
result = await session.execute(
select(PushSubscription).where(
PushSubscription.user_id == user_id,
PushSubscription.endpoint == endpoint,
)
)
existing = result.scalars().first()
if existing:
existing.p256dh = p256dh
existing.auth = auth
existing.user_agent = user_agent
existing.last_used = datetime.now(timezone.utc)
await session.commit()
await session.refresh(existing)
return existing
else:
sub = PushSubscription(
user_id=user_id,
endpoint=endpoint,
p256dh=p256dh,
auth=auth,
user_agent=user_agent,
)
session.add(sub)
await session.commit()
await session.refresh(sub)
return sub
async def delete_subscription(user_id: int, endpoint: str) -> None:
async with async_session() as session:
await session.execute(
delete(PushSubscription).where(
PushSubscription.user_id == user_id,
PushSubscription.endpoint == endpoint,
)
)
await session.commit()
async def _remove_expired_subscription(user_id: int, endpoint: str) -> None:
"""Remove a subscription that returned 410 Gone."""
try:
await delete_subscription(user_id, endpoint)
logger.info("Removed expired push subscription for user %d", user_id)
except Exception:
logger.warning("Failed to remove expired subscription", exc_info=True)
async def send_push_notification(
user_id: int,
title: str,
body: str,
url: str = "/",
) -> None:
"""Send a push notification to all subscriptions for a user.
Fire-and-forget — wrap in asyncio.create_task() at call site.
"""
if not vapid_enabled():
logger.debug("VAPID not configured, skipping push notification")
return
WebPusher = _get_webpush()
if WebPusher is None:
logger.warning("pywebpush not installed, cannot send push notifications")
return
async with async_session() as session:
result = await session.execute(
select(PushSubscription).where(PushSubscription.user_id == user_id)
)
subscriptions = list(result.scalars().all())
if not subscriptions:
return
payload = json.dumps({"title": title, "body": body, "url": url})
vapid_claims = {
"sub": Config.VAPID_CLAIMS_SUB,
}
def _send_sync(sub: PushSubscription) -> tuple[int, str]:
"""Synchronous send — runs in executor."""
try:
subscription_info = {
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
}
response = WebPusher(subscription_info).send(
data=payload,
vapid_private_key=Config.VAPID_PRIVATE_KEY,
vapid_claims=vapid_claims,
)
return response.status_code, sub.endpoint
except Exception as e:
logger.warning("Push send failed for sub %d: %s", sub.id, e)
return 0, sub.endpoint
loop = asyncio.get_event_loop()
tasks = [loop.run_in_executor(None, _send_sync, sub) for sub in subscriptions]
results = await asyncio.gather(*tasks, return_exceptions=True)
for sub, result in zip(subscriptions, results):
if isinstance(result, Exception):
continue
status_code, endpoint = result
if status_code == 410:
asyncio.create_task(_remove_expired_subscription(user_id, endpoint))
elif status_code and status_code not in (200, 201):
logger.warning("Push returned status %d for user %d", status_code, user_id)
+276
View File
@@ -55,6 +55,18 @@ _CORE_TOOLS = [
"enum": ["none", "low", "medium", "high"],
"description": "Task priority level",
},
"project": {
"type": "string",
"description": "Optional project name to assign this task to",
},
"parent_task": {
"type": "string",
"description": "Optional title of a parent task to make this a sub-task of",
},
"milestone": {
"type": "string",
"description": "Optional milestone title within the project to assign this task to",
},
},
"required": ["title"],
},
@@ -81,6 +93,10 @@ _CORE_TOOLS = [
"items": {"type": "string"},
"description": "Tags for the note (without # prefix, hyphens for multi-word: [\"science-fiction\", \"story/idea\"]). Do NOT embed #tags in the note body.",
},
"project": {
"type": "string",
"description": "Optional project name to assign this note to",
},
},
"required": ["title"],
},
@@ -143,6 +159,10 @@ _CORE_TOOLS = [
"enum": ["replace", "add", "remove"],
"description": "'replace' sets tags to exactly this list, 'add' appends without duplicates, 'remove' removes listed tags (default: replace)",
},
"project": {
"type": "string",
"description": "Optional project name to assign this note/task to (set to empty string to remove project)",
},
},
"required": ["query"],
},
@@ -294,6 +314,14 @@ _CORE_TOOLS = [
"type": "string",
"description": "Return tasks due on or after this date (YYYY-MM-DD)",
},
"project": {
"type": "string",
"description": "Filter tasks by project name",
},
"milestone": {
"type": "string",
"description": "Filter tasks by milestone name within a project",
},
"limit": {
"type": "integer",
"description": "Maximum number of tasks to return (default 10)",
@@ -302,6 +330,100 @@ _CORE_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "create_project",
"description": "Create a new project. Use when the user asks to create a project, initiative, or campaign to organize related tasks and notes.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Project title"},
"description": {"type": "string", "description": "Brief description"},
"goal": {"type": "string", "description": "The goal or desired outcome"},
},
"required": ["title"],
},
},
},
{
"type": "function",
"function": {
"name": "list_projects",
"description": "List the user's projects. Use when asked about projects, initiatives, or campaigns.",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["active", "completed", "archived"],
"description": "Filter by status (omit for all)",
},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_project",
"description": "Get details and summary of a specific project.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Project name or keyword to find it"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "update_project",
"description": "Update a project's status, description, or goal.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Project name to find"},
"status": {"type": "string", "enum": ["active", "completed", "archived"]},
"description": {"type": "string"},
"goal": {"type": "string"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "create_milestone",
"description": "Create a new milestone inside a project for grouping and tracking related tasks.",
"parameters": {
"type": "object",
"properties": {
"project": {"type": "string", "description": "Project title (will be created if not found)"},
"title": {"type": "string", "description": "Milestone title"},
"description": {"type": "string", "description": "Optional description"},
},
"required": ["project", "title"],
},
},
},
{
"type": "function",
"function": {
"name": "list_milestones",
"description": "List milestones for a project with their progress percentages.",
"parameters": {
"type": "object",
"properties": {
"project": {"type": "string", "description": "Project name to look up"},
},
"required": ["project"],
},
},
},
]
# CalDAV tools — only included when user has CalDAV configured
@@ -604,6 +726,27 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
)
suggested = await suggest_tags(user_id, task_title, task_body)
_schedule_embedding(note.id, user_id, task_title, task_body)
# Handle optional project and parent_task assignment
project_name = arguments.get("project")
parent_task_name = arguments.get("parent_task")
update_fields: dict = {}
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
update_fields["project_id"] = proj.id
if parent_task_name:
parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1)
if parent_notes:
update_fields["parent_id"] = parent_notes[0].id
milestone_name = arguments.get("milestone")
if milestone_name and project_name:
from fabledassistant.services.projects import get_or_create_project as _gocp
from fabledassistant.services.milestones import get_or_create_milestone
proj = await _gocp(user_id, project_name)
ms = await get_or_create_milestone(user_id, proj.id, milestone_name)
update_fields["milestone_id"] = ms.id
if update_fields:
note = await update_note(user_id, note.id, **update_fields)
return {
"success": True,
"type": "task",
@@ -613,6 +756,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"status": note.status,
"priority": note.priority,
"due_date": str(note.due_date) if note.due_date else None,
"project_id": note.project_id,
},
"suggested_tags": suggested,
}
@@ -635,12 +779,19 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
)
suggested = await suggest_tags(user_id, note_title, note_body)
_schedule_embedding(note.id, user_id, note_title, note_body)
# Handle optional project assignment
project_name = arguments.get("project")
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
note = await update_note(user_id, note.id, project_id=proj.id)
return {
"success": True,
"type": "note",
"data": {
"id": note.id,
"title": note.title,
"project_id": note.project_id,
},
"suggested_tags": suggested,
}
@@ -693,6 +844,15 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
else:
update_fields["tags"] = new_tags
if "project" in arguments:
project_name = arguments["project"]
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
update_fields["project_id"] = proj.id
else:
update_fields["project_id"] = None
updated = await update_note(user_id, note.id, **update_fields)
if updated is None:
return {"success": False, "error": "Failed to update note."}
@@ -710,6 +870,25 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
}
elif tool_name == "list_tasks":
project_id = None
milestone_id = None
project_name = arguments.get("project")
if project_name:
from fabledassistant.services.projects import get_project_by_title, list_projects as _lp
proj = await get_project_by_title(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
if matches:
proj = matches[0]
if proj:
project_id = proj.id
milestone_name = arguments.get("milestone")
if milestone_name:
from fabledassistant.services.milestones import get_milestone_by_title
ms = await get_milestone_by_title(user_id, proj.id, milestone_name)
if ms:
milestone_id = ms.id
notes, total = await list_notes(
user_id=user_id,
is_task=True,
@@ -717,6 +896,8 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
priority=arguments.get("priority"),
due_before=_parse_due_date(arguments.get("due_before")),
due_after=_parse_due_date(arguments.get("due_after")),
project_id=project_id,
milestone_id=milestone_id,
limit=int(arguments.get("limit", 10)),
sort="due_date",
order="asc",
@@ -729,6 +910,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"status": n.status,
"priority": n.priority,
"due_date": str(n.due_date) if n.due_date else None,
"project_id": n.project_id,
"preview": (n.body[:120] + "...") if n.body and len(n.body) > 120 else (n.body or ""),
})
return {
@@ -950,6 +1132,39 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"data": {"id": note.id, "title": note.title},
}
elif tool_name == "create_milestone":
from fabledassistant.services.projects import get_or_create_project as _gocp
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
project_name = arguments.get("project", "")
if not project_name:
return {"success": False, "error": "project is required"}
proj = await _gocp(user_id, project_name)
ms = await _cm(
user_id,
proj.id,
title=arguments.get("title", "Untitled Milestone"),
description=arguments.get("description"),
)
return {"success": True, "type": "milestone", "data": ms.to_dict()}
elif tool_name == "list_milestones":
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
from fabledassistant.services.milestones import get_project_milestone_summary
project_name = arguments.get("project", "")
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"No project found matching '{project_name}'"}
summary = await get_project_milestone_summary(user_id, proj.id)
return {
"success": True,
"type": "milestones_list",
"data": {"project": proj.title, "count": len(summary), "milestones": summary},
}
elif tool_name == "search_web":
from fabledassistant.services.research import _search_searxng
query = arguments.get("query", "")
@@ -976,6 +1191,67 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"data": {"topic": topic},
}
elif tool_name == "create_project":
from fabledassistant.services.projects import create_project as _create_project
project = await _create_project(
user_id,
title=arguments.get("title", "New Project"),
description=arguments.get("description", ""),
goal=arguments.get("goal", ""),
)
return {"success": True, "type": "project", "data": project.to_dict()}
elif tool_name == "list_projects":
from fabledassistant.services.projects import list_projects as _list_projects
projects = await _list_projects(user_id, status=arguments.get("status"))
return {
"success": True,
"type": "projects_list",
"data": {"count": len(projects), "projects": [p.to_dict() for p in projects]},
}
elif tool_name == "get_project":
from fabledassistant.services.projects import (
get_project_by_title as _gpbt,
get_project_summary as _gps,
list_projects as _lp,
)
query = arguments.get("query", "")
project = await _gpbt(user_id, query)
if project is None:
all_projects = await _lp(user_id)
matches = [p for p in all_projects if query.lower() in p.title.lower()]
if matches:
project = matches[0]
if project is None:
return {"success": False, "error": f"No project found matching '{query}'"}
summary = await _gps(user_id, project.id)
data = project.to_dict()
data["summary"] = summary
return {"success": True, "type": "project_detail", "data": data}
elif tool_name == "update_project":
from fabledassistant.services.projects import (
get_project_by_title as _gpbt,
update_project as _up,
list_projects as _lp,
)
query = arguments.get("query", "")
project = await _gpbt(user_id, query)
if project is None:
all_projects = await _lp(user_id)
matches = [p for p in all_projects if query.lower() in p.title.lower()]
if matches:
project = matches[0]
if project is None:
return {"success": False, "error": f"No project found matching '{query}'"}
fields = {}
for k in ("status", "description", "goal"):
if k in arguments:
fields[k] = arguments[k]
updated = await _up(user_id, project.id, **fields)
return {"success": True, "type": "project_updated", "data": updated.to_dict()}
elif tool_name == "search_images":
from fabledassistant.services.images import fetch_and_store_image
from fabledassistant.services.research import _search_searxng_images