Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c3f0ab7f7 | |||
| 33f52081e5 | |||
| a58dbe79f2 | |||
| 0d41b8be34 | |||
| bf3bf99410 | |||
| 9cb3700a5c | |||
| b44d8496bc | |||
| 3eb61950c9 | |||
| beb57876fb | |||
| 5ea3bb5aff | |||
| 6e57ce4555 |
@@ -328,6 +328,8 @@ export interface BriefingConfig {
|
|||||||
work_days: number[];
|
work_days: number[];
|
||||||
slots: BriefingSlots;
|
slots: BriefingSlots;
|
||||||
notifications: boolean;
|
notifications: boolean;
|
||||||
|
temp_unit: 'C' | 'F';
|
||||||
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BriefingFeed {
|
export interface BriefingFeed {
|
||||||
@@ -360,6 +362,8 @@ const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
|||||||
work_days: [1, 2, 3, 4, 5],
|
work_days: [1, 2, 3, 4, 5],
|
||||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||||
notifications: true,
|
notifications: true,
|
||||||
|
temp_unit: 'C',
|
||||||
|
timezone: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const config = reactive<BriefingConfig>({
|
|||||||
work_days: [1, 2, 3, 4, 5],
|
work_days: [1, 2, 3, 4, 5],
|
||||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||||
notifications: true,
|
notifications: true,
|
||||||
|
temp_unit: 'C',
|
||||||
|
timezone: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 2 — locations
|
// Step 2 — locations
|
||||||
|
|||||||
+28
-17
@@ -109,6 +109,28 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drain the next queued message for a conversation, if conditions are met.
|
||||||
|
// Called both at stream-end and after fetchConversation, so orphaned queue
|
||||||
|
// messages (e.g. from a navigation away mid-stream) are picked up on return.
|
||||||
|
function _tryDrainQueue(convId: number) {
|
||||||
|
const queue = convQueues.value[convId];
|
||||||
|
if (!queue?.length) return;
|
||||||
|
if (isStreamingConv(convId)) return; // stream-end will drain naturally
|
||||||
|
if (currentConversation.value?.id !== convId) return; // not our conversation
|
||||||
|
const next = queue.shift()!;
|
||||||
|
_saveQueue(convId);
|
||||||
|
setTimeout(() => sendMessage(
|
||||||
|
next.content,
|
||||||
|
next.contextNoteId,
|
||||||
|
next.includeNoteIds,
|
||||||
|
next.think,
|
||||||
|
next.contextNoteTitle,
|
||||||
|
next.excludeNoteIds,
|
||||||
|
next.ragProjectId,
|
||||||
|
next.workspaceProjectId,
|
||||||
|
), 0);
|
||||||
|
}
|
||||||
|
|
||||||
function clearQueue() {
|
function clearQueue() {
|
||||||
const id = currentConversation.value?.id;
|
const id = currentConversation.value?.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -158,6 +180,9 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
`/api/chat/conversations/${id}`
|
`/api/chat/conversations/${id}`
|
||||||
);
|
);
|
||||||
_loadQueue(id);
|
_loadQueue(id);
|
||||||
|
// Drain any messages that were queued but never sent because the user
|
||||||
|
// navigated away before the previous stream finished.
|
||||||
|
_tryDrainQueue(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useToastStore().show("Failed to load conversation", "error");
|
useToastStore().show("Failed to load conversation", "error");
|
||||||
throw e;
|
throw e;
|
||||||
@@ -431,23 +456,9 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
s.pendingTool = null;
|
s.pendingTool = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process next queued message, if any.
|
// Process next queued message if this is still the active conversation.
|
||||||
// Use setTimeout so this frame resolves before the next send begins.
|
// If the user has navigated away, _tryDrainQueue will fire on fetchConversation.
|
||||||
const queue = convQueues.value[convId];
|
_tryDrainQueue(convId);
|
||||||
if (queue?.length && currentConversation.value?.id === convId) {
|
|
||||||
const next = queue.shift()!;
|
|
||||||
_saveQueue(convId);
|
|
||||||
setTimeout(() => sendMessage(
|
|
||||||
next.content,
|
|
||||||
next.contextNoteId,
|
|
||||||
next.includeNoteIds,
|
|
||||||
next.think,
|
|
||||||
next.contextNoteTitle,
|
|
||||||
next.excludeNoteIds,
|
|
||||||
next.ragProjectId,
|
|
||||||
next.workspaceProjectId,
|
|
||||||
), 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reconnectIfGenerating(convId: number): Promise<void> {
|
async function reconnectIfGenerating(convId: number): Promise<void> {
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ const briefingConfig = ref<BriefingConfig>({
|
|||||||
work_days: [1, 2, 3, 4, 5],
|
work_days: [1, 2, 3, 4, 5],
|
||||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||||
notifications: true,
|
notifications: true,
|
||||||
|
temp_unit: 'C',
|
||||||
|
timezone: '',
|
||||||
});
|
});
|
||||||
const briefingFeeds = ref<BriefingFeed[]>([]);
|
const briefingFeeds = ref<BriefingFeed[]>([]);
|
||||||
const briefingSaving = ref(false);
|
const briefingSaving = ref(false);
|
||||||
@@ -138,6 +140,10 @@ const addingFeed = ref(false);
|
|||||||
async function loadBriefingTab() {
|
async function loadBriefingTab() {
|
||||||
briefingConfig.value = await getBriefingConfig();
|
briefingConfig.value = await getBriefingConfig();
|
||||||
briefingFeeds.value = await getBriefingFeeds();
|
briefingFeeds.value = await getBriefingFeeds();
|
||||||
|
// Auto-populate timezone from browser if not already stored.
|
||||||
|
if (!briefingConfig.value.timezone) {
|
||||||
|
briefingConfig.value.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function geocodeLocation(key: 'home' | 'work') {
|
async function geocodeLocation(key: 'home' | 'work') {
|
||||||
@@ -173,6 +179,7 @@ function toggleWorkDay(day: number) {
|
|||||||
days.sort();
|
days.sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function saveBriefingSettings() {
|
async function saveBriefingSettings() {
|
||||||
briefingSaving.value = true;
|
briefingSaving.value = true;
|
||||||
briefingSaved.value = false;
|
briefingSaved.value = false;
|
||||||
@@ -329,6 +336,7 @@ onMounted(async () => {
|
|||||||
// base URL not configured yet
|
// base URL not configured yet
|
||||||
}
|
}
|
||||||
if (activeTab.value === "groups") loadGroupsPanel();
|
if (activeTab.value === "groups") loadGroupsPanel();
|
||||||
|
if (activeTab.value === "briefing") loadBriefingTab();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1316,6 +1324,22 @@ function formatUserDate(iso: string): string {
|
|||||||
</label>
|
</label>
|
||||||
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
|
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="margin-top: 1rem">
|
||||||
|
<label class="field-label">Temperature unit</label>
|
||||||
|
<div class="briefing-unit-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'C' }]"
|
||||||
|
@click="briefingConfig.temp_unit = 'C'"
|
||||||
|
>°C</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'F' }]"
|
||||||
|
@click="briefingConfig.temp_unit = 'F'"
|
||||||
|
>°F</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Work schedule -->
|
<!-- Work schedule -->
|
||||||
@@ -1333,13 +1357,42 @@ function formatUserDate(iso: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Timezone -->
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Timezone</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
Briefing slots fire at the times below in this timezone.
|
||||||
|
Auto-detected from your browser — override if the server should use a different zone.
|
||||||
|
</p>
|
||||||
|
<div class="briefing-timezone-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
v-model="briefingConfig.timezone"
|
||||||
|
placeholder="e.g. America/New_York"
|
||||||
|
style="flex: 1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
@click="briefingConfig.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone"
|
||||||
|
>Detect</button>
|
||||||
|
</div>
|
||||||
|
<p class="field-hint">
|
||||||
|
Use an
|
||||||
|
<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" target="_blank" rel="noopener">IANA timezone name</a>
|
||||||
|
(e.g. <code>Europe/London</code>, <code>America/Chicago</code>).
|
||||||
|
Your browser reports: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Slots -->
|
<!-- Slots -->
|
||||||
<section class="settings-section full-width">
|
<section class="settings-section full-width">
|
||||||
<h2>Scheduled Slots</h2>
|
<h2>Scheduled Slots</h2>
|
||||||
<p class="section-desc">Each active slot will post an update into your briefing conversation.</p>
|
<p class="section-desc">Each active slot posts an update into your briefing conversation at the listed local time.</p>
|
||||||
<div class="briefing-slot-list">
|
<div class="briefing-slot-list">
|
||||||
<div
|
<div
|
||||||
v-for="[key, label, time] in ([
|
v-for="[key, label, localTime] in ([
|
||||||
['compilation', 'Morning briefing', '4:00 am'],
|
['compilation', 'Morning briefing', '4:00 am'],
|
||||||
['morning', 'Office check-in', '8:00 am'],
|
['morning', 'Office check-in', '8:00 am'],
|
||||||
['midday', 'Midday update', '12:00 pm'],
|
['midday', 'Midday update', '12:00 pm'],
|
||||||
@@ -1350,13 +1403,16 @@ function formatUserDate(iso: string): string {
|
|||||||
>
|
>
|
||||||
<div class="briefing-slot-info">
|
<div class="briefing-slot-info">
|
||||||
<span class="briefing-slot-label">{{ label }}</span>
|
<span class="briefing-slot-label">{{ label }}</span>
|
||||||
<span class="briefing-slot-time">{{ time }}</span>
|
<span class="briefing-slot-time">{{ localTime }}</span>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" v-model="briefingConfig.slots[key]" />
|
<input type="checkbox" v-model="briefingConfig.slots[key]" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="briefingConfig.timezone" class="field-hint" style="margin-top: 0.5rem">
|
||||||
|
Firing in timezone: <strong>{{ briefingConfig.timezone }}</strong>
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- RSS Feeds -->
|
<!-- RSS Feeds -->
|
||||||
@@ -2697,6 +2753,38 @@ function formatUserDate(iso: string): string {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.briefing-timezone-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.briefing-unit-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.briefing-unit-btn {
|
||||||
|
padding: 0.35rem 1rem;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.briefing-unit-btn:first-child {
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.briefing-unit-btn.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.briefing-day-btn {
|
.briefing-day-btn {
|
||||||
padding: 0.35rem 0.65rem;
|
padding: 0.35rem 0.65rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ watch(
|
|||||||
activeNoteId.value = tc.result.data.id as number;
|
activeNoteId.value = tc.result.data.id as number;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
["create_task", "update_task"].includes(tc.function) &&
|
["create_task", "update_task", "create_milestone", "update_milestone"].includes(tc.function) &&
|
||||||
tc.status === "success"
|
tc.status === "success"
|
||||||
) {
|
) {
|
||||||
taskPanelRef.value?.reload();
|
taskPanelRef.value?.reload();
|
||||||
@@ -115,6 +115,7 @@ function scrollToBottom() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(() => chatStore.streamingContent, scrollToBottom);
|
watch(() => chatStore.streamingContent, scrollToBottom);
|
||||||
|
watch(() => chatStore.currentConversation?.messages.length, scrollToBottom);
|
||||||
|
|
||||||
function togglePanel(panel: keyof typeof panelOpen.value) {
|
function togglePanel(panel: keyof typeof panelOpen.value) {
|
||||||
const open = panelOpen.value;
|
const open = panelOpen.value;
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ async def get_config():
|
|||||||
async def put_config():
|
async def put_config():
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
|
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
|
||||||
|
# Live-patch the scheduler so the new timezone takes effect immediately.
|
||||||
|
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
||||||
|
update_user_schedule(g.user.id, data)
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.models.note import TaskPriority, TaskStatus
|
from fabledassistant.models.note import TaskPriority, TaskStatus
|
||||||
from fabledassistant.routes.utils import not_found, parse_iso_date
|
from fabledassistant.routes.utils import not_found, parse_iso_date
|
||||||
|
from fabledassistant.services.embeddings import upsert_note_embedding
|
||||||
from fabledassistant.services.notes import (
|
from fabledassistant.services.notes import (
|
||||||
create_note,
|
create_note,
|
||||||
delete_note,
|
delete_note,
|
||||||
@@ -93,6 +96,9 @@ async def create_task_route():
|
|||||||
milestone_id=data.get("milestone_id"),
|
milestone_id=data.get("milestone_id"),
|
||||||
parent_id=data.get("parent_id"),
|
parent_id=data.get("parent_id"),
|
||||||
)
|
)
|
||||||
|
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||||
|
if text:
|
||||||
|
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
|
||||||
return jsonify(task.to_dict()), 201
|
return jsonify(task.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
@@ -147,6 +153,9 @@ async def update_task_route(task_id: int):
|
|||||||
task = await update_note(uid, task_id, **fields)
|
task = await update_note(uid, task_id, **fields)
|
||||||
if task is None:
|
if task is None:
|
||||||
return not_found("Task")
|
return not_found("Task")
|
||||||
|
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||||
|
if text:
|
||||||
|
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
|
||||||
return jsonify(task.to_dict())
|
return jsonify(task.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ async def _gather_internal(user_id: int) -> dict:
|
|||||||
# Calendar events today
|
# Calendar events today
|
||||||
calendar_events = []
|
calendar_events = []
|
||||||
try:
|
try:
|
||||||
if is_caldav_configured(user_id):
|
if await is_caldav_configured(user_id):
|
||||||
events = await list_events(user_id, start=today, end=today)
|
events = await list_events(user_id, start=today, end=today)
|
||||||
calendar_events = [
|
calendar_events = [
|
||||||
f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
|
f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
|
||||||
@@ -192,16 +192,26 @@ def _internal_user_prompt(data: dict, slot: str) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _external_user_prompt(data: dict, slot: str) -> str:
|
def _format_temp(value: float, unit: str) -> str:
|
||||||
|
"""Convert Celsius to the requested unit and format as an integer string."""
|
||||||
|
if unit == "F":
|
||||||
|
return f"{value * 9 / 5 + 32:.0f}"
|
||||||
|
return f"{value:.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _external_user_prompt(data: dict, slot: str, temp_unit: str = "C") -> str:
|
||||||
|
unit_sym = f"°{temp_unit}"
|
||||||
lines = [f"Briefing slot: {slot}", ""]
|
lines = [f"Briefing slot: {slot}", ""]
|
||||||
if data["weather"]:
|
if data["weather"]:
|
||||||
lines.append("WEATHER:")
|
lines.append("WEATHER:")
|
||||||
for loc in data["weather"]:
|
for loc in data["weather"]:
|
||||||
lines.append(f" {loc['location_label']}:")
|
lines.append(f" {loc['location_label']}:")
|
||||||
for day in loc["days"][:3]:
|
for day in loc["days"][:3]:
|
||||||
|
t_min = _format_temp(day["temp_min"], temp_unit)
|
||||||
|
t_max = _format_temp(day["temp_max"], temp_unit)
|
||||||
lines.append(
|
lines.append(
|
||||||
f" {day['date']}: {day['description']}, "
|
f" {day['date']}: {day['description']}, "
|
||||||
f"{day['temp_min']}–{day['temp_max']}°C, {day['precip_mm']}mm rain"
|
f"{t_min}–{t_max}{unit_sym}, {day['precip_mm']}mm rain"
|
||||||
)
|
)
|
||||||
if loc["changes_since_last_fetch"]:
|
if loc["changes_since_last_fetch"]:
|
||||||
lines.append(" FORECAST CHANGES:")
|
lines.append(" FORECAST CHANGES:")
|
||||||
@@ -218,6 +228,18 @@ def _external_user_prompt(data: dict, slot: str) -> str:
|
|||||||
|
|
||||||
# ── Main entry point ───────────────────────────────────────────────────────────
|
# ── Main entry point ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _get_temp_unit(user_id: int) -> str:
|
||||||
|
"""Read the user's preferred temperature unit from briefing_config ('C' or 'F')."""
|
||||||
|
import json
|
||||||
|
raw = await get_setting(user_id, "briefing_config", "{}")
|
||||||
|
try:
|
||||||
|
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||||
|
unit = config.get("temp_unit", "C")
|
||||||
|
return unit if unit in ("C", "F") else "C"
|
||||||
|
except Exception:
|
||||||
|
return "C"
|
||||||
|
|
||||||
|
|
||||||
async def run_compilation(user_id: int, slot: str, model: str | None = None) -> str:
|
async def run_compilation(user_id: int, slot: str, model: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Run the full two-lane briefing pipeline for a user and slot.
|
Run the full two-lane briefing pipeline for a user and slot.
|
||||||
@@ -227,7 +249,10 @@ async def run_compilation(user_id: int, slot: str, model: str | None = None) ->
|
|||||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||||
|
|
||||||
from fabledassistant.services.briefing_profile import get_profile_body
|
from fabledassistant.services.briefing_profile import get_profile_body
|
||||||
profile_body = await get_profile_body(user_id)
|
profile_body, temp_unit = await asyncio.gather(
|
||||||
|
get_profile_body(user_id),
|
||||||
|
_get_temp_unit(user_id),
|
||||||
|
)
|
||||||
|
|
||||||
# Parallel gather
|
# Parallel gather
|
||||||
internal_data, external_data = await asyncio.gather(
|
internal_data, external_data = await asyncio.gather(
|
||||||
@@ -244,7 +269,7 @@ async def run_compilation(user_id: int, slot: str, model: str | None = None) ->
|
|||||||
),
|
),
|
||||||
_llm_synthesise(
|
_llm_synthesise(
|
||||||
_external_system_prompt(),
|
_external_system_prompt(),
|
||||||
_external_user_prompt(external_data, slot),
|
_external_user_prompt(external_data, slot, temp_unit),
|
||||||
model,
|
model,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -268,9 +293,10 @@ async def run_slot_injection(user_id: int, slot: str, model: str | None = None)
|
|||||||
if model is None:
|
if model is None:
|
||||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||||
|
|
||||||
internal_data, external_data = await asyncio.gather(
|
internal_data, external_data, temp_unit = await asyncio.gather(
|
||||||
_gather_internal(user_id),
|
_gather_internal(user_id),
|
||||||
_gather_external(user_id),
|
_gather_external(user_id),
|
||||||
|
_get_temp_unit(user_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
system = (
|
system = (
|
||||||
@@ -281,6 +307,6 @@ async def run_slot_injection(user_id: int, slot: str, model: str | None = None)
|
|||||||
f"Slot: {slot}\n\n"
|
f"Slot: {slot}\n\n"
|
||||||
+ _internal_user_prompt(internal_data, slot)
|
+ _internal_user_prompt(internal_data, slot)
|
||||||
+ "\n\n"
|
+ "\n\n"
|
||||||
+ _external_user_prompt(external_data, slot)
|
+ _external_user_prompt(external_data, slot, temp_unit)
|
||||||
)
|
)
|
||||||
return await _llm_synthesise(system, user_prompt, model)
|
return await _llm_synthesise(system, user_prompt, model)
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
APScheduler-based briefing scheduler.
|
APScheduler-based briefing scheduler — per-user, timezone-aware.
|
||||||
|
|
||||||
|
Each enabled user gets 4 individual CronTrigger jobs keyed to their IANA
|
||||||
|
timezone (stored in briefing_config.timezone). Changing the config via the
|
||||||
|
settings UI calls update_user_schedule() which live-patches the scheduler
|
||||||
|
without a restart.
|
||||||
|
|
||||||
Uses a background thread scheduler (not async) because APScheduler 3.x's
|
Uses a background thread scheduler (not async) because APScheduler 3.x's
|
||||||
AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async functions
|
AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async
|
||||||
wrapped with asyncio.run_coroutine_threadsafe().
|
functions wrapped with asyncio.run_coroutine_threadsafe().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, time, timedelta, timezone
|
from datetime import date, datetime, time, timedelta
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
@@ -20,8 +26,9 @@ from fabledassistant.models.setting import Setting
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_scheduler: BackgroundScheduler | None = None
|
_scheduler: BackgroundScheduler | None = None
|
||||||
|
_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
# Slot definitions: (name, hour, minute)
|
# Slot definitions: (name, hour, minute) — local time in the user's timezone
|
||||||
SLOTS = [
|
SLOTS = [
|
||||||
("compilation", 4, 0),
|
("compilation", 4, 0),
|
||||||
("morning", 8, 0),
|
("morning", 8, 0),
|
||||||
@@ -30,8 +37,22 @@ SLOTS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _resolve_timezone(tz_str: str) -> str:
|
||||||
|
"""Validate and return an IANA timezone string, falling back to UTC."""
|
||||||
|
if not tz_str:
|
||||||
|
return "UTC"
|
||||||
|
try:
|
||||||
|
ZoneInfo(tz_str)
|
||||||
|
return tz_str
|
||||||
|
except (ZoneInfoNotFoundError, KeyError):
|
||||||
|
logger.warning("Invalid timezone %r in briefing config, falling back to UTC", tz_str)
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
|
||||||
async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
|
async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
|
||||||
"""Return [(user_id, model)] for users with briefing enabled."""
|
"""Return [(user_id, iana_timezone)] for all users with briefing enabled."""
|
||||||
import json
|
import json
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -44,12 +65,60 @@ async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
|
|||||||
try:
|
try:
|
||||||
config = json.loads(row.value) if row.value else {}
|
config = json.loads(row.value) if row.value else {}
|
||||||
if config.get("enabled"):
|
if config.get("enabled"):
|
||||||
enabled.append((row.user_id, "")) # model resolved per-user at runtime
|
tz = _resolve_timezone(config.get("timezone", "UTC"))
|
||||||
|
enabled.append((row.user_id, tz))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return enabled
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
|
def _job_id(user_id: int, slot: str) -> str:
|
||||||
|
return f"briefing_{slot}_user_{user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _add_user_jobs(user_id: int, tz: str) -> None:
|
||||||
|
"""Add (or replace) all 4 slot jobs for a user in their timezone."""
|
||||||
|
if _scheduler is None or _loop is None:
|
||||||
|
return
|
||||||
|
for slot_name, hour, minute in SLOTS:
|
||||||
|
_scheduler.add_job(
|
||||||
|
_run_user_slot_sync,
|
||||||
|
CronTrigger(hour=hour, minute=minute, timezone=tz),
|
||||||
|
args=[user_id, slot_name],
|
||||||
|
id=_job_id(user_id, slot_name),
|
||||||
|
replace_existing=True,
|
||||||
|
misfire_grace_time=3600,
|
||||||
|
)
|
||||||
|
logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_user_jobs(user_id: int) -> None:
|
||||||
|
"""Remove all slot jobs for a user."""
|
||||||
|
if _scheduler is None:
|
||||||
|
return
|
||||||
|
for slot_name, _, _ in SLOTS:
|
||||||
|
jid = _job_id(user_id, slot_name)
|
||||||
|
if _scheduler.get_job(jid):
|
||||||
|
_scheduler.remove_job(jid)
|
||||||
|
logger.info("Removed briefing jobs for user %d", user_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def update_user_schedule(user_id: int, config: dict) -> None:
|
||||||
|
"""
|
||||||
|
Called when a user saves their briefing config via the settings UI.
|
||||||
|
Live-patches the scheduler — no restart required.
|
||||||
|
"""
|
||||||
|
if config.get("enabled"):
|
||||||
|
tz = _resolve_timezone(config.get("timezone", "UTC"))
|
||||||
|
_add_user_jobs(user_id, tz)
|
||||||
|
else:
|
||||||
|
_remove_user_jobs(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Job execution ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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 (
|
||||||
@@ -64,12 +133,11 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
|||||||
if slot == "compilation":
|
if slot == "compilation":
|
||||||
# Refresh external data first
|
# Refresh external data first
|
||||||
try:
|
try:
|
||||||
from fabledassistant.services.rss import refresh_all_feeds
|
|
||||||
import json
|
import json
|
||||||
|
from fabledassistant.services.rss import refresh_all_feeds
|
||||||
config_raw = await get_setting(user_id, "briefing_config", "{}")
|
config_raw = await get_setting(user_id, "briefing_config", "{}")
|
||||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||||
await refresh_all_feeds(user_id)
|
await refresh_all_feeds(user_id)
|
||||||
# Refresh weather for configured locations
|
|
||||||
from fabledassistant.services import weather as wx
|
from fabledassistant.services import weather as wx
|
||||||
for key, loc in config.get("locations", {}).items():
|
for key, loc in config.get("locations", {}).items():
|
||||||
if loc.get("lat") and loc.get("lon"):
|
if loc.get("lat") and loc.get("lon"):
|
||||||
@@ -83,25 +151,19 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Pre-compilation refresh failed for user %d", user_id, exc_info=True)
|
logger.warning("Pre-compilation refresh failed for user %d", user_id, exc_info=True)
|
||||||
|
|
||||||
# Run previous day's profile close-out
|
|
||||||
await _run_profile_closeout(user_id, model)
|
await _run_profile_closeout(user_id, model)
|
||||||
|
|
||||||
# Create today's conversation and post opening message
|
|
||||||
conv = await get_or_create_today_conversation(user_id, model)
|
conv = await get_or_create_today_conversation(user_id, model)
|
||||||
text = await run_compilation(user_id, slot, model)
|
text = await run_compilation(user_id, slot, model)
|
||||||
if text:
|
if text:
|
||||||
await post_message(conv.id, "assistant", text)
|
await post_message(conv.id, "assistant", text)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Inject slot update into today's conversation
|
|
||||||
conv = await get_or_create_today_conversation(user_id, model)
|
conv = await get_or_create_today_conversation(user_id, model)
|
||||||
text = await run_slot_injection(user_id, slot, model)
|
text = await run_slot_injection(user_id, slot, model)
|
||||||
if text:
|
if text:
|
||||||
# Post as a system-injected user prompt + assistant response pair
|
|
||||||
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
|
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
|
||||||
await post_message(conv.id, "assistant", text)
|
await post_message(conv.id, "assistant", text)
|
||||||
|
|
||||||
# Send push notification
|
|
||||||
try:
|
try:
|
||||||
from fabledassistant.services.push import send_push_notification
|
from fabledassistant.services.push import send_push_notification
|
||||||
slot_labels = {
|
slot_labels = {
|
||||||
@@ -122,10 +184,22 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
|||||||
logger.info("Briefing slot '%s' completed for user %d", slot, user_id)
|
logger.info("Briefing slot '%s' completed for user %d", slot, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_user_slot_sync(user_id: int, slot: str) -> None:
|
||||||
|
"""Synchronous wrapper called by APScheduler's background thread."""
|
||||||
|
if _loop is None:
|
||||||
|
logger.error("No event loop available for briefing slot %s user %d", slot, user_id)
|
||||||
|
return
|
||||||
|
future = asyncio.run_coroutine_threadsafe(_run_slot_for_user(user_id, slot), _loop)
|
||||||
|
try:
|
||||||
|
future.result(timeout=600)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Briefing slot '%s' failed for user %d", slot, user_id)
|
||||||
|
|
||||||
|
|
||||||
async def _run_profile_closeout(user_id: int, model: str) -> None:
|
async def _run_profile_closeout(user_id: int, model: str) -> None:
|
||||||
"""
|
"""
|
||||||
Read yesterday's briefing conversation, ask the LLM to extract preference
|
Read yesterday's briefing conversation, extract preference observations,
|
||||||
observations, and append them to the briefing profile note.
|
and append them to the briefing profile note.
|
||||||
"""
|
"""
|
||||||
from fabledassistant.services.briefing_profile import append_observations
|
from fabledassistant.services.briefing_profile import append_observations
|
||||||
from fabledassistant.services.briefing_pipeline import _llm_synthesise
|
from fabledassistant.services.briefing_pipeline import _llm_synthesise
|
||||||
@@ -149,7 +223,7 @@ async def _run_profile_closeout(user_id: int, model: str) -> None:
|
|||||||
messages = list(msgs_result.scalars().all())
|
messages = list(msgs_result.scalars().all())
|
||||||
|
|
||||||
if len(messages) < 2:
|
if len(messages) < 2:
|
||||||
return # Nothing interesting to learn from
|
return
|
||||||
|
|
||||||
transcript = "\n".join(
|
transcript = "\n".join(
|
||||||
f"{m.role.upper()}: {m.content[:500]}" for m in messages[-20:]
|
f"{m.role.upper()}: {m.content[:500]}" for m in messages[-20:]
|
||||||
@@ -166,100 +240,97 @@ async def _run_profile_closeout(user_id: int, model: str) -> None:
|
|||||||
await append_observations(user_id, observations)
|
await append_observations(user_id, observations)
|
||||||
|
|
||||||
|
|
||||||
def _run_slot_sync(slot: str, loop: asyncio.AbstractEventLoop) -> None:
|
# ── Startup / catchup ─────────────────────────────────────────────────────────
|
||||||
"""Synchronous wrapper called by APScheduler's background thread."""
|
|
||||||
async def _job():
|
|
||||||
users = await _get_briefing_enabled_users()
|
|
||||||
for user_id, _ in users:
|
|
||||||
try:
|
|
||||||
await _run_slot_for_user(user_id, slot)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Briefing slot '%s' failed for user %d", slot, user_id)
|
|
||||||
|
|
||||||
future = asyncio.run_coroutine_threadsafe(_job(), loop)
|
|
||||||
try:
|
|
||||||
future.result(timeout=600) # 10 min max per slot run
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Briefing slot '%s' job failed", slot)
|
|
||||||
|
|
||||||
|
|
||||||
async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
|
async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
|
||||||
"""
|
"""
|
||||||
On startup, check if any slot was missed within the last 24 hours.
|
On startup, fire any slot that was missed in the last 24 hours
|
||||||
Fire it once if so. Never backfill more than one slot per slot-name.
|
(one catch-up per slot per user, evaluated in the user's local timezone).
|
||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
users = await _get_briefing_enabled_users()
|
||||||
today = now.date()
|
for user_id, tz in users:
|
||||||
|
user_tz = ZoneInfo(tz)
|
||||||
|
now_local = datetime.now(user_tz)
|
||||||
|
today_local = now_local.date()
|
||||||
|
|
||||||
for slot_name, hour, minute in SLOTS:
|
for slot_name, hour, minute in SLOTS:
|
||||||
slot_time = datetime.combine(today, time(hour, minute), tzinfo=timezone.utc)
|
slot_local = datetime.combine(today_local, time(hour, minute), tzinfo=user_tz)
|
||||||
if slot_time > now:
|
if slot_local > now_local:
|
||||||
continue # Hasn't happened yet today
|
continue # Not yet due
|
||||||
age = (now - slot_time).total_seconds()
|
age = (now_local - slot_local).total_seconds()
|
||||||
if age > 86400:
|
if age > 86400:
|
||||||
continue # More than 24h ago — skip
|
continue # More than 24h ago — skip
|
||||||
# Check if we already have a message for this slot today
|
|
||||||
# Simple heuristic: if today's briefing conversation has messages posted
|
# Check if today's conversation already has a message from after slot time
|
||||||
# after the slot time, consider it covered
|
|
||||||
users = await _get_briefing_enabled_users()
|
|
||||||
for user_id, _ in users:
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
from fabledassistant.models.conversation import Conversation, Message
|
from fabledassistant.models.conversation import Conversation, Message
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Conversation).where(
|
select(Conversation).where(
|
||||||
Conversation.user_id == user_id,
|
Conversation.user_id == user_id,
|
||||||
Conversation.conversation_type == "briefing",
|
Conversation.conversation_type == "briefing",
|
||||||
Conversation.briefing_date == today,
|
Conversation.briefing_date == today_local,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
conv = result.scalars().first()
|
conv = result.scalars().first()
|
||||||
if conv:
|
if conv:
|
||||||
|
# Convert slot_local to UTC for DB comparison (stored as UTC)
|
||||||
|
slot_utc = slot_local.astimezone(ZoneInfo("UTC"))
|
||||||
msgs = await session.execute(
|
msgs = await session.execute(
|
||||||
select(Message).where(
|
select(Message).where(
|
||||||
Message.conversation_id == conv.id,
|
Message.conversation_id == conv.id,
|
||||||
Message.created_at >= slot_time,
|
Message.created_at >= slot_utc,
|
||||||
).limit(1)
|
).limit(1)
|
||||||
)
|
)
|
||||||
if msgs.scalars().first():
|
if msgs.scalars().first():
|
||||||
continue # Already covered
|
continue # Already covered
|
||||||
# Fire the missed slot
|
|
||||||
logger.info("Catching up missed briefing slot '%s' for user %d", slot_name, user_id)
|
logger.info(
|
||||||
|
"Catching up missed briefing slot '%s' for user %d (tz: %s)",
|
||||||
|
slot_name, user_id, tz,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await _run_slot_for_user(user_id, slot_name)
|
await _run_slot_for_user(user_id, slot_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Catch-up for slot '%s' user %d failed", slot_name, user_id)
|
logger.exception(
|
||||||
|
"Catch-up for slot '%s' user %d failed", slot_name, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||||
"""
|
"""
|
||||||
Start the APScheduler background scheduler.
|
Start the APScheduler background scheduler with per-user timezone-aware jobs.
|
||||||
Must be called from the app's before_serving hook with the running event loop.
|
Must be called from the app's before_serving hook with the running event loop.
|
||||||
"""
|
"""
|
||||||
global _scheduler
|
global _scheduler, _loop
|
||||||
if _scheduler is not None:
|
if _scheduler is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_loop = loop
|
||||||
_scheduler = BackgroundScheduler(timezone="UTC")
|
_scheduler = BackgroundScheduler(timezone="UTC")
|
||||||
|
|
||||||
for slot_name, hour, minute in SLOTS:
|
# Schedule jobs synchronously: run the async query in the provided loop
|
||||||
_scheduler.add_job(
|
future = asyncio.run_coroutine_threadsafe(_get_briefing_enabled_users(), loop)
|
||||||
_run_slot_sync,
|
try:
|
||||||
CronTrigger(hour=hour, minute=minute),
|
users = future.result(timeout=10)
|
||||||
args=[slot_name, loop],
|
except Exception:
|
||||||
id=f"briefing_{slot_name}",
|
logger.exception("Failed to load briefing users at startup")
|
||||||
replace_existing=True,
|
users = []
|
||||||
misfire_grace_time=3600, # Fire up to 1h late rather than skip
|
|
||||||
)
|
for user_id, tz in users:
|
||||||
|
_add_user_jobs(user_id, tz)
|
||||||
|
|
||||||
_scheduler.start()
|
_scheduler.start()
|
||||||
logger.info("Briefing scheduler started")
|
logger.info(
|
||||||
|
"Briefing scheduler started with %d user(s) across %d job(s)",
|
||||||
|
len(users), len(users) * len(SLOTS),
|
||||||
|
)
|
||||||
|
|
||||||
# Catch up missed slots in the background
|
|
||||||
asyncio.run_coroutine_threadsafe(_catchup_missed_slots(loop), loop)
|
asyncio.run_coroutine_threadsafe(_catchup_missed_slots(loop), loop)
|
||||||
|
|
||||||
|
|
||||||
def stop_briefing_scheduler() -> None:
|
def stop_briefing_scheduler() -> None:
|
||||||
global _scheduler
|
global _scheduler, _loop
|
||||||
if _scheduler:
|
if _scheduler:
|
||||||
_scheduler.shutdown(wait=False)
|
_scheduler.shutdown(wait=False)
|
||||||
_scheduler = None
|
_scheduler = None
|
||||||
|
_loop = None
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ async def list_notes(
|
|||||||
if no_project:
|
if no_project:
|
||||||
query = query.where(Note.project_id.is_(None))
|
query = query.where(Note.project_id.is_(None))
|
||||||
count_query = count_query.where(Note.project_id.is_(None))
|
count_query = count_query.where(Note.project_id.is_(None))
|
||||||
count_query = count_query.where(Note.parent_id == parent_id)
|
|
||||||
|
|
||||||
sort_col = getattr(Note, sort, Note.updated_at)
|
sort_col = getattr(Note, sort, Note.updated_at)
|
||||||
if order == "asc":
|
if order == "asc":
|
||||||
|
|||||||
@@ -30,6 +30,35 @@ def _schedule_embedding(note_id: int, user_id: int, title: str, body: str) -> No
|
|||||||
asyncio.create_task(upsert_note_embedding(note_id, user_id, text))
|
asyncio.create_task(upsert_note_embedding(note_id, user_id, text))
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_project(user_id: int, project_name: str):
|
||||||
|
"""Exact-then-fuzzy project lookup. Returns the Project or None.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. Exact title match (case-insensitive via DB)
|
||||||
|
2. project_name is a substring of an existing title ("Famous Supply" in "Famous Supply Co.")
|
||||||
|
3. Existing title is a substring of project_name ("Famous Supply Co." in "Famous Supply Co. UK")
|
||||||
|
4. SequenceMatcher ratio >= 0.55 (catches abbreviations / partial names)
|
||||||
|
"""
|
||||||
|
from fabledassistant.services.projects import get_project_by_title, list_projects
|
||||||
|
proj = await get_project_by_title(user_id, project_name)
|
||||||
|
if proj is not None:
|
||||||
|
return proj
|
||||||
|
needle = project_name.lower().strip()
|
||||||
|
all_p = await list_projects(user_id)
|
||||||
|
# Substring checks first (deterministic)
|
||||||
|
for p in all_p:
|
||||||
|
haystack = p.title.lower().strip()
|
||||||
|
if needle in haystack or haystack in needle:
|
||||||
|
return p
|
||||||
|
# SequenceMatcher fallback for abbreviations / partial names
|
||||||
|
best, best_r = None, 0.0
|
||||||
|
for p in all_p:
|
||||||
|
r = SequenceMatcher(None, needle, p.title.lower().strip()).ratio()
|
||||||
|
if r >= 0.55 and r > best_r:
|
||||||
|
best, best_r = p, r
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
# Core tools — always available
|
# Core tools — always available
|
||||||
_CORE_TOOLS = [
|
_CORE_TOOLS = [
|
||||||
{
|
{
|
||||||
@@ -358,7 +387,7 @@ _CORE_TOOLS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "create_project",
|
"name": "create_project",
|
||||||
"description": "Create a new project. IMPORTANT: You MUST ask the user to confirm the project name and intent before calling this tool. Only call with confirmed=true after the user has explicitly approved creation.",
|
"description": "Create a brand-new project. IMPORTANT: NEVER call this because a project name was not found — always use list_projects first to confirm no similar project exists. Only call this when the user has explicitly asked to create a new project AND confirmed it. Call with confirmed=true only after the user has explicitly approved. The system will block creation if any project with a similar name already exists.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -436,6 +465,24 @@ _CORE_TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "update_milestone",
|
||||||
|
"description": "Update the title, description, or status of an existing milestone.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project": {"type": "string", "description": "Project title the milestone belongs to"},
|
||||||
|
"milestone": {"type": "string", "description": "Current milestone title to look up"},
|
||||||
|
"title": {"type": "string", "description": "New title (omit to keep current)"},
|
||||||
|
"description": {"type": "string", "description": "New description (omit to keep current)"},
|
||||||
|
"status": {"type": "string", "enum": ["active", "completed", "cancelled"], "description": "New status (omit to keep current)"},
|
||||||
|
},
|
||||||
|
"required": ["project", "milestone"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -842,14 +889,9 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
parent_task_name = arguments.get("parent_task")
|
parent_task_name = arguments.get("parent_task")
|
||||||
pre_fields: dict = {}
|
pre_fields: dict = {}
|
||||||
if project_name:
|
if project_name:
|
||||||
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
|
proj = await _resolve_project(user_id, project_name)
|
||||||
proj = await _gpbt(user_id, project_name)
|
|
||||||
if proj is None:
|
if proj is None:
|
||||||
all_p = await _lp(user_id)
|
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
|
||||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
|
||||||
proj = matches[0] if matches else None
|
|
||||||
if proj is None:
|
|
||||||
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
|
|
||||||
pre_fields["project_id"] = proj.id
|
pre_fields["project_id"] = proj.id
|
||||||
if milestone_name:
|
if milestone_name:
|
||||||
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt
|
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt
|
||||||
@@ -924,14 +966,9 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
project_name = arguments.get("project")
|
project_name = arguments.get("project")
|
||||||
project_id = None
|
project_id = None
|
||||||
if project_name:
|
if project_name:
|
||||||
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
|
proj = await _resolve_project(user_id, project_name)
|
||||||
proj = await _gpbt(user_id, project_name)
|
|
||||||
if proj is None:
|
if proj is None:
|
||||||
all_p = await _lp(user_id)
|
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
|
||||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
|
||||||
proj = matches[0] if matches else None
|
|
||||||
if proj is None:
|
|
||||||
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
|
|
||||||
project_id = proj.id
|
project_id = proj.id
|
||||||
|
|
||||||
existing_notes, _ = await list_notes(user_id=user_id, q=note_title, is_task=False, limit=1)
|
existing_notes, _ = await list_notes(user_id=user_id, q=note_title, is_task=False, limit=1)
|
||||||
@@ -1024,12 +1061,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
if "project" in arguments:
|
if "project" in arguments:
|
||||||
project_name = arguments["project"]
|
project_name = arguments["project"]
|
||||||
if project_name:
|
if project_name:
|
||||||
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
|
proj = await _resolve_project(user_id, project_name)
|
||||||
proj = await _gpbt(user_id, project_name)
|
|
||||||
if proj is None:
|
|
||||||
all_p = await _lp(user_id)
|
|
||||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
|
||||||
proj = matches[0] if matches else None
|
|
||||||
if proj is None:
|
if proj is None:
|
||||||
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects."}
|
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects."}
|
||||||
update_fields["project_id"] = proj.id
|
update_fields["project_id"] = proj.id
|
||||||
@@ -1064,13 +1096,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
project_name = arguments.get("project")
|
project_name = arguments.get("project")
|
||||||
milestone_name = arguments.get("milestone")
|
milestone_name = arguments.get("milestone")
|
||||||
if project_name:
|
if project_name:
|
||||||
from fabledassistant.services.projects import get_project_by_title, list_projects as _lp
|
proj = await _resolve_project(user_id, project_name)
|
||||||
proj = await get_project_by_title(user_id, project_name)
|
|
||||||
if proj is None:
|
|
||||||
all_p = await _lp(user_id)
|
|
||||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
|
||||||
if matches:
|
|
||||||
proj = matches[0]
|
|
||||||
if proj:
|
if proj:
|
||||||
project_id = proj.id
|
project_id = proj.id
|
||||||
if milestone_name:
|
if milestone_name:
|
||||||
@@ -1132,12 +1158,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
# Resolve project name → id
|
# Resolve project name → id
|
||||||
search_project_id: int | None = None
|
search_project_id: int | None = None
|
||||||
if project_name:
|
if project_name:
|
||||||
from fabledassistant.services.projects import get_project_by_title as _gpbt2, list_projects as _lp2
|
proj = await _resolve_project(user_id, project_name)
|
||||||
proj = await _gpbt2(user_id, project_name)
|
|
||||||
if proj is None:
|
|
||||||
all_p = await _lp2(user_id)
|
|
||||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
|
||||||
proj = matches[0] if matches else None
|
|
||||||
if proj:
|
if proj:
|
||||||
search_project_id = proj.id
|
search_project_id = proj.id
|
||||||
|
|
||||||
@@ -1316,12 +1337,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
project_id = None
|
project_id = None
|
||||||
project_name = arguments.get("project")
|
project_name = arguments.get("project")
|
||||||
if project_name:
|
if project_name:
|
||||||
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
|
proj = await _resolve_project(user_id, project_name)
|
||||||
proj = await _gpbt(user_id, project_name)
|
|
||||||
if proj is None:
|
|
||||||
all_p = await _lp(user_id)
|
|
||||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
|
||||||
proj = matches[0] if matches else None
|
|
||||||
if proj is None:
|
if proj is None:
|
||||||
return {"success": False, "error": f"Project '{project_name}' not found."}
|
return {"success": False, "error": f"Project '{project_name}' not found."}
|
||||||
project_id = proj.id
|
project_id = proj.id
|
||||||
@@ -1394,7 +1410,6 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
elif tool_name == "create_milestone":
|
elif tool_name == "create_milestone":
|
||||||
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
|
|
||||||
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
|
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
|
||||||
project_name = arguments.get("project", "")
|
project_name = arguments.get("project", "")
|
||||||
ms_title_pre = arguments.get("title", "Untitled Milestone")
|
ms_title_pre = arguments.get("title", "Untitled Milestone")
|
||||||
@@ -1402,13 +1417,9 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
return {"success": False, "error": "project is required"}
|
return {"success": False, "error": "project is required"}
|
||||||
if not arguments.get("confirmed"):
|
if not arguments.get("confirmed"):
|
||||||
return {"success": False, "requires_confirmation": True, "error": f"Milestone creation requires explicit user confirmation. Ask the user: 'Shall I create a milestone titled \"{ms_title_pre}\" in project \"{project_name}\"?' Then retry with confirmed=true if they agree."}
|
return {"success": False, "requires_confirmation": True, "error": f"Milestone creation requires explicit user confirmation. Ask the user: 'Shall I create a milestone titled \"{ms_title_pre}\" in project \"{project_name}\"?' Then retry with confirmed=true if they agree."}
|
||||||
proj = await _gpbt(user_id, project_name)
|
proj = await _resolve_project(user_id, project_name)
|
||||||
if proj is None:
|
if proj is None:
|
||||||
all_p = await _lp(user_id)
|
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
|
||||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
|
||||||
proj = matches[0] if matches else None
|
|
||||||
if proj is None:
|
|
||||||
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
|
|
||||||
ms_title = ms_title_pre
|
ms_title = ms_title_pre
|
||||||
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt2, list_milestones as _lms
|
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt2, list_milestones as _lms
|
||||||
existing_ms = await _gmbt2(user_id, proj.id, ms_title)
|
existing_ms = await _gmbt2(user_id, proj.id, ms_title)
|
||||||
@@ -1426,15 +1437,34 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
)
|
)
|
||||||
return {"success": True, "type": "milestone", "data": ms.to_dict()}
|
return {"success": True, "type": "milestone", "data": ms.to_dict()}
|
||||||
|
|
||||||
|
elif tool_name == "update_milestone":
|
||||||
|
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt, update_milestone as _um
|
||||||
|
project_name = arguments.get("project", "")
|
||||||
|
milestone_name = arguments.get("milestone", "")
|
||||||
|
if not project_name or not milestone_name:
|
||||||
|
return {"success": False, "error": "Both project and milestone are required"}
|
||||||
|
proj = await _resolve_project(user_id, project_name)
|
||||||
|
if proj is None:
|
||||||
|
return {"success": False, "error": f"Project '{project_name}' not found"}
|
||||||
|
ms = await _gmbt(user_id, proj.id, milestone_name)
|
||||||
|
if ms is None:
|
||||||
|
return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."}
|
||||||
|
fields: dict[str, object] = {}
|
||||||
|
if "title" in arguments:
|
||||||
|
fields["title"] = arguments["title"]
|
||||||
|
if "description" in arguments:
|
||||||
|
fields["description"] = arguments["description"]
|
||||||
|
if "status" in arguments:
|
||||||
|
fields["status"] = arguments["status"]
|
||||||
|
if not fields:
|
||||||
|
return {"success": False, "error": "No fields to update — provide at least one of: title, description, status"}
|
||||||
|
updated = await _um(user_id, ms.id, **fields)
|
||||||
|
return {"success": True, "type": "milestone", "data": updated.to_dict()}
|
||||||
|
|
||||||
elif tool_name == "list_milestones":
|
elif tool_name == "list_milestones":
|
||||||
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
|
|
||||||
from fabledassistant.services.milestones import get_project_milestone_summary
|
from fabledassistant.services.milestones import get_project_milestone_summary
|
||||||
project_name = arguments.get("project", "")
|
project_name = arguments.get("project", "")
|
||||||
proj = await _gpbt(user_id, project_name)
|
proj = await _resolve_project(user_id, project_name)
|
||||||
if proj is None:
|
|
||||||
all_p = await _lp(user_id)
|
|
||||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
|
||||||
proj = matches[0] if matches else None
|
|
||||||
if proj is None:
|
if proj is None:
|
||||||
return {"success": False, "error": f"No project found matching '{project_name}'"}
|
return {"success": False, "error": f"No project found matching '{project_name}'"}
|
||||||
summary = await get_project_milestone_summary(user_id, proj.id)
|
summary = await get_project_milestone_summary(user_id, proj.id)
|
||||||
@@ -1473,15 +1503,17 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
elif tool_name == "create_project":
|
elif tool_name == "create_project":
|
||||||
from fabledassistant.services.projects import create_project as _create_project, get_project_by_title as _gpbt, list_projects as _lp
|
from fabledassistant.services.projects import create_project as _create_project, get_project_by_title as _gpbt, list_projects as _lp
|
||||||
proj_title = arguments.get("title", "New Project")
|
proj_title = arguments.get("title", "New Project")
|
||||||
if not arguments.get("confirmed"):
|
# Always check for existing/similar projects first — even when confirmed=true
|
||||||
return {"success": False, "requires_confirmation": True, "error": f"Project creation requires explicit user confirmation. Ask the user: 'Shall I create a new project titled \"{proj_title}\"?' Then retry with confirmed=true if they agree."}
|
|
||||||
existing_proj = await _gpbt(user_id, proj_title)
|
existing_proj = await _gpbt(user_id, proj_title)
|
||||||
if existing_proj is not None:
|
if existing_proj is not None:
|
||||||
return {"success": False, "error": f"A project titled '{existing_proj.title}' already exists (id: {existing_proj.id}). Use update_project to modify it instead of creating a duplicate."}
|
return {"success": False, "error": f"A project titled '{existing_proj.title}' already exists (id: {existing_proj.id}). Use that project instead of creating a duplicate."}
|
||||||
all_projects = await _lp(user_id)
|
all_projects = await _lp(user_id)
|
||||||
near_proj, ratio = _fuzzy_title_match(proj_title, all_projects)
|
# Broad fuzzy check (0.55) catches partial names and abbreviations
|
||||||
|
near_proj, ratio = _fuzzy_title_match(proj_title, all_projects, threshold=0.55)
|
||||||
if near_proj is not None:
|
if near_proj is not None:
|
||||||
return {"success": False, "error": f"A project with a very similar title '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). Use update_project to modify it instead of creating a duplicate."}
|
return {"success": False, "requires_confirmation": True, "error": f"A project with a similar name '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). This is likely the project you meant — use that project's name instead. Only call create_project again if you are certain this should be a completely new, separate project with a different confirmed=true."}
|
||||||
|
if not arguments.get("confirmed"):
|
||||||
|
return {"success": False, "requires_confirmation": True, "error": f"Project creation requires explicit user confirmation. Ask the user: 'Shall I create a new project titled \"{proj_title}\"?' Then retry with confirmed=true if they agree."}
|
||||||
project = await _create_project(
|
project = await _create_project(
|
||||||
user_id,
|
user_id,
|
||||||
title=proj_title,
|
title=proj_title,
|
||||||
|
|||||||
+14
-1
@@ -12,7 +12,20 @@
|
|||||||
> Include file-level details in the commit body when the change is non-trivial.
|
> Include file-level details in the commit body when the change is non-trivial.
|
||||||
|
|
||||||
## Last Updated
|
## Last Updated
|
||||||
2026-03-12 — Daily Briefing feature (full stack), CI release process fix, CalVer version tracking, content deduplication, task history improvements.
|
2026-03-16 — Fuzzy project resolution, accidental project creation guard, task embedding, briefing CalDAV fix, notes count query fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**2026-03-16 — Fuzzy project resolution + create_project guard:**
|
||||||
|
|
||||||
|
- `services/tools.py` — `_resolve_project` rewritten with 4-step lookup: (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55 fallback. Partial names like "Famous Supply" now reliably resolve to "Famous Supply Co." without the model creating a duplicate project.
|
||||||
|
- `services/tools.py` — `create_project` handler: similar-project checks (threshold 0.55) now run **before** the `confirmed` gate, so `confirmed=true` cannot bypass them. All call sites that duplicated the old inline substring logic replaced with `_resolve_project`.
|
||||||
|
- `services/tools.py` — Error messages on project-not-found no longer say "or create_project to create one"; they direct the model to `list_projects` instead. Tool description explicitly says never call `create_project` in response to a lookup failure.
|
||||||
|
- `routes/tasks.py` — Fire-and-forget `upsert_note_embedding` added to create and update task routes (was missing; tasks created via REST API weren't getting embeddings).
|
||||||
|
- `services/briefing_pipeline.py` — `await is_caldav_configured()` (was called without await — was a latent bug since `is_caldav_configured` became async).
|
||||||
|
- `services/notes.py` — Removed erroneous duplicate `count_query.where(Note.parent_id == parent_id)` that was applied inside the `no_project` branch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**CI release process (2026-03-12):**
|
**CI release process (2026-03-12):**
|
||||||
- `.forgejo/workflows/ci.yml`: removed `main` from `branches` push trigger. CI now fires only on `dev` pushes, `v*` tags, and pull requests. Main branch merges no longer trigger a run — the PR check covers pre-merge safety. Production release process: create a release via Forgejo UI on `main` with a `v*` tag → tag push event fires CI → build job pushes `:latest` + `:<version>` Docker images to registry. Branch protection is not an obstacle because the UI release creates the tag through the API.
|
- `.forgejo/workflows/ci.yml`: removed `main` from `branches` push trigger. CI now fires only on `dev` pushes, `v*` tags, and pull requests. Main branch merges no longer trigger a run — the PR check covers pre-merge safety. Production release process: create a release via Forgejo UI on `main` with a `v*` tag → tag push event fires CI → build job pushes `:latest` + `:<version>` Docker images to registry. Branch protection is not an obstacle because the UI release creates the tag through the API.
|
||||||
|
|||||||
Reference in New Issue
Block a user