Compare commits

...

11 Commits

Author SHA1 Message Date
bvandeusen 1c3f0ab7f7 Merge pull request 'Dev to Main: bug fixes' (#5) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#5
2026-03-16 16:00:47 +00:00
bvandeusen 33f52081e5 docs: update summary.md for 2026-03-16 session
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 08:29:04 -04:00
bvandeusen a58dbe79f2 fix: fuzzy project resolution and guard against accidental project creation
- _resolve_project now uses reverse-substring + SequenceMatcher (≥0.55)
  so partial names like "Famous Supply" match "Famous Supply Co." reliably
- create_project runs similar-project checks before the confirmed gate so
  confirmed=true can't bypass them; threshold lowered to 0.55 to catch
  abbreviated names
- Error messages on project-not-found no longer suggest create_project,
  directing the model to list_projects first
- Tool description updated to explicitly prohibit calling create_project
  in response to a lookup failure
- tasks.py: fire-and-forget embedding update on create/update
- briefing_pipeline.py: await is_caldav_configured (was sync call)
- notes.py: remove erroneous duplicate count_query filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 07:44:27 -04:00
bvandeusen 0d41b8be34 Fix briefing settings not loading when tab is active on mount
The watch(activeTab) only fires on changes, not the initial value.
When localStorage had settings_tab="briefing", onMounted skipped
loadBriefingTab() so the form always showed default empty values.
Follows the same pattern already used for the groups panel (line 338).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 23:05:10 -04:00
bvandeusen bf3bf99410 Merge pull request 'dev to main: improvements and bug fixes' (#4) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#4
2026-03-12 22:06:01 -04:00
bvandeusen 9cb3700a5c fix: add temp_unit and timezone to inline BriefingConfig literals
BriefingSetupWizard and SettingsView had hardcoded initial objects that
predated the new fields, causing TS2345 type errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 18:33:17 -04:00
bvandeusen b44d8496bc fix: queue drain survives navigation away from a streaming conversation
Previously the queue drain guard (currentConversation.id === convId) meant
that if the user navigated to ChatView while a workspace stream was running,
the workspace's queued messages were silently abandoned — they sat in
localStorage indefinitely with no code path to resume them.

- Extract _tryDrainQueue(convId) with the same guard logic
- Call it at stream-end (replaces the inline block)
- Call it in fetchConversation after _loadQueue, so returning to a
  conversation with orphaned queue messages drains them automatically
- Order is now preserved: messages drain in the order they were queued,
  even across navigation events

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 18:08:45 -04:00
bvandeusen 3eb61950c9 fix: auto-scroll workspace chat when messages are added
Watch currentConversation.messages.length so the chat panel scrolls to
the bottom when user messages are appended or assistant messages land,
not only while streaming content is updating.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 18:02:28 -04:00
bvandeusen beb57876fb feat: update_milestone tool and workspace milestone refresh
- Add update_milestone LLM tool: accepts project + milestone title to
  look up, then applies any of title/description/status changes
- WorkspaceView SSE watcher now triggers taskPanelRef.reload() on
  create_milestone and update_milestone success, so milestone groups
  appear immediately without a manual refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 17:58:13 -04:00
bvandeusen 5ea3bb5aff feat: per-user timezone-aware briefing scheduler
Briefing slots (4am/8am/12pm/4pm) now fire in each user's local timezone
rather than UTC, so the schedule matches the user's actual day.

Backend:
- briefing_scheduler: replaced 4 global UTC cron jobs with per-user
  CronTrigger jobs keyed to the user's IANA timezone; update_user_schedule()
  live-patches the scheduler when config is saved (no restart needed)
- Catchup logic evaluates missed slots in the user's local timezone
- put_config route calls update_user_schedule() after saving

Frontend:
- New Timezone section in Briefing settings: text input pre-filled from
  browser (Intl.DateTimeFormat) with a Detect button for re-detection
- Slot times shown as fixed local times (4:00 am etc.) with the configured
  timezone displayed beneath, replacing the old UTC-conversion display
- timezone field added to BriefingConfig type and default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:34:31 -04:00
bvandeusen 6e57ce4555 feat: temperature unit preference and slot timezone display for briefing
- Add °C/°F toggle in briefing settings; persisted in briefing_config.temp_unit
- briefing_pipeline reads temp_unit and converts Open-Meteo Celsius values
  before passing them to the LLM (both full compilation and slot injection)
- Scheduled Slots section now shows each UTC slot time converted to the
  user's browser local time, plus a line confirming which timezone the
  browser is using

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:29:40 -04:00
12 changed files with 416 additions and 157 deletions
+4
View File
@@ -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
View File
@@ -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> {
+91 -3
View File
@@ -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);
+2 -1
View File
@@ -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;
+3
View File
@@ -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})
+9
View File
@@ -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
-1
View File
@@ -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":
+90 -58
View File
@@ -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
View File
@@ -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-12Daily Briefing feature (full stack), CI release process fix, CalVer version tracking, content deduplication, task history improvements. 2026-03-16Fuzzy 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.