Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fb81bc1ed | |||
| c1bc73da8e | |||
| 3b8d40fea3 | |||
| 2f5abc034e | |||
| 7a01733334 | |||
| 7d5611d00b | |||
| e5821ef3f8 | |||
| 43231f44d2 | |||
| ab0b9c3199 | |||
| d12fc5d282 | |||
| 4c3d67994b | |||
| 126bad7d99 | |||
| 28fae75258 | |||
| 058bd76719 | |||
| d56b57ee40 | |||
| 8762552234 | |||
| 6cbf9be052 | |||
| 3687fbeeb9 | |||
| a6543c1dc5 | |||
| 4558dd578a | |||
| 8647f52fbc | |||
| 304affb837 | |||
| 2e3f90384d |
@@ -208,7 +208,7 @@ router.afterEach(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
padding: 0.3rem 0.75rem;
|
padding: 0.3rem 0.75rem;
|
||||||
@@ -216,11 +216,11 @@ router.afterEach(() => {
|
|||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text);
|
||||||
background: var(--color-primary-tint);
|
background: var(--color-primary-tint);
|
||||||
}
|
}
|
||||||
.nav-link.router-link-active {
|
.nav-link.router-link-active {
|
||||||
color: #c4b5fd;
|
color: var(--color-primary-solid);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: rgba(124, 58, 237, 0.25);
|
background: rgba(124, 58, 237, 0.25);
|
||||||
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
|
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
|||||||
const weatherData = ref<WeatherData[]>([])
|
const weatherData = ref<WeatherData[]>([])
|
||||||
const tempUnit = ref<string>('C')
|
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() {
|
async function loadWeather() {
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
|
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
|
||||||
@@ -90,6 +111,7 @@ async function loadAll() {
|
|||||||
getBriefingToday().catch(() => null),
|
getBriefingToday().catch(() => null),
|
||||||
loadWeather(),
|
loadWeather(),
|
||||||
loadNews(),
|
loadNews(),
|
||||||
|
loadCurrentConditions(),
|
||||||
])
|
])
|
||||||
conversations.value = convList
|
conversations.value = convList
|
||||||
if (today) {
|
if (today) {
|
||||||
@@ -198,11 +220,18 @@ useBackgroundRefresh(
|
|||||||
)
|
)
|
||||||
|
|
||||||
let _mounted = true
|
let _mounted = true
|
||||||
onUnmounted(() => { _mounted = false })
|
onUnmounted(() => {
|
||||||
|
_mounted = false
|
||||||
|
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await checkSetup()
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -235,6 +264,7 @@ onMounted(async () => {
|
|||||||
<!-- Left column: Weather -->
|
<!-- Left column: Weather -->
|
||||||
<div class="briefing-left">
|
<div class="briefing-left">
|
||||||
<div class="panel-label">Weather</div>
|
<div class="panel-label">Weather</div>
|
||||||
|
<!-- Forecast card -->
|
||||||
<template v-if="weatherData.length">
|
<template v-if="weatherData.length">
|
||||||
<WeatherCard
|
<WeatherCard
|
||||||
v-for="loc in weatherData"
|
v-for="loc in weatherData"
|
||||||
@@ -457,6 +487,8 @@ onMounted(async () => {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Current conditions (live) ─────────────────────────── */
|
||||||
|
|
||||||
.panel-empty {
|
.panel-empty {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ async function confirmDelete() {
|
|||||||
await store.deleteNote(noteId.value);
|
await store.deleteNote(noteId.value);
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note deleted");
|
toast.show("Note deleted");
|
||||||
router.push("/notes");
|
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
|
||||||
} catch {
|
} catch {
|
||||||
toast.show("Failed to delete note", "error");
|
toast.show("Failed to delete note", "error");
|
||||||
}
|
}
|
||||||
@@ -445,7 +445,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
<main class="editor-page note-editor-page">
|
<main class="editor-page note-editor-page">
|
||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<div class="toolbar">
|
<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">
|
<button class="btn-save" @click="save" :disabled="saving">
|
||||||
{{ saving ? "Saving..." : "Save" }}
|
{{ saving ? "Saving..." : "Save" }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface Project {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
goal: string | null;
|
goal: string | null;
|
||||||
status: "active" | "completed" | "archived";
|
status: "active" | "paused" | "completed" | "archived";
|
||||||
color: string | null;
|
color: string | null;
|
||||||
auto_summary: string | null;
|
auto_summary: string | null;
|
||||||
permission?: string;
|
permission?: string;
|
||||||
@@ -40,7 +40,7 @@ const toast = useToastStore();
|
|||||||
const projects = ref<Project[]>([]);
|
const projects = ref<Project[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
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
|
// New project modal
|
||||||
const showNewProjectModal = ref(false);
|
const showNewProjectModal = ref(false);
|
||||||
@@ -103,6 +103,7 @@ async function createProject() {
|
|||||||
|
|
||||||
function statusLabel(status: Project["status"]): string {
|
function statusLabel(status: Project["status"]): string {
|
||||||
if (status === "active") return "Active";
|
if (status === "active") return "Active";
|
||||||
|
if (status === "paused") return "Paused";
|
||||||
if (status === "completed") return "Completed";
|
if (status === "completed") return "Completed";
|
||||||
if (status === "archived") return "Archived";
|
if (status === "archived") return "Archived";
|
||||||
return status;
|
return status;
|
||||||
@@ -112,6 +113,14 @@ function truncate(text: string | null, max = 120): string {
|
|||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
return text.length > max ? text.slice(0, max) + "..." : text;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -124,7 +133,7 @@ function truncate(text: string | null, max = 120): string {
|
|||||||
<!-- Filter tabs -->
|
<!-- Filter tabs -->
|
||||||
<div class="filter-tabs">
|
<div class="filter-tabs">
|
||||||
<button
|
<button
|
||||||
v-for="tab in ['all', 'active', 'completed', 'archived'] as const"
|
v-for="tab in ['all', 'active', 'paused', 'completed', 'archived'] as const"
|
||||||
:key="tab"
|
:key="tab"
|
||||||
:class="['tab-btn', { active: activeTab === tab }]"
|
:class="['tab-btn', { active: activeTab === tab }]"
|
||||||
@click="activeTab = tab"
|
@click="activeTab = tab"
|
||||||
@@ -163,6 +172,18 @@ function truncate(text: string | null, max = 120): string {
|
|||||||
</p>
|
</p>
|
||||||
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</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 -->
|
<!-- Milestone progress bars -->
|
||||||
<div
|
<div
|
||||||
v-if="project.summary?.milestone_summary?.length"
|
v-if="project.summary?.milestone_summary?.length"
|
||||||
@@ -416,6 +437,34 @@ function truncate(text: string | null, max = 120): string {
|
|||||||
line-height: 1.45;
|
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 {
|
.milestone-bars {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface Project {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
goal: string | null;
|
goal: string | null;
|
||||||
status: "active" | "completed" | "archived";
|
status: "active" | "paused" | "completed" | "archived";
|
||||||
color: string | null;
|
color: string | null;
|
||||||
auto_summary: string | null;
|
auto_summary: string | null;
|
||||||
permission?: string;
|
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() {
|
async function loadProject() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
@@ -130,6 +138,7 @@ async function loadProject() {
|
|||||||
editStatus.value = data.status;
|
editStatus.value = data.status;
|
||||||
editDirty.value = false;
|
editDirty.value = false;
|
||||||
milestones.value = data.summary?.milestone_summary ?? [];
|
milestones.value = data.summary?.milestone_summary ?? [];
|
||||||
|
autoCollapseCompleted(milestones.value);
|
||||||
} catch {
|
} catch {
|
||||||
error.value = "Failed to load project.";
|
error.value = "Failed to load project.";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -143,6 +152,7 @@ async function loadMilestones() {
|
|||||||
`/api/projects/${projectId.value}/milestones`
|
`/api/projects/${projectId.value}/milestones`
|
||||||
);
|
);
|
||||||
milestones.value = data.milestones;
|
milestones.value = data.milestones;
|
||||||
|
autoCollapseCompleted(milestones.value);
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
@@ -395,6 +405,7 @@ async function confirmDelete() {
|
|||||||
<label class="edit-label">Status</label>
|
<label class="edit-label">Status</label>
|
||||||
<select v-model="editStatus" class="edit-select">
|
<select v-model="editStatus" class="edit-select">
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
|
<option value="paused">Paused</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
<option value="archived">Archived</option>
|
<option value="archived">Archived</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -752,6 +763,7 @@ async function confirmDelete() {
|
|||||||
flex-shrink: 0;
|
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-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-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); }
|
.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); }
|
||||||
|
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ async function confirmDelete() {
|
|||||||
await store.deleteTask(taskId.value);
|
await store.deleteTask(taskId.value);
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Task deleted");
|
toast.show("Task deleted");
|
||||||
router.push("/tasks");
|
router.push(projectId.value ? `/projects/${projectId.value}` : "/");
|
||||||
} catch {
|
} catch {
|
||||||
toast.show("Failed to delete task", "error");
|
toast.show("Failed to delete task", "error");
|
||||||
}
|
}
|
||||||
@@ -410,7 +410,7 @@ useEditorGuards(dirty, save);
|
|||||||
<main class="editor-page task-editor-page">
|
<main class="editor-page task-editor-page">
|
||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<div class="toolbar">
|
<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">
|
<button class="btn-save" @click="save" :disabled="saving">
|
||||||
{{ saving ? "Saving..." : "Save" }}
|
{{ saving ? "Saving..." : "Save" }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -155,6 +155,44 @@ async def get_weather():
|
|||||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
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"])
|
@briefing_bp.route("/weather/geocode", methods=["POST"])
|
||||||
@_REQUIRE
|
@_REQUIRE
|
||||||
async def geocode_location():
|
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
|
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
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ async def create_project_route():
|
|||||||
if not data.get("title"):
|
if not data.get("title"):
|
||||||
return jsonify({"error": "title is required"}), 400
|
return jsonify({"error": "title is required"}), 400
|
||||||
status = data.get("status", "active")
|
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
|
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
|
||||||
project = await create_project(
|
project = await create_project(
|
||||||
uid,
|
uid,
|
||||||
@@ -88,8 +88,8 @@ async def update_project_route(project_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
allowed = {"title", "description", "goal", "status", "color"}
|
allowed = {"title", "description", "goal", "status", "color"}
|
||||||
fields = {k: v for k, v in data.items() if k in allowed}
|
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", "completed", "archived"):
|
if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
|
||||||
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
|
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
|
||||||
project = await update_project(uid, project_id, **fields)
|
project = await update_project(uid, project_id, **fields)
|
||||||
if project is None:
|
if project is None:
|
||||||
|
|||||||
@@ -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")]
|
parts = [task.get("title", "Untitled")]
|
||||||
if task.get("status"):
|
if task.get("status"):
|
||||||
parts.append(f"[{task['status'].replace('_', ' ').title()}]")
|
parts.append(f"[{task['status'].replace('_', ' ').title()}]")
|
||||||
if task.get("due_date"):
|
if task.get("due_date"):
|
||||||
parts.append(f"due {task['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", ""):
|
if task.get("priority") and task["priority"] not in ("none", ""):
|
||||||
parts.append(f"priority: {task['priority']}")
|
parts.append(f"priority: {task['priority']}")
|
||||||
|
if task.get("project_title"):
|
||||||
|
parts.append(f"project: {task['project_title']}")
|
||||||
return " — ".join(parts)
|
return " — ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@@ -134,6 +145,13 @@ async def _gather_internal(user_id: int) -> dict:
|
|||||||
all_tasks: list[dict] = []
|
all_tasks: list[dict] = []
|
||||||
try:
|
try:
|
||||||
all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100)
|
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 = [
|
all_tasks = [
|
||||||
{
|
{
|
||||||
"task_id": t.id,
|
"task_id": t.id,
|
||||||
@@ -141,21 +159,22 @@ async def _gather_internal(user_id: int) -> dict:
|
|||||||
"status": t.status,
|
"status": t.status,
|
||||||
"due_date": t.due_date.isoformat() if t.due_date else None,
|
"due_date": t.due_date.isoformat() if t.due_date else None,
|
||||||
"priority": t.priority,
|
"priority": t.priority,
|
||||||
|
"project_title": project_titles.get(t.project_id) if t.project_id else None,
|
||||||
}
|
}
|
||||||
for t in all_task_objs
|
for t in all_task_objs
|
||||||
]
|
]
|
||||||
overdue = [
|
overdue = [
|
||||||
format_task(t) for t in all_tasks
|
format_task(t, today) for t in all_tasks
|
||||||
if t.get("due_date") and t["due_date"] < today and t.get("status") != "done"
|
if t.get("due_date") and t["due_date"] < today and t.get("status") not in ("done", "cancelled")
|
||||||
]
|
]
|
||||||
due_today = [
|
due_today = [
|
||||||
format_task(t) for t in all_tasks
|
format_task(t, today) for t in all_tasks
|
||||||
if t.get("due_date") == today and t.get("status") != "done"
|
if t.get("due_date") == today and t.get("status") not in ("done", "cancelled")
|
||||||
]
|
]
|
||||||
high_priority = [
|
high_priority = [
|
||||||
format_task(t) for t in all_tasks
|
format_task(t, today) for t in all_tasks
|
||||||
if t.get("priority") == "high" and t.get("status") not in ("done",) and
|
if t.get("priority") == "high" and t.get("status") not in ("done", "cancelled") and
|
||||||
format_task(t) not in overdue and format_task(t) not in due_today
|
format_task(t, today) not in overdue and format_task(t, today) not in due_today
|
||||||
][:5]
|
][:5]
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to gather tasks for briefing", exc_info=True)
|
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 today — internal store
|
||||||
calendar_events: list[str] = []
|
calendar_events: list[str] = []
|
||||||
|
internal_events: list[dict] = []
|
||||||
try:
|
try:
|
||||||
from fabledassistant.services.events import list_events as list_internal_events
|
from fabledassistant.services.events import list_events as list_internal_events
|
||||||
today_date = datetime.now(user_tz).date()
|
today_date = datetime.now(user_tz).date()
|
||||||
@@ -197,6 +217,87 @@ async def _gather_internal(user_id: int) -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True)
|
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: active projects
|
||||||
projects_summary = []
|
projects_summary = []
|
||||||
try:
|
try:
|
||||||
@@ -206,6 +307,51 @@ async def _gather_internal(user_id: int) -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to gather projects for briefing", exc_info=True)
|
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 {
|
return {
|
||||||
"date": today,
|
"date": today,
|
||||||
"overdue_tasks": overdue,
|
"overdue_tasks": overdue,
|
||||||
@@ -214,6 +360,80 @@ async def _gather_internal(user_id: int) -> dict:
|
|||||||
"calendar_events": calendar_events,
|
"calendar_events": calendar_events,
|
||||||
"active_projects": projects_summary,
|
"active_projects": projects_summary,
|
||||||
"all_tasks_raw": all_tasks,
|
"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 ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _unified_system_prompt(profile_body: str) -> str:
|
def _unified_system_prompt(profile_body: str, slot: str = "compilation") -> str:
|
||||||
return (
|
base = (
|
||||||
"You are a personal assistant delivering a daily briefing. "
|
"You are a personal assistant. "
|
||||||
"Speak naturally and conversationally — as if talking to the user, not writing a report. "
|
"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. "
|
"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 "
|
"Be warm, concise, and human. "
|
||||||
"events or tasks due today, and briefly reference one or two noteworthy news stories. "
|
"The user can reply to act on your suggestions via tool calls.\n\n"
|
||||||
"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 "")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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:
|
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}", ""]
|
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 (brief — card handles detail)
|
||||||
weather = external_data.get("weather") or []
|
weather = external_data.get("weather") or []
|
||||||
if weather:
|
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.extend(f" - {e}" for e in internal_data["calendar_events"])
|
||||||
lines.append("")
|
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
|
# Tasks due today
|
||||||
if internal_data.get("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.extend(f" - {t}" for t in internal_data["due_today"])
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Overdue tasks (brief mention only)
|
# Overdue tasks
|
||||||
if internal_data.get("overdue_tasks"):
|
if internal_data.get("overdue_tasks"):
|
||||||
overdue = internal_data["overdue_tasks"]
|
overdue = internal_data["overdue_tasks"]
|
||||||
lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}):")
|
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[:3])
|
lines.extend(f" - {t}" for t in overdue[:5])
|
||||||
if len(overdue) > 3:
|
if len(overdue) > 5:
|
||||||
lines.append(f" (and {len(overdue) - 3} more)")
|
lines.append(f" (and {len(overdue) - 5} more)")
|
||||||
lines.append("")
|
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 []
|
rss = external_data.get("rss_items") or []
|
||||||
|
topic_scores = external_data.get("topic_scores") or {}
|
||||||
if rss:
|
if rss:
|
||||||
lines.append("NEWS HIGHLIGHTS (weave 1-2 into your briefing naturally; the full list is shown separately):")
|
# Group articles by their primary topic
|
||||||
for item in rss[:3]:
|
clusters: dict[str, list[dict]] = {}
|
||||||
source = item.get("feed_title") or item.get("source") or "News"
|
uncategorized: list[dict] = []
|
||||||
title = item.get("title", "")
|
for item in rss:
|
||||||
excerpt = (item.get("content") or item.get("snippet") or "")[:500].strip()
|
topics = item.get("topics") or []
|
||||||
lines.append(f" [{source}] {title}")
|
if topics:
|
||||||
if excerpt:
|
primary = topics[0]
|
||||||
lines.append(f" {excerpt}")
|
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("")
|
lines.append("")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
@@ -389,6 +765,10 @@ async def run_compilation(
|
|||||||
get_cached_weather_rows(user_id),
|
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
|
# Task change detection
|
||||||
all_tasks = internal_data.get("all_tasks_raw", [])
|
all_tasks = internal_data.get("all_tasks_raw", [])
|
||||||
changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks)
|
changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks)
|
||||||
@@ -429,10 +809,11 @@ async def run_compilation(
|
|||||||
external_data_filtered = {
|
external_data_filtered = {
|
||||||
"rss_items": filtered_rss,
|
"rss_items": filtered_rss,
|
||||||
"weather": [],
|
"weather": [],
|
||||||
|
"topic_scores": topic_scores,
|
||||||
}
|
}
|
||||||
|
|
||||||
briefing_text = await _llm_synthesise(
|
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),
|
_unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit),
|
||||||
model,
|
model,
|
||||||
num_ctx=8192,
|
num_ctx=8192,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ functions wrapped with asyncio.run_coroutine_threadsafe().
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
@@ -36,6 +36,11 @@ SLOTS = [
|
|||||||
("afternoon", 16, 0),
|
("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 ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -102,6 +107,26 @@ def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=3600,
|
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)
|
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)
|
jid = _job_id(user_id, slot_name)
|
||||||
if _scheduler.get_job(jid):
|
if _scheduler.get_job(jid):
|
||||||
_scheduler.remove_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)
|
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 ─────────────────────────────────────────────────────────────
|
# ── 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:
|
async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
||||||
"""Execute one slot job for one user."""
|
"""Execute one slot job for one user."""
|
||||||
from fabledassistant.services.briefing_conversations import (
|
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)
|
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||||
|
|
||||||
if slot == "compilation":
|
if slot == "compilation":
|
||||||
|
# Auto-pause stale projects before compiling the briefing
|
||||||
|
await _auto_pause_stale_projects(user_id)
|
||||||
|
|
||||||
# Refresh external data first
|
# Refresh external data first
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -40,29 +40,35 @@ _EMAIL_LOGO_SVG = (
|
|||||||
def _email_html(title: str, body: str) -> str:
|
def _email_html(title: str, body: str) -> str:
|
||||||
"""Wrap email body content in the standard Fabled Assistant template."""
|
"""Wrap email body content in the standard Fabled Assistant template."""
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
<head>
|
||||||
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
<meta charset="utf-8">
|
||||||
<div style="padding:32px 16px;">
|
<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="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 -->
|
<!-- Header -->
|
||||||
<div style="background:#6366f1;padding:20px 24px;text-align:center;">
|
<div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
|
||||||
<div style="margin-bottom:6px;">
|
<div style="margin-bottom:8px;">
|
||||||
{_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>
|
{_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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div style="padding:28px 24px;">
|
<div style="padding:32px 28px;color:#1e1b4b;">
|
||||||
{body}
|
{body}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div style="border-top:1px solid #e5e7eb;padding:14px 24px;text-align:center;background:#f9fafb;">
|
<div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
|
||||||
<p style="margin:0;color:#9ca3af;font-size:12px;">This email was sent by your Fabled Assistant instance.</p>
|
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Assistant instance.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</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:
|
async def send_test_email(to: str) -> None:
|
||||||
"""Send a branded test email."""
|
"""Send a branded test email."""
|
||||||
body = """
|
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>
|
<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))
|
await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))
|
||||||
|
|||||||
@@ -122,34 +122,74 @@ async def _semantic_knowledge_search(
|
|||||||
limit: int,
|
limit: int,
|
||||||
offset: int,
|
offset: int,
|
||||||
) -> tuple[list[dict], 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:
|
try:
|
||||||
from fabledassistant.services.embeddings import semantic_search_notes
|
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)
|
is_task_filter = True if note_type == "task" else (False if note_type else None)
|
||||||
candidates = await semantic_search_notes(
|
candidates = await semantic_search_notes(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
query=q,
|
query=q,
|
||||||
limit=min(200, limit * 8),
|
limit=min(200, limit * 4),
|
||||||
threshold=0.3,
|
threshold=0.3,
|
||||||
is_task=is_task_filter,
|
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:
|
except Exception:
|
||||||
logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True)
|
logger.warning("Semantic search unavailable, using keyword results only", exc_info=True)
|
||||||
return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset)
|
|
||||||
|
|
||||||
results = []
|
# 3. Merge — keyword matches first, then semantic (deduplicated)
|
||||||
for _score, note in candidates:
|
seen_ids: set[int] = set()
|
||||||
if note_type == "task" and not note.is_task:
|
merged: list[Note] = []
|
||||||
continue
|
for note in keyword_notes:
|
||||||
elif note_type and note_type != "task" and note.entity_type != note_type:
|
if note.id not in seen_ids:
|
||||||
continue
|
seen_ids.add(note.id)
|
||||||
if tags and not all(t in (note.tags or []) for t in tags):
|
merged.append(note)
|
||||||
continue
|
for note in semantic_notes:
|
||||||
results.append(note)
|
if note.id not in seen_ids:
|
||||||
|
seen_ids.add(note.id)
|
||||||
|
merged.append(note)
|
||||||
|
|
||||||
total = len(results)
|
total = len(merged)
|
||||||
page_items = results[offset: offset + limit]
|
page_items = merged[offset: offset + limit]
|
||||||
return [_note_to_item(n) for n in page_items], total
|
return [_note_to_item(n) for n in page_items], total
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ def _normalize_tags(tags: list[str]) -> list[str]:
|
|||||||
return out
|
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:
|
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."""
|
"""Fire generate_project_summary() if the project summary is missing or >1h old."""
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -92,6 +108,7 @@ async def create_note(
|
|||||||
await session.refresh(note)
|
await session.refresh(note)
|
||||||
|
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
|
await _maybe_reactivate_project(project_id)
|
||||||
await _maybe_trigger_project_summary(user_id, project_id)
|
await _maybe_trigger_project_summary(user_id, project_id)
|
||||||
|
|
||||||
return note
|
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)
|
await create_version(user_id, note_id, old_body, old_title, old_tags)
|
||||||
|
|
||||||
if note.project_id is not None:
|
if note.project_id is not None:
|
||||||
|
await _maybe_reactivate_project(note.project_id)
|
||||||
await _maybe_trigger_project_summary(user_id, note.project_id)
|
await _maybe_trigger_project_summary(user_id, note.project_id)
|
||||||
|
|
||||||
return note
|
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.
|
We received a request to reset your password. Click the button below to choose a new password.
|
||||||
</p>
|
</p>
|
||||||
<div style="text-align:center;margin:24px 0;">
|
<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
|
Reset Password
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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.
|
Click the button below to create your account.
|
||||||
</p>
|
</p>
|
||||||
<div style="text-align:center;margin:24px 0;">
|
<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
|
Accept Invitation
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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)
|
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:
|
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
|
||||||
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
|
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
|
||||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
|||||||
Reference in New Issue
Block a user