Compare commits

..

23 Commits

Author SHA1 Message Date
bvandeusen 8fb81bc1ed Merge pull request 'Release v26.04.10.1' (#28) from dev into main 2026-04-10 15:05:15 +00:00
bvandeusen c1bc73da8e fix(briefing): remove redundant current-conditions block; patch live temp into WeatherCard
The standalone current-conditions div showed just a bare temperature with no
forecast context, making the weather panel look incomplete. Now the live
temperature from /weather/current is patched directly into the WeatherCard's
current_temp so it stays fresh without a second display block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:43:30 -04:00
bvandeusen 3b8d40fea3 feat: overall completion bar on project cards; auto-collapse done milestones
ProjectListView: add an overall task completion bar above the per-milestone
bars showing the percentage of all project tasks that are done.

ProjectView: milestones where every task is complete now start collapsed
by default, keeping the view clean for projects with many finished stages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:38:02 -04:00
bvandeusen 2f5abc034e style: update email templates to Modern Fable visual identity
Replace indigo (#6366f1) with deep violet (#7c3aed) gradient header,
violet-tinted card border and background, refined border radius and
spacing, and violet-accented footer to match the app's design language.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:31:16 -04:00
bvandeusen 7a01733334 fix: coerce null project fields to empty string to avoid NOT NULL violation 2026-04-09 21:34:14 -04:00
bvandeusen 7d5611d00b fix: add 'paused' tab to project list filter 2026-04-09 19:33:50 -04:00
bvandeusen e5821ef3f8 fix: allow 'paused' in project status validation 2026-04-09 18:17:48 -04:00
bvandeusen 43231f44d2 fix(search): hybrid keyword + semantic search — exact title/body matches rank first 2026-04-09 12:28:55 -04:00
bvandeusen ab0b9c3199 fix(lint): add timezone import, use timedelta directly, remove unused variable 2026-04-09 08:46:34 -04:00
bvandeusen d12fc5d282 feat: paused project status with auto-pause (14 days), auto-reactivate on activity; fix back button context 2026-04-09 08:37:08 -04:00
bvandeusen 4c3d67994b fix: back button returns to project when note/task has a project, otherwise to Knowledge 2026-04-09 08:18:46 -04:00
bvandeusen 126bad7d99 fix(header): darken nav text in light mode — use text-secondary for inactive, primary-solid for active 2026-04-08 23:43:25 -04:00
bvandeusen 28fae75258 Merge pull request 'Release v26.04.09.1 — Briefing intelligence overhaul' (#27) from dev into main 2026-04-09 03:30:17 +00:00
bvandeusen 058bd76719 feat(briefing): multi-article topic discussion endpoint for deep thematic analysis 2026-04-08 22:53:23 -04:00
bvandeusen d56b57ee40 feat(briefing): cluster-level preference filtering — rank themes by user interest, suppress disliked topics 2026-04-08 22:51:13 -04:00
bvandeusen 8762552234 feat(briefing): project continuity — yesterday's activity and stale project warnings 2026-04-08 22:19:38 -04:00
bvandeusen 6cbf9be052 feat(briefing): weekly review — 7-day recap with task/note/project summary and upcoming preview 2026-04-08 22:18:19 -04:00
bvandeusen 3687fbeeb9 feat(briefing): differentiated check-in slots — midday progress nudge, afternoon wrap-up 2026-04-08 22:09:18 -04:00
bvandeusen a6543c1dc5 feat(briefing): cluster news by topic for thematic synthesis instead of flat headlines 2026-04-08 22:07:24 -04:00
bvandeusen 4558dd578a feat(briefing): weather × calendar cross-reference — rain warnings for outdoor events 2026-04-08 22:03:18 -04:00
bvandeusen 8647f52fbc feat(weather): live current conditions endpoint + polling display in briefing view 2026-04-08 21:33:27 -04:00
bvandeusen 304affb837 feat(briefing): daily planning — prioritized focus list and calendar gap analysis 2026-04-08 20:59:06 -04:00
bvandeusen 2e3f90384d feat(briefing): actionable intelligence — days overdue, project context, suggest next steps 2026-04-08 20:33:16 -04:00
15 changed files with 894 additions and 81 deletions
+3 -3
View File
@@ -208,7 +208,7 @@ router.afterEach(() => {
}
.nav-link {
color: var(--color-text-muted);
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.82rem;
padding: 0.3rem 0.75rem;
@@ -216,11 +216,11 @@ router.afterEach(() => {
transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
color: var(--color-text-secondary);
color: var(--color-text);
background: var(--color-primary-tint);
}
.nav-link.router-link-active {
color: #c4b5fd;
color: var(--color-primary-solid);
font-weight: 600;
background: rgba(124, 58, 237, 0.25);
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
+34 -2
View File
@@ -60,6 +60,27 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
const weatherData = ref<WeatherData[]>([])
const tempUnit = ref<string>('C')
interface CurrentConditions {
temperature: number | null;
windspeed: number | null;
description: string;
precip_next_3h: number[];
temp_unit: string;
location: string;
}
const currentConditions = ref<CurrentConditions | null>(null)
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
async function loadCurrentConditions() {
try {
currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current')
// Patch the live temperature into the WeatherCard so it stays fresh
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
}
} catch { /* silent — endpoint may not have locations configured */ }
}
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
@@ -90,6 +111,7 @@ async function loadAll() {
getBriefingToday().catch(() => null),
loadWeather(),
loadNews(),
loadCurrentConditions(),
])
conversations.value = convList
if (today) {
@@ -198,11 +220,18 @@ useBackgroundRefresh(
)
let _mounted = true
onUnmounted(() => { _mounted = false })
onUnmounted(() => {
_mounted = false
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
})
onMounted(async () => {
await checkSetup()
if (!showWizard.value) await loadAll()
if (!showWizard.value) {
await loadAll()
// Poll current conditions every 30 minutes
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
}
})
</script>
@@ -235,6 +264,7 @@ onMounted(async () => {
<!-- Left column: Weather -->
<div class="briefing-left">
<div class="panel-label">Weather</div>
<!-- Forecast card -->
<template v-if="weatherData.length">
<WeatherCard
v-for="loc in weatherData"
@@ -457,6 +487,8 @@ onMounted(async () => {
color: var(--color-text-muted);
}
/* ── Current conditions (live) ─────────────────────────── */
.panel-empty {
font-size: 0.82rem;
color: var(--color-text-muted);
+2 -2
View File
@@ -402,7 +402,7 @@ async function confirmDelete() {
await store.deleteNote(noteId.value);
dirty.value = false;
toast.show("Note deleted");
router.push("/notes");
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
} catch {
toast.show("Failed to delete note", "error");
}
@@ -445,7 +445,7 @@ onUnmounted(() => assist.clearSelection());
<main class="editor-page note-editor-page">
<div class="editor-header">
<div class="toolbar">
<router-link to="/notes" class="btn-back"> Notes</router-link>
<router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back"> {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
+52 -3
View File
@@ -20,7 +20,7 @@ interface Project {
title: string;
description: string | null;
goal: string | null;
status: "active" | "completed" | "archived";
status: "active" | "paused" | "completed" | "archived";
color: string | null;
auto_summary: string | null;
permission?: string;
@@ -40,7 +40,7 @@ const toast = useToastStore();
const projects = ref<Project[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"all" | "active" | "completed" | "archived">("all");
const activeTab = ref<"all" | "active" | "paused" | "completed" | "archived">("all");
// New project modal
const showNewProjectModal = ref(false);
@@ -103,6 +103,7 @@ async function createProject() {
function statusLabel(status: Project["status"]): string {
if (status === "active") return "Active";
if (status === "paused") return "Paused";
if (status === "completed") return "Completed";
if (status === "archived") return "Archived";
return status;
@@ -112,6 +113,14 @@ function truncate(text: string | null, max = 120): string {
if (!text) return "";
return text.length > max ? text.slice(0, max) + "..." : text;
}
function overallPct(project: Project): { total: number; pct: number } {
const counts = project.summary?.task_counts;
if (!counts) return { total: 0, pct: 0 };
const total = (counts.todo ?? 0) + (counts.in_progress ?? 0) + (counts.done ?? 0);
const pct = total > 0 ? Math.round((counts.done ?? 0) / total * 100) : 0;
return { total, pct };
}
</script>
<template>
@@ -124,7 +133,7 @@ function truncate(text: string | null, max = 120): string {
<!-- Filter tabs -->
<div class="filter-tabs">
<button
v-for="tab in ['all', 'active', 'completed', 'archived'] as const"
v-for="tab in ['all', 'active', 'paused', 'completed', 'archived'] as const"
:key="tab"
:class="['tab-btn', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -163,6 +172,18 @@ function truncate(text: string | null, max = 120): string {
</p>
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
<!-- Overall completion bar -->
<div
v-if="overallPct(project).total > 0"
class="overall-bar-row"
:title="`${project.summary?.task_counts.done ?? 0} of ${overallPct(project).total} tasks complete`"
>
<div class="overall-bar-track">
<div class="overall-bar-fill" :style="{ width: overallPct(project).pct + '%' }"></div>
</div>
<span class="overall-bar-pct">{{ overallPct(project).pct }}%</span>
</div>
<!-- Milestone progress bars -->
<div
v-if="project.summary?.milestone_summary?.length"
@@ -416,6 +437,34 @@ function truncate(text: string | null, max = 120): string {
line-height: 1.45;
}
.overall-bar-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
margin-top: 0.1rem;
}
.overall-bar-track {
flex: 1;
height: 6px;
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
}
.overall-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--color-primary);
transition: width 0.3s ease;
}
.overall-bar-pct {
color: var(--color-text-secondary);
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
font-weight: 600;
}
.milestone-bars {
display: flex;
flex-direction: column;
+13 -1
View File
@@ -23,7 +23,7 @@ interface Project {
title: string;
description: string | null;
goal: string | null;
status: "active" | "completed" | "archived";
status: "active" | "paused" | "completed" | "archived";
color: string | null;
auto_summary: string | null;
permission?: string;
@@ -118,6 +118,14 @@ function toggleMilestoneCollapse(id: number) {
}
}
function autoCollapseCompleted(msList: Milestone[]) {
for (const ms of msList) {
if (ms.total > 0 && ms.completed === ms.total) {
collapsedMilestones.value.add(ms.id);
}
}
}
async function loadProject() {
loading.value = true;
error.value = null;
@@ -130,6 +138,7 @@ async function loadProject() {
editStatus.value = data.status;
editDirty.value = false;
milestones.value = data.summary?.milestone_summary ?? [];
autoCollapseCompleted(milestones.value);
} catch {
error.value = "Failed to load project.";
} finally {
@@ -143,6 +152,7 @@ async function loadMilestones() {
`/api/projects/${projectId.value}/milestones`
);
milestones.value = data.milestones;
autoCollapseCompleted(milestones.value);
} catch {
// silent
}
@@ -395,6 +405,7 @@ async function confirmDelete() {
<label class="edit-label">Status</label>
<select v-model="editStatus" class="edit-select">
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
@@ -752,6 +763,7 @@ async function confirmDelete() {
flex-shrink: 0;
}
.status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); }
.status-paused { background: color-mix(in srgb, var(--color-accent-warm) 14%, transparent); color: var(--color-accent-warm); border: 1px solid color-mix(in srgb, var(--color-accent-warm) 30%, transparent); }
.status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); }
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); }
+2 -2
View File
@@ -362,7 +362,7 @@ async function confirmDelete() {
await store.deleteTask(taskId.value);
dirty.value = false;
toast.show("Task deleted");
router.push("/tasks");
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
} catch {
toast.show("Failed to delete task", "error");
}
@@ -410,7 +410,7 @@ useEditorGuards(dirty, save);
<main class="editor-page task-editor-page">
<div class="editor-header">
<div class="toolbar">
<router-link to="/tasks" class="btn-back"> Tasks</router-link>
<router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back"> {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
+141
View File
@@ -155,6 +155,44 @@ async def get_weather():
return jsonify({"locations": cards, "temp_unit": temp_unit})
@briefing_bp.route("/weather/current", methods=["GET"])
@_REQUIRE
async def get_current_weather():
"""Return current temperature, conditions, and precipitation for the user's primary location.
Lightweight — fetches live from Open-Meteo, no caching. Intended for periodic frontend polling.
"""
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = config.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
locations = config.get("locations", {})
except Exception:
return jsonify({"error": "No briefing config"}), 404
# Use home location, fall back to work
loc = locations.get("home") or locations.get("work")
if not loc or not loc.get("lat") or not loc.get("lon"):
return jsonify({"error": "No location configured"}), 404
from fabledassistant.services.weather import fetch_current_conditions
current = await fetch_current_conditions(loc["lat"], loc["lon"])
if current is None:
return jsonify({"error": "Failed to fetch current conditions"}), 502
# Convert temperature if needed
temp = current["temperature"]
if temp is not None and temp_unit == "F":
temp = temp * 9 / 5 + 32
current["temperature"] = round(temp) if temp is not None else None
current["temp_unit"] = temp_unit
current["location"] = loc.get("label") or "Home"
return jsonify(current)
@briefing_bp.route("/weather/geocode", methods=["POST"])
@_REQUIRE
async def geocode_location():
@@ -506,3 +544,106 @@ async def discuss_article(item_id: int):
))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
@briefing_bp.route("/topics/<topic>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_topic(topic: str):
"""Discuss all recent articles in a topic cluster — multi-article deep analysis."""
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id required"}), 400
uid = g.user.id
# Verify conversation belongs to user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
# Find recent articles with this topic (last 2 days)
async with async_session() as session:
result = await session.execute(
select(RssItem)
.join(RssFeed, RssFeed.id == RssItem.feed_id)
.where(RssFeed.user_id == uid)
.where(RssItem.topics.contains([topic]))
.order_by(RssItem.published_at.desc().nullslast())
.limit(5)
)
items = list(result.scalars().all())
if not items:
return jsonify({"error": f"No articles found for topic '{topic}'"}), 404
# Fetch full content for each article
from fabledassistant.services.rss import _fetch_full_article
synthetic_tool_calls = []
for item in items:
content = await _fetch_full_article(item.url) if item.url else None
content = content or item.content or ""
synthetic_tool_calls.append({
"function": "read_article",
"arguments": {"url": item.url or ""},
"result": {
"success": True,
"type": "article_content",
"url": item.url or "",
"title": item.title or "",
"source": "",
"content": content[:8000], # cap per article to stay within context
"truncated": len(content) > 8000,
},
})
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
user_prompt = (
f"I'd like to discuss the {len(items)} articles about '{topic}'. "
"Don't just summarize each one — draw connections between the sources, "
"highlight where they agree or disagree, and share your analysis of what "
"this means. Let's have a real discussion about this topic."
)
await add_message(conv_id, "user", user_prompt)
conv = await get_conversation(uid, conv_id)
assert conv is not None
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
model = await get_setting(uid, "default_model", "") or ""
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
user_prompt,
think=True,
))
return jsonify({
"assistant_message_id": assistant_msg.id,
"status": "generating",
"article_count": len(items),
}), 202
+3 -3
View File
@@ -52,7 +52,7 @@ async def create_project_route():
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
status = data.get("status", "active")
if status not in ("active", "completed", "archived"):
if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await create_project(
uid,
@@ -88,8 +88,8 @@ async def update_project_route(project_id: int):
uid = get_current_user_id()
data = await request.get_json()
allowed = {"title", "description", "goal", "status", "color"}
fields = {k: v for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "completed", "archived"):
fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await update_project(uid, project_id, **fields)
if project is None:
+414 -33
View File
@@ -23,14 +23,25 @@ SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
def format_task(task: dict) -> str:
def format_task(task: dict, today: str | None = None) -> str:
parts = [task.get("title", "Untitled")]
if task.get("status"):
parts.append(f"[{task['status'].replace('_', ' ').title()}]")
if task.get("due_date"):
parts.append(f"due {task['due_date']}")
if today and task["due_date"] < today and task.get("status") not in ("done", "cancelled"):
from datetime import date
try:
due = date.fromisoformat(task["due_date"])
td = date.fromisoformat(today)
days = (td - due).days
parts.append(f"({days} day{'s' if days != 1 else ''} overdue)")
except ValueError:
pass
if task.get("priority") and task["priority"] not in ("none", ""):
parts.append(f"priority: {task['priority']}")
if task.get("project_title"):
parts.append(f"project: {task['project_title']}")
return "".join(parts)
@@ -134,6 +145,13 @@ async def _gather_internal(user_id: int) -> dict:
all_tasks: list[dict] = []
try:
all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100)
# Build project title lookup for enrichment
project_titles: dict[int, str] = {}
try:
for p in await list_projects(user_id):
project_titles[p.id] = p.title or "Untitled"
except Exception:
pass
all_tasks = [
{
"task_id": t.id,
@@ -141,21 +159,22 @@ async def _gather_internal(user_id: int) -> dict:
"status": t.status,
"due_date": t.due_date.isoformat() if t.due_date else None,
"priority": t.priority,
"project_title": project_titles.get(t.project_id) if t.project_id else None,
}
for t in all_task_objs
]
overdue = [
format_task(t) for t in all_tasks
if t.get("due_date") and t["due_date"] < today and t.get("status") != "done"
format_task(t, today) for t in all_tasks
if t.get("due_date") and t["due_date"] < today and t.get("status") not in ("done", "cancelled")
]
due_today = [
format_task(t) for t in all_tasks
if t.get("due_date") == today and t.get("status") != "done"
format_task(t, today) for t in all_tasks
if t.get("due_date") == today and t.get("status") not in ("done", "cancelled")
]
high_priority = [
format_task(t) for t in all_tasks
if t.get("priority") == "high" and t.get("status") not in ("done",) and
format_task(t) not in overdue and format_task(t) not in due_today
format_task(t, today) for t in all_tasks
if t.get("priority") == "high" and t.get("status") not in ("done", "cancelled") and
format_task(t, today) not in overdue and format_task(t, today) not in due_today
][:5]
except Exception:
logger.warning("Failed to gather tasks for briefing", exc_info=True)
@@ -163,6 +182,7 @@ async def _gather_internal(user_id: int) -> dict:
# Calendar events today — internal store
calendar_events: list[str] = []
internal_events: list[dict] = []
try:
from fabledassistant.services.events import list_events as list_internal_events
today_date = datetime.now(user_tz).date()
@@ -197,6 +217,87 @@ async def _gather_internal(user_id: int) -> dict:
except Exception:
logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True)
# Weather × calendar cross-reference
weather_warnings: list[str] = []
outdoor_keywords = {"park", "field", "patio", "garden", "outdoor", "trail", "beach",
"pool", "stadium", "yard", "lawn", "hike", "walk", "run", "bike",
"soccer", "baseball", "football", "tennis", "golf", "playground"}
try:
import json as _json
raw_config = await get_setting(user_id, "briefing_config", "{}")
config = _json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
locations = config.get("locations", {})
loc = locations.get("home") or locations.get("work")
if loc and loc.get("lat") and loc.get("lon") and internal_events:
from fabledassistant.services.weather import fetch_hourly_precip
hourly_precip = await fetch_hourly_precip(loc["lat"], loc["lon"])
if hourly_precip:
for e in internal_events:
if e.get("all_day"):
continue
# Check if event title or location contains outdoor keywords
event_text = f"{e.get('title', '')} {e.get('location', '')}".lower()
if not any(kw in event_text for kw in outdoor_keywords):
continue
# Get the event's start hour
start = e.get("start_dt")
if not start:
continue
from datetime import datetime as _dt
if isinstance(start, str):
start = _dt.fromisoformat(start)
local_start = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
# Check precip probability around the event time (hour before through event)
hour_key = local_start.strftime("%Y-%m-%dT%H:00")
precip = hourly_precip.get(hour_key, 0)
if precip >= 30:
time_str = local_start.strftime("%-I:%M %p")
weather_warnings.append(
f"Rain likely ({precip}%) around {time_str} when '{e.get('title', 'Event')}' "
f"is scheduled — consider an umbrella or backup plan"
)
except Exception:
logger.debug("Weather cross-reference failed", exc_info=True)
# Recent activity — what was the user working on yesterday?
recent_activity: list[str] = []
stale_projects: list[str] = []
try:
from sqlalchemy import select as _sel, func as _func
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
yesterday = datetime.now(user_tz) - __import__('datetime').timedelta(days=1)
async with async_session() as session:
# Notes/tasks updated in last 24h, grouped by project
result = await session.execute(
_sel(Project.title, _func.count(Note.id))
.join(Note, Note.project_id == Project.id)
.where(Note.user_id == user_id)
.where(Note.updated_at >= yesterday)
.group_by(Project.title)
.order_by(_func.count(Note.id).desc())
.limit(3)
)
for proj_title, count in result.all():
recent_activity.append(f"{proj_title}: {count} item{'s' if count != 1 else ''} updated")
# Stale projects — active projects with no note/task activity in 7+ days
week_ago = datetime.now(user_tz) - __import__('datetime').timedelta(days=7)
active_projects_q = await session.execute(
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
)
for p in active_projects_q.scalars().all():
latest = await session.execute(
_sel(_func.max(Note.updated_at))
.where(Note.project_id == p.id, Note.user_id == user_id)
)
last_activity = latest.scalar()
if last_activity is None or last_activity < week_ago:
stale_projects.append(p.title or "Untitled")
except Exception:
logger.debug("Failed to gather recent activity for briefing", exc_info=True)
# Projects: active projects
projects_summary = []
try:
@@ -206,6 +307,51 @@ async def _gather_internal(user_id: int) -> dict:
except Exception:
logger.warning("Failed to gather projects for briefing", exc_info=True)
# Build prioritized "Your Day" focus list
# Priority: overdue > due today > high priority in-progress > other in-progress
focus_tasks: list[str] = []
seen_titles: set[str] = set()
for t in sorted(all_tasks, key=lambda x: (
0 if (x.get("due_date") and x["due_date"] < today and x.get("status") not in ("done", "cancelled")) else
1 if (x.get("due_date") == today and x.get("status") not in ("done", "cancelled")) else
2 if (x.get("priority") == "high" and x.get("status") not in ("done", "cancelled")) else
3 if (x.get("status") == "in_progress") else 4
)):
if t.get("status") in ("done", "cancelled"):
continue
formatted = format_task(t, today)
if formatted not in seen_titles:
seen_titles.add(formatted)
focus_tasks.append(formatted)
if len(focus_tasks) >= 5:
break
# Calendar gap analysis — extract event times for LLM
event_time_blocks: list[str] = []
for e in internal_events:
try:
if e.get("all_day"):
continue
start = e.get("start_dt")
end = e.get("end_dt")
if not start:
continue
from datetime import datetime as _dt
if isinstance(start, str):
start = _dt.fromisoformat(start)
if isinstance(end, str):
end = _dt.fromisoformat(end)
s_local = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
s_str = s_local.strftime("%-I:%M %p")
if end:
e_local = end.astimezone(user_tz) if end.tzinfo else end.replace(tzinfo=timezone.utc).astimezone(user_tz)
e_str = e_local.strftime("%-I:%M %p")
event_time_blocks.append(f"{e.get('title', 'Event')}: {s_str}{e_str}")
else:
event_time_blocks.append(f"{e.get('title', 'Event')}: {s_str}?")
except Exception:
pass
return {
"date": today,
"overdue_tasks": overdue,
@@ -214,6 +360,80 @@ async def _gather_internal(user_id: int) -> dict:
"calendar_events": calendar_events,
"active_projects": projects_summary,
"all_tasks_raw": all_tasks,
"focus_tasks": focus_tasks,
"event_time_blocks": event_time_blocks,
"weather_warnings": weather_warnings,
"recent_activity": recent_activity,
"stale_projects": stale_projects,
}
async def _gather_weekly_review(user_id: int) -> dict:
"""Collect 7-day activity summary for the weekly review."""
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import list_projects
from fabledassistant.services.events import list_events as list_internal_events
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
try:
user_tz = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
user_tz = ZoneInfo("UTC")
now = datetime.now(user_tz)
week_ago = now - __import__('datetime').timedelta(days=7)
next_week = now + __import__('datetime').timedelta(days=7)
# Tasks completed this week
completed_tasks = []
overdue_tasks = []
try:
all_task_objs, _ = await list_notes(user_id, is_task=True, limit=200)
for t in all_task_objs:
if t.status == "done" and t.completed_at and t.completed_at >= week_ago:
completed_tasks.append(t.title or "Untitled")
elif t.due_date and t.due_date.isoformat() < now.date().isoformat() and t.status not in ("done", "cancelled"):
overdue_tasks.append(t.title or "Untitled")
except Exception:
logger.debug("Weekly review: failed to gather tasks", exc_info=True)
# Notes created this week
notes_created = 0
try:
all_notes, _ = await list_notes(user_id, is_task=False, limit=200)
notes_created = sum(1 for n in all_notes if n.created_at and n.created_at >= week_ago)
except Exception:
logger.debug("Weekly review: failed to count notes", exc_info=True)
# Project activity
active_projects = []
try:
projects = await list_projects(user_id)
active_projects = [p.title for p in projects if p.status == "active"][:5]
except Exception:
pass
# Events next week
upcoming_events = []
try:
next_events = await list_internal_events(
user_id=user_id,
date_from=now,
date_to=next_week,
)
for e in next_events[:8]:
upcoming_events.append(f"{e.get('title', 'Event')}{e.get('start_dt', '')[:10]}")
except Exception:
pass
return {
"completed_count": len(completed_tasks),
"completed_highlights": completed_tasks[:5],
"overdue_count": len(overdue_tasks),
"overdue_items": overdue_tasks[:5],
"notes_created": notes_created,
"active_projects": active_projects,
"upcoming_events": upcoming_events,
}
@@ -259,23 +479,107 @@ async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str, num_
return ""
def _unified_system_prompt(profile_body: str) -> str:
return (
"You are a personal assistant delivering a daily briefing. "
def _unified_system_prompt(profile_body: str, slot: str = "compilation") -> str:
base = (
"You are a personal assistant. "
"Speak naturally and conversationally — as if talking to the user, not writing a report. "
"Use no markdown: no headers, no bullet points, no bold, no lists. Write in flowing prose. "
"Weave together what matters today: mention the weather in a sentence, note any calendar "
"events or tasks due today, and briefly reference one or two noteworthy news stories. "
"Only mention projects if a task from one is specifically due today. "
"Be warm, concise, and human — aim for 3 to 5 sentences. "
"Future context like emails and messages will be added over time — keep the tone open and helpful.\n\n"
+ (f"User profile:\n{profile_body}\n" if profile_body else "")
"Be warm, concise, and human. "
"The user can reply to act on your suggestions via tool calls.\n\n"
)
if slot == "compilation":
base += (
"This is the MORNING BRIEFING — the full daily overview. "
"Weave together what matters today: mention the weather, note calendar events and tasks, "
"briefly reference one or two noteworthy news themes, and suggest a daily focus. "
"Be actionable: for overdue or due-today tasks, suggest a concrete next step. "
"Aim for 4 to 8 sentences.\n\n"
)
elif slot in ("morning", "midday"):
base += (
"This is a MIDDAY CHECK-IN — brief progress nudge, not a full briefing. "
"Focus on what's been done and what hasn't been touched yet today. "
"If weather conditions have changed, mention it briefly. "
"Keep it to 2-3 sentences. Don't repeat news or the full task list.\n\n"
)
elif slot == "afternoon":
base += (
"This is the AFTERNOON WRAP-UP — help the user close out the day. "
"Summarize what was accomplished today. Flag anything still overdue. "
"Ask if there's anything to capture before tomorrow — notes, thoughts, follow-ups. "
"Keep it to 2-4 sentences. Reflective and encouraging tone.\n\n"
)
elif slot == "weekly_review":
base += (
"This is the WEEKLY REVIEW — a reflective recap of the past 7 days. "
"Summarize accomplishments, flag lingering overdue items, note how many notes were created, "
"and preview the upcoming week's key events. "
"Be reflective and encouraging — celebrate progress, gently flag what's stuck. "
"Aim for 5 to 8 sentences. This wraps up a chapter.\n\n"
)
if profile_body:
base += f"User profile:\n{profile_body}\n"
return base
def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, temp_unit: str = "C") -> str:
lines = [f"Date: {internal_data['date']}", f"Slot: {slot}", ""]
# Weekly review — separate data path
if slot == "weekly_review":
weekly = internal_data.get("weekly_review") or {}
lines.append(f"COMPLETED THIS WEEK: {weekly.get('completed_count', 0)} tasks")
if weekly.get("completed_highlights"):
lines.extend(f" - {t}" for t in weekly["completed_highlights"])
lines.append("")
if weekly.get("overdue_count", 0) > 0:
lines.append(f"STILL OVERDUE: {weekly['overdue_count']} task(s)")
lines.extend(f" - {t}" for t in weekly.get("overdue_items", []))
lines.append("")
lines.append(f"NOTES CREATED: {weekly.get('notes_created', 0)}")
lines.append("")
if weekly.get("active_projects"):
lines.append(f"ACTIVE PROJECTS: {', '.join(weekly['active_projects'])}")
lines.append("")
if weekly.get("upcoming_events"):
lines.append("UPCOMING WEEK:")
lines.extend(f" - {e}" for e in weekly["upcoming_events"])
lines.append("")
return "\n".join(lines)
# For check-in slots, build a slimmer prompt
if slot in ("morning", "midday"):
# Just tasks status + any weather changes
if internal_data.get("due_today"):
lines.append("TASKS DUE TODAY (note which are still untouched):")
lines.extend(f" - {t}" for t in internal_data["due_today"])
lines.append("")
if internal_data.get("overdue_tasks"):
lines.append(f"STILL OVERDUE: {len(internal_data['overdue_tasks'])} task(s)")
lines.append("")
if internal_data.get("calendar_events"):
lines.append("REMAINING EVENTS TODAY:")
lines.extend(f" - {e}" for e in internal_data["calendar_events"])
lines.append("")
return "\n".join(lines)
if slot == "afternoon":
if internal_data.get("due_today"):
lines.append("TASKS THAT WERE DUE TODAY:")
lines.extend(f" - {t}" for t in internal_data["due_today"])
lines.append("")
if internal_data.get("overdue_tasks"):
overdue = internal_data["overdue_tasks"]
lines.append(f"OVERDUE ({len(overdue)} remaining):")
lines.extend(f" - {t}" for t in overdue[:3])
lines.append("")
lines.append("Ask the user if there's anything to capture before tomorrow — a note, a thought, a follow-up task.")
return "\n".join(lines)
# ── Full compilation prompt below ──────────────────────────────────────────
# Weather (brief — card handles detail)
weather = external_data.get("weather") or []
if weather:
@@ -298,32 +602,104 @@ def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, te
lines.extend(f" - {e}" for e in internal_data["calendar_events"])
lines.append("")
# Weather warnings for outdoor events
if internal_data.get("weather_warnings"):
lines.append("WEATHER ALERTS (mention these naturally when discussing affected events):")
lines.extend(f"{w}" for w in internal_data["weather_warnings"])
lines.append("")
# Tasks due today
if internal_data.get("due_today"):
lines.append("DUE TODAY:")
lines.append("DUE TODAY (suggest next steps — offer to mark in progress or help prioritize):")
lines.extend(f" - {t}" for t in internal_data["due_today"])
lines.append("")
# Overdue tasks (brief mention only)
# Overdue tasks
if internal_data.get("overdue_tasks"):
overdue = internal_data["overdue_tasks"]
lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}):")
lines.extend(f" - {t}" for t in overdue[:3])
if len(overdue) > 3:
lines.append(f" (and {len(overdue) - 3} more)")
lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}) — suggest rescheduling or breaking down:")
lines.extend(f" - {t}" for t in overdue[:5])
if len(overdue) > 5:
lines.append(f" (and {len(overdue) - 5} more)")
lines.append("")
# News highlights (top 3 with excerpts — right panel shows full list)
# High priority in-progress
if internal_data.get("high_priority"):
lines.append("HIGH PRIORITY:")
lines.extend(f" - {t}" for t in internal_data["high_priority"])
lines.append("")
# Your Day — prioritized focus + calendar gaps (compilation slot only)
if internal_data.get("focus_tasks") and slot == "compilation":
lines.append("YOUR DAY — top tasks to focus on (mention these as a suggested plan):")
for i, t in enumerate(internal_data["focus_tasks"], 1):
lines.append(f" {i}. {t}")
if internal_data.get("event_time_blocks"):
lines.append("")
lines.append(" Scheduled time blocks:")
lines.extend(f" {b}" for b in internal_data["event_time_blocks"])
lines.append(" (Identify free blocks between events for focused work.)")
lines.append("")
# Project continuity — what was the user working on?
if internal_data.get("recent_activity") and slot == "compilation":
lines.append("YESTERDAY'S ACTIVITY (mention what the user was working on to help them pick up):")
lines.extend(f" - {a}" for a in internal_data["recent_activity"])
lines.append("")
if internal_data.get("stale_projects") and slot == "compilation":
lines.append("STALE PROJECTS (no activity in 7+ days — gently flag):")
lines.extend(f" - {p}" for p in internal_data["stale_projects"])
lines.append("")
# News — clustered by topic, ranked by preference score
rss = external_data.get("rss_items") or []
topic_scores = external_data.get("topic_scores") or {}
if rss:
lines.append("NEWS HIGHLIGHTS (weave 1-2 into your briefing naturally; the full list is shown separately):")
for item in rss[:3]:
source = item.get("feed_title") or item.get("source") or "News"
title = item.get("title", "")
excerpt = (item.get("content") or item.get("snippet") or "")[:500].strip()
lines.append(f" [{source}] {title}")
if excerpt:
lines.append(f" {excerpt}")
# Group articles by their primary topic
clusters: dict[str, list[dict]] = {}
uncategorized: list[dict] = []
for item in rss:
topics = item.get("topics") or []
if topics:
primary = topics[0]
clusters.setdefault(primary, []).append(item)
else:
uncategorized.append(item)
# Score each cluster by aggregate preference (positive = user likes this topic)
def cluster_score(topic: str, items: list[dict]) -> float:
pref = topic_scores.get(topic, 0.0)
return pref * 2.0 + len(items) # preference-weighted, size as tiebreak
# Filter out clusters where the user has a strongly negative preference
filtered_clusters = [
(topic, items) for topic, items in clusters.items()
if topic_scores.get(topic, 0.0) >= -2.0
]
# Sort by preference-weighted score
sorted_clusters = sorted(filtered_clusters, key=lambda x: cluster_score(x[0], x[1]), reverse=True)
lines.append("NEWS THEMES (synthesize 1-2 themes into your briefing; mention article count per theme):")
for i, (topic, items) in enumerate(sorted_clusters[:4]):
count = len(items)
titles = [it.get("title", "") for it in items[:3]]
sources = list({it.get("feed_title") or it.get("source") or "News" for it in items})
pref_indicator = ""
pref = topic_scores.get(topic, 0.0)
if pref >= 2.0:
pref_indicator = "" # user's preferred topic
lines.append(f" [{topic.title()}]{pref_indicator} ({count} article{'s' if count != 1 else ''} from {', '.join(sources[:2])})")
for t in titles:
lines.append(f"{t}")
# Include excerpt for preferred clusters or the top cluster
if pref >= 1.0 or i == 0:
excerpt = (items[0].get("content") or items[0].get("snippet") or "")[:400].strip()
if excerpt:
lines.append(f" > {excerpt}")
if uncategorized:
lines.append(f" [Other] ({len(uncategorized)} uncategorized article{'s' if len(uncategorized) != 1 else ''})")
lines.append("")
return "\n".join(lines)
@@ -389,6 +765,10 @@ async def run_compilation(
get_cached_weather_rows(user_id),
)
# Weekly review: gather 7-day summary data
if slot == "weekly_review":
internal_data["weekly_review"] = await _gather_weekly_review(user_id)
# Task change detection
all_tasks = internal_data.get("all_tasks_raw", [])
changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks)
@@ -429,10 +809,11 @@ async def run_compilation(
external_data_filtered = {
"rss_items": filtered_rss,
"weather": [],
"topic_scores": topic_scores,
}
briefing_text = await _llm_synthesise(
_unified_system_prompt(profile_context),
_unified_system_prompt(profile_context, slot),
_unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit),
model,
num_ctx=8192,
@@ -13,7 +13,7 @@ functions wrapped with asyncio.run_coroutine_threadsafe().
import asyncio
import logging
from datetime import date, datetime, time, timedelta
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.background import BackgroundScheduler
@@ -36,6 +36,11 @@ SLOTS = [
("afternoon", 16, 0),
]
# Weekly review runs Sunday at 6pm by default
WEEKLY_REVIEW_DAY = "sun" # APScheduler day_of_week format
WEEKLY_REVIEW_HOUR = 18
WEEKLY_REVIEW_MINUTE = 0
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -102,6 +107,26 @@ def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
replace_existing=True,
misfire_grace_time=3600,
)
# Weekly review job — runs once per week
weekly_jid = _job_id(user_id, "weekly_review")
weekly_on = enabled_slots.get("weekly_review", True)
if weekly_on:
_scheduler.add_job(
_run_user_slot_sync,
CronTrigger(
day_of_week=WEEKLY_REVIEW_DAY,
hour=WEEKLY_REVIEW_HOUR,
minute=WEEKLY_REVIEW_MINUTE,
timezone=tz,
),
args=[user_id, "weekly_review"],
id=weekly_jid,
replace_existing=True,
misfire_grace_time=7200,
)
elif _scheduler.get_job(weekly_jid):
_scheduler.remove_job(weekly_jid)
logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz)
@@ -113,6 +138,9 @@ def _remove_user_jobs(user_id: int) -> None:
jid = _job_id(user_id, slot_name)
if _scheduler.get_job(jid):
_scheduler.remove_job(jid)
weekly_jid = _job_id(user_id, "weekly_review")
if _scheduler.get_job(weekly_jid):
_scheduler.remove_job(weekly_jid)
logger.info("Removed briefing jobs for user %d", user_id)
@@ -134,6 +162,35 @@ def update_user_schedule(user_id: int, config: dict, tz_override: str | None = N
# ── Job execution ─────────────────────────────────────────────────────────────
async def _auto_pause_stale_projects(user_id: int) -> list[str]:
"""Pause active projects with no note/task activity in 14+ days. Returns paused project titles."""
from sqlalchemy import select as _sel, func as _func
from fabledassistant.models.project import Project
from fabledassistant.models.note import Note
from fabledassistant.models import async_session as _session
paused: list[str] = []
threshold = datetime.now(timezone.utc) - timedelta(days=14)
try:
async with _session() as session:
projects = (await session.execute(
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
)).scalars().all()
for p in projects:
latest = (await session.execute(
_sel(_func.max(Note.updated_at)).where(Note.project_id == p.id)
)).scalar()
if latest is None or latest < threshold:
p.status = "paused"
paused.append(p.title or "Untitled")
if paused:
await session.commit()
logger.info("Auto-paused %d stale projects for user %d: %s", len(paused), user_id, paused)
except Exception:
logger.debug("Auto-pause check failed for user %d", user_id, exc_info=True)
return paused
async def _run_slot_for_user(user_id: int, slot: str) -> None:
"""Execute one slot job for one user."""
from fabledassistant.services.briefing_conversations import (
@@ -164,6 +221,9 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
if slot == "compilation":
# Auto-pause stale projects before compiling the briefing
await _auto_pause_stale_projects(user_id)
# Refresh external data first
try:
import json
+19 -13
View File
@@ -40,29 +40,35 @@ _EMAIL_LOGO_SVG = (
def _email_html(title: str, body: str) -> str:
"""Wrap email body content in the standard Fabled Assistant template."""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="padding:32px 16px;">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
</head>
<body style="margin:0;padding:0;background:#f5f3ff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<div style="padding:36px 16px 48px;">
<div style="max-width:520px;margin:0 auto;">
<div style="background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
<!-- Card -->
<div style="background:#ffffff;border:1px solid #ddd6fe;border-radius:14px;overflow:hidden;box-shadow:0 4px 24px rgba(109,40,217,0.08);">
<!-- Header -->
<div style="background:#6366f1;padding:20px 24px;text-align:center;">
<div style="margin-bottom:6px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:17px;font-weight:600;letter-spacing:0.01em;">Fabled Assistant</span>
<div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
<div style="margin-bottom:8px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Assistant</span>
</div>
<p style="margin:0;color:#c7d2fe;font-size:13px;">{title}</p>
<p style="margin:0;color:#ede9fe;font-size:13px;letter-spacing:0.03em;">{title}</p>
</div>
<!-- Body -->
<div style="padding:28px 24px;">
<div style="padding:32px 28px;color:#1e1b4b;">
{body}
</div>
<!-- Footer -->
<div style="border-top:1px solid #e5e7eb;padding:14px 24px;text-align:center;background:#f9fafb;">
<p style="margin:0;color:#9ca3af;font-size:12px;">This email was sent by your Fabled Assistant instance.</p>
<div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Assistant instance.</p>
</div>
</div>
@@ -169,7 +175,7 @@ async def send_email(to: str, subject: str, html_body: str) -> None:
async def send_test_email(to: str) -> None:
"""Send a branded test email."""
body = """
<p style="margin:0 0 12px;color:#111827;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<p style="margin:0 0 12px;color:#1e1b4b;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p>
"""
await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))
+56 -16
View File
@@ -122,34 +122,74 @@ async def _semantic_knowledge_search(
limit: int,
offset: int,
) -> tuple[list[dict], int]:
"""Semantic search over knowledge objects, with SQL filters applied post-rank."""
"""Hybrid search: keyword matches first (title/body ILIKE), then semantic results.
Exact keyword matches always rank above semantic-only matches so that
searching for a name like "Weston" surfaces the note with that title
before conceptually related notes.
"""
# 1. Keyword search — title and body ILIKE
keyword_notes: list[Note] = []
try:
async with async_session() as session:
pattern = f"%{q}%"
base = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.title.ilike(pattern) | Note.body.ilike(pattern))
)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
for tag in tags:
base = base.where(Note.tags.contains([tag]))
# Title matches first, then body-only matches, newest first within each
base = base.order_by(
Note.title.ilike(pattern).desc(),
Note.updated_at.desc(),
).limit(limit * 2)
keyword_notes = list((await session.execute(base)).scalars().all())
except Exception:
logger.warning("Keyword search failed", exc_info=True)
# 2. Semantic search — conceptual similarity
semantic_notes: list[Note] = []
try:
from fabledassistant.services.embeddings import semantic_search_notes
# Fetch a larger candidate set to allow for filtering
is_task_filter = True if note_type == "task" else (False if note_type else None)
candidates = await semantic_search_notes(
user_id=user_id,
query=q,
limit=min(200, limit * 8),
limit=min(200, limit * 4),
threshold=0.3,
is_task=is_task_filter,
)
for _score, note in candidates:
if note_type == "task" and not note.is_task:
continue
elif note_type and note_type != "task" and note.entity_type != note_type:
continue
if tags and not all(t in (note.tags or []) for t in tags):
continue
semantic_notes.append(note)
except Exception:
logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True)
return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset)
logger.warning("Semantic search unavailable, using keyword results only", exc_info=True)
results = []
for _score, note in candidates:
if note_type == "task" and not note.is_task:
continue
elif note_type and note_type != "task" and note.entity_type != note_type:
continue
if tags and not all(t in (note.tags or []) for t in tags):
continue
results.append(note)
# 3. Merge — keyword matches first, then semantic (deduplicated)
seen_ids: set[int] = set()
merged: list[Note] = []
for note in keyword_notes:
if note.id not in seen_ids:
seen_ids.add(note.id)
merged.append(note)
for note in semantic_notes:
if note.id not in seen_ids:
seen_ids.add(note.id)
merged.append(note)
total = len(results)
page_items = results[offset: offset + limit]
total = len(merged)
page_items = merged[offset: offset + limit]
return [_note_to_item(n) for n in page_items], total
+18
View File
@@ -22,6 +22,22 @@ def _normalize_tags(tags: list[str]) -> list[str]:
return out
async def _maybe_reactivate_project(project_id: int) -> None:
"""If a project is paused, reactivate it — activity indicates resumed work."""
from fabledassistant.models.project import Project
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project and project.status == "paused":
project.status = "active"
await session.commit()
logger.info("Auto-reactivated paused project %d (%s)", project_id, project.title)
except Exception:
logger.debug("_maybe_reactivate_project failed for project %d", project_id, exc_info=True)
async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None:
"""Fire generate_project_summary() if the project summary is missing or >1h old."""
import asyncio
@@ -92,6 +108,7 @@ async def create_note(
await session.refresh(note)
if project_id is not None:
await _maybe_reactivate_project(project_id)
await _maybe_trigger_project_summary(user_id, project_id)
return note
@@ -284,6 +301,7 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
await create_version(user_id, note_id, old_body, old_title, old_tags)
if note.project_id is not None:
await _maybe_reactivate_project(note.project_id)
await _maybe_trigger_project_summary(user_id, note.project_id)
return note
@@ -103,7 +103,7 @@ async def send_password_reset_email(email: str, reset_url: str) -> None:
We received a request to reset your password. Click the button below to choose a new password.
</p>
<div style="text-align:center;margin:24px 0;">
<a href="{reset_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
<a href="{reset_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Reset Password
</a>
</div>
@@ -136,7 +136,7 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
Click the button below to create your account.
</p>
<div style="text-align:center;margin:24px 0;">
<a href="{invite_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
<a href="{invite_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Accept Invitation
</a>
</div>
+74
View File
@@ -184,6 +184,80 @@ async def geocode(query: str) -> tuple[float, float, str]:
return float(r["lat"]), float(r["lon"]), r.get("display_name", query)
async def fetch_current_conditions(lat: float, lon: float) -> dict | None:
"""Fetch current temperature + conditions + next 3 hours precipitation.
Lightweight call — no daily forecast, no caching needed.
Returns dict with: temperature, windspeed, weathercode, description,
and precip_next_3h (list of hourly precip probabilities).
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat,
"longitude": lon,
"current_weather": "true",
"hourly": "precipitation_probability",
"forecast_days": 1,
"timezone": "auto",
})
resp.raise_for_status()
data = resp.json()
current = data.get("current_weather", {})
hourly = data.get("hourly", {})
hourly_times = hourly.get("time", [])
hourly_precip = hourly.get("precipitation_probability", [])
# Find the current hour index and get next 3 hours of precip
current_time = current.get("time", "")
precip_next_3h = []
try:
idx = next(i for i, t in enumerate(hourly_times) if t >= current_time)
precip_next_3h = hourly_precip[idx:idx + 3]
except (StopIteration, IndexError):
pass
code = current.get("weathercode", 0)
return {
"temperature": current.get("temperature"),
"windspeed": current.get("windspeed"),
"weathercode": code,
"description": describe_weathercode(code),
"time": current_time,
"precip_next_3h": precip_next_3h,
}
except Exception:
logger.debug("Failed to fetch current conditions for %s, %s", lat, lon, exc_info=True)
return None
async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]:
"""Fetch today's hourly precipitation probabilities.
Returns a dict of ISO hour string → probability percentage, e.g.:
{"2026-04-08T14:00": 65, "2026-04-08T15:00": 80, ...}
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat,
"longitude": lon,
"hourly": "precipitation_probability",
"forecast_days": 1,
"timezone": "auto",
})
resp.raise_for_status()
data = resp.json()
hourly = data.get("hourly", {})
times = hourly.get("time", [])
probs = hourly.get("precipitation_probability", [])
return {t: p for t, p in zip(times, probs) if p is not None}
except Exception:
logger.debug("Failed to fetch hourly precip for %s, %s", lat, lon, exc_info=True)
return {}
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
async with httpx.AsyncClient(timeout=15.0) as client: