Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76dc75a03b | |||
| a551f52682 | |||
| 6de855e226 | |||
| c6357e52d9 | |||
| 5d40f2113f | |||
| 9a96fdb3c0 | |||
| 460959f0d4 | |||
| 0dbbb98cf5 | |||
| 4b7ca1b17e | |||
| 65a3689aaa | |||
| 42c11dedae | |||
| 0fbb1fbd92 | |||
| bb650ba563 | |||
| c663532fd4 | |||
| 090b7d83dd | |||
| 4e9eead3ab | |||
| fc6ebf81eb | |||
| b88d5ee6b3 | |||
| 020bd6614b | |||
| 4403026797 | |||
| 552943d6c0 | |||
| 2576be9e49 | |||
| e17fc088b2 | |||
| c5b0344240 | |||
| c8765959ea | |||
| c33cab7020 | |||
| 36cd08c236 | |||
| f85b92a885 | |||
| 84640a0dc4 | |||
| 4c58603009 | |||
| b7e7073425 | |||
| 94b169f31c | |||
| 2db23cec7a | |||
| b81c4aa600 | |||
| 35fab6cbf7 |
@@ -1 +1 @@
|
||||
2298268
|
||||
3158517
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""replace events.end_dt with duration_minutes (Fable #160)
|
||||
|
||||
Revision ID: 0043
|
||||
Revises: 0042
|
||||
Create Date: 2026-04-29
|
||||
|
||||
Structural fix for the "end before start" bug class observed on prod
|
||||
2026-04-29: an event landed with end_dt 32 days before start_dt due
|
||||
to a tool-call mishap, then disappeared from upcoming-list filters.
|
||||
Storing duration instead of end_dt makes the invalid state
|
||||
inexpressible at the schema level (duration_minutes >= 0).
|
||||
|
||||
Backfill rules:
|
||||
- end_dt valid (end_dt > start_dt) → duration_minutes = total minutes
|
||||
- end_dt == start_dt → duration_minutes = 0 (zero-duration point)
|
||||
- end_dt NULL OR end_dt < start_dt → duration_minutes = NULL (corrupt
|
||||
or open-ended; treated as a point event from here on)
|
||||
|
||||
Existing API consumers continue to receive `end_dt` in responses — the
|
||||
field is now derived from `start_dt + duration_minutes` in
|
||||
``Event.to_dict()`` rather than stored.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0043"
|
||||
down_revision = "0042"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("duration_minutes", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"events_duration_minutes_non_negative",
|
||||
"events",
|
||||
"duration_minutes IS NULL OR duration_minutes >= 0",
|
||||
)
|
||||
# Backfill: convert valid end_dt into a minute count; leave NULL for
|
||||
# corrupt or absent end_dt. Bad rows (end_dt <= start_dt) collapse
|
||||
# cleanly to point events instead of forcing a recovery guess.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE events
|
||||
SET duration_minutes = CAST(
|
||||
EXTRACT(EPOCH FROM (end_dt - start_dt)) / 60 AS INTEGER
|
||||
)
|
||||
WHERE end_dt IS NOT NULL AND end_dt >= start_dt
|
||||
"""
|
||||
)
|
||||
op.drop_column("events", "end_dt")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("end_dt", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# Restore end_dt = start_dt + duration_minutes minutes for rows that
|
||||
# had a duration. NULL duration → NULL end_dt (point event).
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE events
|
||||
SET end_dt = start_dt + (duration_minutes || ' minutes')::interval
|
||||
WHERE duration_minutes IS NOT NULL
|
||||
"""
|
||||
)
|
||||
op.drop_constraint("events_duration_minutes_non_negative", "events")
|
||||
op.drop_column("events", "duration_minutes")
|
||||
Generated
+1
-1
@@ -184,7 +184,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fable-mcp"
|
||||
version = "0.2.6"
|
||||
version = "0.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
@@ -317,6 +317,7 @@ export interface JournalConfig {
|
||||
day_rollover_hour: number;
|
||||
morning_end_hour?: number;
|
||||
midday_end_hour?: number;
|
||||
closeout_enabled?: boolean;
|
||||
// Ambient-context fields (carried forward from the briefing config schema)
|
||||
locations?: { home?: JournalLocation; work?: JournalLocation };
|
||||
temp_unit?: 'C' | 'F';
|
||||
@@ -545,7 +546,7 @@ export interface EventUpdatePayload {
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string;
|
||||
recurrence?: string | null;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
@@ -696,3 +697,11 @@ export const updateProfile = (data: Partial<UserProfile>) =>
|
||||
export const consolidateProfile = () =>
|
||||
apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {})
|
||||
export const clearProfileObservations = () => apiDelete('/api/profile/observations')
|
||||
|
||||
export interface ProfileObservationEntry {
|
||||
date: string
|
||||
bullets: string
|
||||
}
|
||||
|
||||
export const listProfileObservations = () =>
|
||||
apiGet<{ observations: ProfileObservationEntry[] }>('/api/profile/observations')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
@@ -37,6 +37,36 @@ const description = ref("");
|
||||
const location = ref("");
|
||||
const color = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const recurrence = ref<string>("");
|
||||
|
||||
// Preset RRULE strings. The select binds to `recurrencePreset`, which writes
|
||||
// through to `recurrence`. CalDAV-imported rules with extra parts
|
||||
// (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`) fall through to "custom" and the raw
|
||||
// string is shown read-only below the select.
|
||||
const RECURRENCE_PRESETS: Record<string, string> = {
|
||||
none: "",
|
||||
daily: "FREQ=DAILY",
|
||||
weekly: "FREQ=WEEKLY",
|
||||
monthly: "FREQ=MONTHLY",
|
||||
yearly: "FREQ=YEARLY",
|
||||
};
|
||||
|
||||
const recurrencePreset = computed<string>({
|
||||
get() {
|
||||
const r = (recurrence.value || "").trim();
|
||||
if (!r) return "none";
|
||||
for (const [key, val] of Object.entries(RECURRENCE_PRESETS)) {
|
||||
if (val && val === r) return key;
|
||||
}
|
||||
return "custom";
|
||||
},
|
||||
set(key: string) {
|
||||
if (key === "custom") return; // no-op; can't pick custom from dropdown
|
||||
recurrence.value = RECURRENCE_PRESETS[key] ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
const isCustomRecurrence = computed(() => recurrencePreset.value === "custom");
|
||||
|
||||
function dateFromIso(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
@@ -115,6 +145,7 @@ function resetForm() {
|
||||
location.value = props.event.location || "";
|
||||
color.value = props.event.color || "";
|
||||
projectId.value = props.event.project_id;
|
||||
recurrence.value = props.event.recurrence || "";
|
||||
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
|
||||
if (_lastDurationMin <= 0) _lastDurationMin = 60;
|
||||
} else {
|
||||
@@ -130,6 +161,7 @@ function resetForm() {
|
||||
location.value = "";
|
||||
color.value = "";
|
||||
projectId.value = null;
|
||||
recurrence.value = "";
|
||||
_lastDurationMin = 60;
|
||||
}
|
||||
deleteConfirm.value = false;
|
||||
@@ -177,26 +209,69 @@ watch(() => props.event, resetForm, { immediate: true });
|
||||
watch(() => props.initialDate, resetForm);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") emit("close");
|
||||
if (e.key === "Escape") {
|
||||
if (deleteConfirm.value) {
|
||||
// Esc cancels the delete-confirm rather than closing the modal —
|
||||
// gives the user a clear way out of the destructive prompt.
|
||||
deleteConfirm.value = false;
|
||||
return;
|
||||
}
|
||||
attemptClose();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
||||
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
|
||||
|
||||
async function save() {
|
||||
// ── Close / save flow ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
|
||||
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
|
||||
//
|
||||
// Validity-aware behavior:
|
||||
// - Form valid → save (PATCH for edit, POST for create), then close.
|
||||
// - Form invalid in EDIT mode → discard the in-memory change and close.
|
||||
// A toast tells the user what happened so they don't think their edit
|
||||
// silently landed.
|
||||
// - Form invalid in CREATE mode → close silently (nothing existed to begin
|
||||
// with; no need to call this out).
|
||||
|
||||
function isFormValid(): { valid: boolean; reason?: string } {
|
||||
if (!title.value.trim()) {
|
||||
toast.show("Title is required", "error");
|
||||
return;
|
||||
return { valid: false, reason: "Title required" };
|
||||
}
|
||||
if (!startDate.value) {
|
||||
toast.show("Start date is required", "error");
|
||||
return;
|
||||
return { valid: false, reason: "Start date required" };
|
||||
}
|
||||
if (!allDay.value && !startTime.value) {
|
||||
toast.show("Start time is required", "error");
|
||||
return;
|
||||
return { valid: false, reason: "Start time required" };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
let _closing = false;
|
||||
|
||||
async function attemptClose() {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
try {
|
||||
const validity = isFormValid();
|
||||
if (!validity.valid) {
|
||||
if (isEditMode.value) {
|
||||
toast.show(`${validity.reason} — change discarded`, "warning");
|
||||
}
|
||||
// Create mode + invalid: silent close. Nothing was committed.
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
await save();
|
||||
emit("close");
|
||||
} finally {
|
||||
_closing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
||||
const end_dt = endDate.value
|
||||
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
|
||||
@@ -214,9 +289,9 @@ async function save() {
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || null,
|
||||
};
|
||||
const updated = await updateEvent(props.event.id, payload);
|
||||
toast.show("Event updated", "success");
|
||||
emit("updated", updated);
|
||||
} else {
|
||||
const payload: EventCreatePayload = {
|
||||
@@ -228,9 +303,9 @@ async function save() {
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || undefined,
|
||||
};
|
||||
const created = await createEvent(payload);
|
||||
toast.show("Event created", "success");
|
||||
emit("created", created);
|
||||
}
|
||||
} catch {
|
||||
@@ -256,23 +331,57 @@ async function doDelete() {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="slide-over-backdrop" @click.self="emit('close')">
|
||||
<div class="slide-over-panel" role="dialog" aria-modal="true">
|
||||
<div class="so-header">
|
||||
<h2 class="so-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||
<button class="so-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
|
||||
<div class="modal-backdrop" @click.self="attemptClose">
|
||||
<div class="modal-panel" role="dialog" aria-modal="true">
|
||||
<!-- Header: trash + close (or inline delete-confirm) -->
|
||||
<div class="modal-header">
|
||||
<template v-if="!deleteConfirm">
|
||||
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
class="header-btn header-btn-danger"
|
||||
@click="deleteConfirm = true"
|
||||
title="Delete event"
|
||||
aria-label="Delete event"
|
||||
><Trash2 :size="16" /></button>
|
||||
<button
|
||||
class="header-btn"
|
||||
@click="attemptClose"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
><X :size="16" /></button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="delete-confirm-prompt">Delete this event?</span>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="deleting"
|
||||
@click="doDelete"
|
||||
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-confirm-cancel"
|
||||
@click="deleteConfirm = false"
|
||||
>No</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<form class="so-form" @submit.prevent="save">
|
||||
<!-- Body: form (scrolls if it gets long) -->
|
||||
<form class="modal-form" @submit.prevent="attemptClose">
|
||||
<!-- Title -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Title <span class="required">*</span></label>
|
||||
<input v-model="title" class="so-input" placeholder="Event title" autofocus />
|
||||
<div class="form-field">
|
||||
<label class="form-label">Title <span class="required">*</span></label>
|
||||
<input v-model="title" class="form-input" placeholder="Event title" autofocus />
|
||||
</div>
|
||||
|
||||
<!-- All-day toggle -->
|
||||
<div class="so-field so-field-row">
|
||||
<label class="so-label so-label-inline">All day</label>
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">All day</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="['toggle-btn', { active: allDay }]"
|
||||
@@ -281,74 +390,73 @@ async function doDelete() {
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Start <span class="required">*</span></label>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Start <span class="required">*</span></label>
|
||||
<div class="dt-row">
|
||||
<input v-model="startDate" type="date" class="so-input dt-date" required />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" required />
|
||||
<input v-model="startDate" type="date" class="form-input dt-date" required />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
|
||||
</div>
|
||||
<p v-if="isPastEvent" class="so-past-hint">This event is in the past</p>
|
||||
<p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
|
||||
</div>
|
||||
|
||||
<!-- End -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">End</label>
|
||||
<div class="form-field">
|
||||
<label class="form-label">End</label>
|
||||
<div class="dt-row">
|
||||
<input v-model="endDate" type="date" class="so-input dt-date" :min="startDate" />
|
||||
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
|
||||
<input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
|
||||
<input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recurrence -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Repeat</label>
|
||||
<select v-model="recurrencePreset" class="form-input">
|
||||
<option value="none">Does not repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option v-if="isCustomRecurrence" value="custom" disabled>Custom</option>
|
||||
</select>
|
||||
<p v-if="isCustomRecurrence" class="recurrence-custom-hint">
|
||||
Custom rule: <code>{{ recurrence }}</code>
|
||||
<br />
|
||||
<span class="form-hint">Picking a preset will replace this rule.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Location <span class="so-hint">(optional)</span></label>
|
||||
<input v-model="location" class="so-input" placeholder="Location" />
|
||||
<div class="form-field">
|
||||
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
|
||||
<input v-model="location" class="form-input" placeholder="Location" />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Description <span class="so-hint">(optional)</span></label>
|
||||
<textarea v-model="description" class="so-input so-textarea" placeholder="Description" rows="3" />
|
||||
<div class="form-field">
|
||||
<label class="form-label">Description <span class="form-hint">(optional)</span></label>
|
||||
<textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="so-field so-field-row">
|
||||
<label class="so-label so-label-inline">Color</label>
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">Color</label>
|
||||
<div class="color-row">
|
||||
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
|
||||
<input v-model="color" class="so-input color-hex" placeholder="#5B4A8A" />
|
||||
<input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
|
||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Project <span class="so-hint">(optional)</span></label>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
|
||||
<ProjectSelector v-model="projectId" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="so-actions">
|
||||
<button type="submit" class="btn-primary" :disabled="saving">
|
||||
{{ saving ? "Saving…" : (isEditMode ? "Save" : "Create") }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
|
||||
<template v-if="isEditMode">
|
||||
<button
|
||||
v-if="!deleteConfirm"
|
||||
type="button"
|
||||
class="btn-danger-ghost"
|
||||
@click="deleteConfirm = true"
|
||||
>Delete</button>
|
||||
<template v-else>
|
||||
<span class="delete-confirm-label">Delete this event?</span>
|
||||
<button type="button" class="btn-danger" :disabled="deleting" @click="doDelete">
|
||||
{{ deleting ? "Deleting…" : "Yes, delete" }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" @click="deleteConfirm = false">No</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<!-- A hidden submit so Enter inside text inputs triggers attemptClose,
|
||||
matching the no-explicit-Save-button intent: Enter commits. -->
|
||||
<button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,81 +464,110 @@ async function doDelete() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-over-backdrop {
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.slide-over-panel {
|
||||
.modal-panel {
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
border-left: 1px solid var(--color-border, #2a2b30);
|
||||
width: min(440px, 100vw);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
width: min(480px, 100%);
|
||||
max-height: calc(100vh - 2.5rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.so-header {
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem 0.85rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #2a2b30);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
z-index: 1;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.so-title {
|
||||
.modal-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.so-close {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: var(--color-hover, rgba(255,255,255,0.06));
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
.so-close:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.so-form {
|
||||
padding: 1.25rem 1.5rem;
|
||||
/* Trash in header: subtle until hover, then Oxblood. Lower visual weight
|
||||
than Save would have been — destructive actions shouldn't loom. */
|
||||
.header-btn-danger:hover {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Inline delete-confirm prompt replaces the title row */
|
||||
.delete-confirm-prompt {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Form scrolls inside the panel when content overflows */
|
||||
.modal-form {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.so-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.so-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
|
||||
.so-label {
|
||||
.form-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.so-label-inline { flex-shrink: 0; margin: 0; }
|
||||
.so-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||
.form-label-inline { flex-shrink: 0; margin: 0; }
|
||||
.form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||
|
||||
.required { color: #f87171; }
|
||||
|
||||
.so-input {
|
||||
.form-input {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text, #e8e9f0);
|
||||
@@ -441,19 +578,35 @@ async function doDelete() {
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.so-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.form-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||
.form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||
|
||||
.dt-row { display: flex; gap: 0.5rem; }
|
||||
.dt-date { flex: 1; }
|
||||
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
||||
.so-past-hint {
|
||||
.form-past-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.recurrence-custom-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.recurrence-custom-hint code {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
@@ -482,65 +635,14 @@ async function doDelete() {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.so-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border, #2a2b30);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Save (in slide-over): Moss action-primary */
|
||||
.btn-primary {
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
|
||||
/* Cancel: Bronze action-secondary */
|
||||
.btn-secondary {
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Delete (entry-point): Oxblood action-destructive ghost */
|
||||
.btn-danger-ghost {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-danger-ghost:hover { background: var(--color-action-destructive); color: #fff; }
|
||||
|
||||
/* Confirm-delete: Oxblood filled */
|
||||
/* Confirm-delete buttons (only shown during the inline confirm flow) */
|
||||
.btn-danger {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
@@ -548,9 +650,28 @@ async function doDelete() {
|
||||
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.delete-confirm-label {
|
||||
.btn-confirm-cancel {
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
align-self: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
|
||||
path as X / Esc / backdrop. No visible Save button needed. */
|
||||
.hidden-submit {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile, type JournalConfig } from "@/api/client";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, listProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile, type JournalConfig, type ProfileObservationEntry } from "@/api/client";
|
||||
import { usePushStore } from "@/stores/push";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
@@ -549,6 +549,26 @@ const profileSaving = ref(false)
|
||||
const profileSaved = ref(false)
|
||||
const consolidating = ref(false)
|
||||
const clearingObs = ref(false)
|
||||
const observations = ref<ProfileObservationEntry[]>([])
|
||||
const observationsExpanded = ref(false)
|
||||
const observationsLoading = ref(false)
|
||||
const observationsLoaded = ref(false)
|
||||
|
||||
async function toggleObservations() {
|
||||
observationsExpanded.value = !observationsExpanded.value
|
||||
if (observationsExpanded.value && !observationsLoaded.value) {
|
||||
observationsLoading.value = true
|
||||
try {
|
||||
const res = await listProfileObservations()
|
||||
observations.value = res.observations
|
||||
observationsLoaded.value = true
|
||||
} catch {
|
||||
toastStore.show('Failed to load observations', 'error')
|
||||
} finally {
|
||||
observationsLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
try { profile.value = await getProfile() } catch { /* non-critical */ }
|
||||
@@ -582,6 +602,17 @@ function toggleProfileWorkDay(day: string) {
|
||||
profile.value.work_schedule = { ...profile.value.work_schedule, days }
|
||||
}
|
||||
|
||||
async function onToggleCloseout(enabled: boolean) {
|
||||
journalConfig.value.closeout_enabled = enabled
|
||||
try {
|
||||
await saveJournalConfig(journalConfig.value)
|
||||
toastStore.show(enabled ? 'Nightly closeout enabled' : 'Nightly closeout disabled')
|
||||
} catch {
|
||||
journalConfig.value.closeout_enabled = !enabled // revert UI on failure
|
||||
toastStore.show('Failed to update closeout setting', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function runConsolidate() {
|
||||
consolidating.value = true
|
||||
try {
|
||||
@@ -600,6 +631,7 @@ const journalConfig = ref<JournalConfig>({
|
||||
prep_hour: 5,
|
||||
prep_minute: 0,
|
||||
day_rollover_hour: 4,
|
||||
closeout_enabled: true,
|
||||
locations: { home: { label: 'Home', address: '' }, work: { label: 'Work', address: '' } },
|
||||
temp_unit: 'C',
|
||||
})
|
||||
@@ -620,6 +652,7 @@ async function loadJournalConfig() {
|
||||
prep_hour: cfg.prep_hour ?? 5,
|
||||
prep_minute: cfg.prep_minute ?? 0,
|
||||
day_rollover_hour: cfg.day_rollover_hour ?? 4,
|
||||
closeout_enabled: cfg.closeout_enabled ?? true,
|
||||
morning_end_hour: cfg.morning_end_hour,
|
||||
midday_end_hour: cfg.midday_end_hour,
|
||||
locations: {
|
||||
@@ -689,6 +722,8 @@ async function clearObservations() {
|
||||
profile.value.learned_summary = ''
|
||||
profile.value.observations_count = 0
|
||||
profile.value.observations_updated_at = null
|
||||
observations.value = []
|
||||
observationsLoaded.value = false
|
||||
toastStore.show('Learned data cleared')
|
||||
} catch { toastStore.show('Failed to clear observations', 'error') }
|
||||
finally { clearingObs.value = false }
|
||||
@@ -1901,8 +1936,38 @@ function formatUserDate(iso: string): string {
|
||||
The assistant observes patterns from your journal and chat conversations and builds a summary over time. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you.
|
||||
<span v-if="profile.observations_count > 0"> {{ profile.observations_count }} raw observation{{ profile.observations_count !== 1 ? 's' : '' }} stored.</span>
|
||||
</p>
|
||||
|
||||
<label class="toggle-row" style="display:flex;align-items:center;gap:0.6rem;margin:0.5rem 0 0.75rem">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="journalConfig.closeout_enabled !== false"
|
||||
@change="onToggleCloseout(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>
|
||||
<strong>Nightly closeout</strong>
|
||||
<small style="display:block;color:var(--color-text-muted)">Extracts patterns from yesterday's journal at your day-rollover hour.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="profile.learned_summary" class="learned-summary">{{ profile.learned_summary }}</div>
|
||||
<div v-else class="learned-empty">No learned summary yet. Observations accumulate from journal and chat conversations.</div>
|
||||
|
||||
<div class="observations-panel" style="margin-top:0.75rem">
|
||||
<button class="btn-secondary" @click="toggleObservations" :disabled="profile.observations_count === 0">
|
||||
{{ observationsExpanded ? '▾' : '▸' }} Recent observations ({{ profile.observations_count }})
|
||||
</button>
|
||||
<div v-if="observationsExpanded" class="observations-list" style="margin-top:0.5rem;padding:0.5rem 0.75rem;border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-elev-1)">
|
||||
<div v-if="observationsLoading">Loading…</div>
|
||||
<div v-else-if="observations.length === 0">No observations yet.</div>
|
||||
<div v-else>
|
||||
<div v-for="entry in observations" :key="entry.date" style="margin-bottom:0.75rem">
|
||||
<div style="font-weight:600;font-size:0.875rem;color:var(--color-text-muted)">{{ entry.date }}</div>
|
||||
<div style="white-space:pre-wrap;font-size:0.9rem">{{ entry.bullets }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" style="gap:0.5rem;flex-wrap:wrap">
|
||||
<button class="btn-secondary" @click="runConsolidate" :disabled="consolidating || profile.observations_count === 0">
|
||||
{{ consolidating ? 'Consolidating…' : 'Consolidate Now' }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@@ -20,7 +20,11 @@ class Event(Base):
|
||||
uid: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
end_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Duration in minutes; NULL = point event with no end specified.
|
||||
# Replaces the prior `end_dt` column (Fable #160 / migration 0043).
|
||||
# The DB has a CHECK constraint that this is NULL or >= 0, so an
|
||||
# event whose end is before its start is structurally inexpressible.
|
||||
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
location: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -38,7 +42,21 @@ class Event(Base):
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
@property
|
||||
def end_dt(self) -> datetime | None:
|
||||
"""Derived end datetime: ``start_dt + duration_minutes``.
|
||||
|
||||
Returns ``None`` for point events (``duration_minutes is None``).
|
||||
Computed at access time rather than stored — a stored end was
|
||||
the source of the "end before start" corruption that motivated
|
||||
this redesign.
|
||||
"""
|
||||
if self.duration_minutes is None:
|
||||
return None
|
||||
return self.start_dt + timedelta(minutes=self.duration_minutes)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
end_dt = self.end_dt
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
@@ -47,7 +65,8 @@ class Event(Base):
|
||||
"project_id": self.project_id,
|
||||
"title": self.title,
|
||||
"start_dt": self.start_dt.isoformat() if self.start_dt else None,
|
||||
"end_dt": self.end_dt.isoformat() if self.end_dt else None,
|
||||
"end_dt": end_dt.isoformat() if end_dt else None,
|
||||
"duration_minutes": self.duration_minutes,
|
||||
"all_day": self.all_day,
|
||||
"description": self.description,
|
||||
"location": self.location,
|
||||
|
||||
@@ -54,19 +54,23 @@ async def create_event():
|
||||
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid datetime format"}), 400
|
||||
event = await events_svc.create_event(
|
||||
user_id=_get_current_user_id(),
|
||||
title=data["title"],
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
all_day=data.get("all_day", False),
|
||||
description=data.get("description", ""),
|
||||
location=data.get("location", ""),
|
||||
color=data.get("color", ""),
|
||||
recurrence=data.get("recurrence"),
|
||||
project_id=data.get("project_id"),
|
||||
reminder_minutes=data.get("reminder_minutes"),
|
||||
)
|
||||
try:
|
||||
event = await events_svc.create_event(
|
||||
user_id=_get_current_user_id(),
|
||||
title=data["title"],
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
duration_minutes=data.get("duration_minutes"),
|
||||
all_day=data.get("all_day", False),
|
||||
description=data.get("description", ""),
|
||||
location=data.get("location", ""),
|
||||
color=data.get("color", ""),
|
||||
recurrence=data.get("recurrence"),
|
||||
project_id=data.get("project_id"),
|
||||
reminder_minutes=data.get("reminder_minutes"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(event.to_dict()), 201
|
||||
|
||||
|
||||
@@ -93,7 +97,7 @@ async def update_event(event_id: int):
|
||||
for bool_field in ("all_day",):
|
||||
if bool_field in data:
|
||||
fields[bool_field] = data[bool_field]
|
||||
for int_field in ("project_id", "reminder_minutes"):
|
||||
for int_field in ("project_id", "reminder_minutes", "duration_minutes"):
|
||||
if int_field in data:
|
||||
fields[int_field] = data[int_field]
|
||||
for dt_field in ("start_dt", "end_dt"):
|
||||
@@ -106,11 +110,14 @@ async def update_event(event_id: int):
|
||||
fields[dt_field] = _parse_dt(data[dt_field])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
||||
event = await events_svc.update_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
**fields,
|
||||
)
|
||||
try:
|
||||
event = await events_svc.update_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
**fields,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if event is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
return jsonify(event.to_dict())
|
||||
|
||||
@@ -66,6 +66,17 @@ async def _resolve_config(user_id: int) -> dict:
|
||||
return {**DEFAULT_JOURNAL_CONFIG, **config}
|
||||
|
||||
|
||||
def _valid_location_keys(cfg: dict) -> set[str]:
|
||||
"""Keys in ``cfg.locations`` that have a usable lat/lon. Anything else
|
||||
(orphaned cache rows, locations the user typed but didn't geocode) is
|
||||
excluded so it can't render as a fake site in the UI."""
|
||||
locations = cfg.get("locations") or {}
|
||||
return {
|
||||
key for key, loc in locations.items()
|
||||
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
|
||||
}
|
||||
|
||||
|
||||
@journal_bp.get("/config")
|
||||
@login_required
|
||||
async def get_config():
|
||||
@@ -82,9 +93,41 @@ async def put_config():
|
||||
return jsonify({"error": "config must be an object"}), 400
|
||||
await set_setting(user_id, "journal_config", json.dumps(body))
|
||||
await update_user_schedule(user_id)
|
||||
|
||||
# Trigger a background weather refresh for any newly-saved location with
|
||||
# valid lat/lon. Without this, the cache row for the location doesn't
|
||||
# exist (or stays stale) until the user clicks the manual refresh button,
|
||||
# so the journal weather panel renders empty for newly-entered sites.
|
||||
valid_locs = [
|
||||
(key, loc)
|
||||
for key, loc in (body.get("locations") or {}).items()
|
||||
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
|
||||
]
|
||||
if valid_locs:
|
||||
asyncio.create_task(_refresh_locations_in_background(user_id, valid_locs))
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
async def _refresh_locations_in_background(
|
||||
user_id: int, locations: list[tuple[str, dict]]
|
||||
) -> None:
|
||||
for key, loc in locations:
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Post-save weather refresh failed for user %d / %s",
|
||||
user_id, key, exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.get("/today")
|
||||
@login_required
|
||||
async def get_today():
|
||||
@@ -258,7 +301,9 @@ async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> No
|
||||
@login_required
|
||||
async def get_weather():
|
||||
user_id = get_current_user_id()
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id)
|
||||
cfg = await _resolve_config(user_id)
|
||||
valid_keys = _valid_location_keys(cfg)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
|
||||
# Kick off a best-effort background refresh for stale rows so the next page
|
||||
@@ -318,7 +363,8 @@ async def refresh_weather():
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id)
|
||||
valid_keys = _valid_location_keys(cfg)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
|
||||
@@ -59,3 +59,13 @@ async def clear_observations():
|
||||
uid = get_current_user_id()
|
||||
await clear_learned_data(uid)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@profile_bp.route("/observations", methods=["GET"])
|
||||
@login_required
|
||||
async def list_observations():
|
||||
uid = get_current_user_id()
|
||||
profile = await get_profile(uid)
|
||||
raw = list(profile.observations_raw or [])
|
||||
# Newest first, last 14 entries
|
||||
return jsonify({"observations": list(reversed(raw[-14:]))})
|
||||
|
||||
@@ -130,6 +130,17 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
async with async_session() as session:
|
||||
for ev in remote_events:
|
||||
caldav_uid = ev["caldav_uid"]
|
||||
# Storage uses duration, not end_dt. Convert here so the
|
||||
# rest of this function can compare/upsert in one shape.
|
||||
ev_start = ev["start_dt"]
|
||||
ev_end = ev["end_dt"]
|
||||
ev_duration = (
|
||||
int((ev_end - ev_start).total_seconds() // 60)
|
||||
if ev_end is not None and ev_start is not None and ev_end > ev_start
|
||||
else None
|
||||
)
|
||||
ev["duration_minutes"] = ev_duration
|
||||
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
@@ -145,8 +156,8 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
uid=str(uuid.uuid4()),
|
||||
caldav_uid=caldav_uid,
|
||||
title=ev["title"],
|
||||
start_dt=ev["start_dt"],
|
||||
end_dt=ev["end_dt"],
|
||||
start_dt=ev_start,
|
||||
duration_minutes=ev_duration,
|
||||
all_day=ev["all_day"],
|
||||
description=ev["description"],
|
||||
location=ev["location"],
|
||||
@@ -157,7 +168,7 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
else:
|
||||
# Update if anything changed
|
||||
changed = False
|
||||
for field in ("title", "start_dt", "end_dt", "all_day", "description", "location", "recurrence"):
|
||||
for field in ("title", "start_dt", "duration_minutes", "all_day", "description", "location", "recurrence"):
|
||||
if getattr(existing, field) != ev[field]:
|
||||
setattr(existing, field, ev[field])
|
||||
changed = True
|
||||
|
||||
@@ -207,6 +207,14 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
||||
d = dtend.dt
|
||||
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||
|
||||
# Storage uses duration, not end_dt. Convert iCal DTEND to a
|
||||
# minute count anchored on DTSTART. Treat invalid (end <= start)
|
||||
# incoming data as a point event rather than rejecting; we
|
||||
# don't control external CalDAV writers.
|
||||
duration_minutes = None
|
||||
if end_dt is not None and end_dt > start_dt:
|
||||
duration_minutes = int((end_dt - start_dt).total_seconds() // 60)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
|
||||
@@ -215,7 +223,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
||||
if existing:
|
||||
existing.title = title
|
||||
existing.start_dt = start_dt
|
||||
existing.end_dt = end_dt
|
||||
existing.duration_minutes = duration_minutes
|
||||
existing.all_day = all_day
|
||||
existing.description = description
|
||||
existing.location = location
|
||||
@@ -230,7 +238,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
||||
uid=ical_uid,
|
||||
title=title,
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
duration_minutes=duration_minutes,
|
||||
all_day=all_day,
|
||||
description=description,
|
||||
location=location,
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
"""Internal event store service with CalDAV push sync."""
|
||||
"""Internal event store service with CalDAV push sync.
|
||||
|
||||
Storage model: an event is anchored at ``start_dt`` and has an optional
|
||||
``duration_minutes``. The end of the event is *derived* via
|
||||
``Event.end_dt`` (a Python property), never stored. Callers may still
|
||||
pass ``end_dt`` on writes for ergonomic compatibility — the service
|
||||
converts to ``duration_minutes`` internally. This rules out the entire
|
||||
"end before start" bug class structurally (Fable #160 / migration
|
||||
0043). Open-ended events use ``duration_minutes = None``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -7,7 +16,7 @@ import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
@@ -15,11 +24,56 @@ from fabledassistant.models.event import Event
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_duration(
|
||||
*,
|
||||
start_dt: datetime,
|
||||
end_dt: datetime | None,
|
||||
duration_minutes: int | None,
|
||||
) -> int | None:
|
||||
"""Reduce (end_dt, duration_minutes) inputs to a single canonical
|
||||
``duration_minutes`` value.
|
||||
|
||||
Resolution order:
|
||||
1. If ``duration_minutes`` is explicit, use it (validate >= 0).
|
||||
If ``end_dt`` is also given, validate the two agree.
|
||||
2. Otherwise, derive from ``end_dt - start_dt``.
|
||||
3. Otherwise None (point event with no end).
|
||||
|
||||
Raises ``ValueError`` for any invalid combination — duration < 0,
|
||||
end_dt < start_dt, or end_dt and duration_minutes inconsistent.
|
||||
"""
|
||||
if duration_minutes is not None:
|
||||
if duration_minutes < 0:
|
||||
raise ValueError(
|
||||
f"duration_minutes must be >= 0, got {duration_minutes}"
|
||||
)
|
||||
if end_dt is not None:
|
||||
expected = int((end_dt - start_dt).total_seconds() // 60)
|
||||
if expected != duration_minutes:
|
||||
raise ValueError(
|
||||
f"end_dt ({end_dt.isoformat()}) implies "
|
||||
f"{expected} minutes but duration_minutes={duration_minutes} "
|
||||
f"was passed; pass only one or make them agree."
|
||||
)
|
||||
return duration_minutes
|
||||
if end_dt is not None:
|
||||
delta_seconds = (end_dt - start_dt).total_seconds()
|
||||
if delta_seconds < 0:
|
||||
raise ValueError(
|
||||
f"end_dt ({end_dt.isoformat()}) must be at or after "
|
||||
f"start_dt ({start_dt.isoformat()}); pass end_dt=None "
|
||||
f"or omit it for point events."
|
||||
)
|
||||
return int(delta_seconds // 60)
|
||||
return None
|
||||
|
||||
|
||||
async def create_event(
|
||||
user_id: int,
|
||||
title: str,
|
||||
start_dt: datetime,
|
||||
end_dt: datetime | None = None,
|
||||
duration_minutes: int | None = None,
|
||||
all_day: bool = False,
|
||||
description: str = "",
|
||||
location: str = "",
|
||||
@@ -27,12 +81,25 @@ async def create_event(
|
||||
recurrence: str | None = None,
|
||||
project_id: int | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
# CalDAV-only fields (not stored in DB, forwarded to push)
|
||||
# ``duration`` is a legacy alias kept for the calendar tool layer
|
||||
# and CalDAV pass-through callers; promotes to duration_minutes
|
||||
# when duration_minutes isn't otherwise specified.
|
||||
duration: int | None = None,
|
||||
attendees: list[str] | None = None,
|
||||
calendar_name: str | None = None,
|
||||
) -> Event:
|
||||
"""Create an event in the DB, then fire a CalDAV push task."""
|
||||
"""Create an event in the DB, then fire a CalDAV push task.
|
||||
|
||||
Either ``end_dt`` or ``duration_minutes`` may be supplied; the
|
||||
service converts to ``duration_minutes`` internally. Raises
|
||||
``ValueError`` on invalid combinations (negative duration, end
|
||||
before start, end/duration disagreement).
|
||||
"""
|
||||
if duration is not None and duration_minutes is None:
|
||||
duration_minutes = duration
|
||||
duration_minutes = _normalize_duration(
|
||||
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
|
||||
)
|
||||
uid = str(uuid.uuid4())
|
||||
async with async_session() as session:
|
||||
event = Event(
|
||||
@@ -40,7 +107,7 @@ async def create_event(
|
||||
uid=uid,
|
||||
title=title,
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
duration_minutes=duration_minutes,
|
||||
all_day=all_day,
|
||||
description=description,
|
||||
location=location,
|
||||
@@ -54,7 +121,7 @@ async def create_event(
|
||||
await session.refresh(event)
|
||||
|
||||
extra_fields = {
|
||||
"duration": duration,
|
||||
"duration": duration_minutes,
|
||||
"reminder_minutes": reminder_minutes,
|
||||
"attendees": attendees,
|
||||
"calendar_name": calendar_name,
|
||||
@@ -80,66 +147,74 @@ async def list_events(
|
||||
"""List events for user_id that overlap [date_from, date_to].
|
||||
|
||||
Recurring events (with an RRULE recurrence string) are expanded into
|
||||
individual occurrences within the range. Non-recurring events are
|
||||
returned as-is. All results are sorted by start time and returned as
|
||||
dicts (same shape as Event.to_dict()).
|
||||
individual occurrences within the range. Non-recurring events are
|
||||
returned as-is. All results are sorted by start time and returned as
|
||||
dicts (same shape as ``Event.to_dict()``).
|
||||
|
||||
Filtering strategy: a coarse SQL prefilter (events that start on or
|
||||
before ``date_to``), then refine in Python using the event's derived
|
||||
end (``start_dt + duration_minutes``). Doing the end-of-event math
|
||||
in SQL would require Postgres-specific interval arithmetic; the
|
||||
Python-side refinement is a few row-loops over a small per-user
|
||||
result set, which is fine for personal-scale data and avoids
|
||||
coupling the query to a specific dialect.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
# Match strategy:
|
||||
# - Recurring events: fetch all, expand via rrule below.
|
||||
# - Non-recurring with an end_dt: standard overlap — starts before
|
||||
# date_to and ends after date_from.
|
||||
# - Non-recurring with no end_dt: treat as a point event at
|
||||
# start_dt, include only if start_dt falls within the window.
|
||||
# (Previously this branch matched any event with a null end_dt,
|
||||
# returning all past events as "happening today".)
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.user_id == user_id,
|
||||
or_(
|
||||
Event.recurrence.isnot(None),
|
||||
and_(
|
||||
Event.recurrence.is_(None),
|
||||
Event.start_dt <= date_to,
|
||||
or_(
|
||||
Event.end_dt >= date_from,
|
||||
and_(
|
||||
Event.end_dt.is_(None),
|
||||
Event.start_dt >= date_from,
|
||||
),
|
||||
),
|
||||
),
|
||||
Event.start_dt <= date_to,
|
||||
),
|
||||
).order_by(Event.start_dt)
|
||||
)
|
||||
.order_by(Event.start_dt)
|
||||
)
|
||||
events = list(result.scalars().all())
|
||||
|
||||
items: list[dict] = []
|
||||
for event in events:
|
||||
if not event.recurrence:
|
||||
items.append(event.to_dict())
|
||||
if event.recurrence:
|
||||
duration = (
|
||||
timedelta(minutes=event.duration_minutes)
|
||||
if event.duration_minutes is not None
|
||||
else None
|
||||
)
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occurrences = rule.between(date_from, date_to, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
||||
# Fall back to canonical event row; still apply the
|
||||
# window check so a far-future canonical row doesn't
|
||||
# leak into today's list.
|
||||
if date_from <= event.start_dt <= date_to:
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
base = event.to_dict()
|
||||
for occ in occurrences:
|
||||
if occ.tzinfo is None:
|
||||
occ = occ.replace(tzinfo=timezone.utc)
|
||||
occurrence_dict = dict(base)
|
||||
occurrence_dict["start_dt"] = occ.isoformat()
|
||||
if duration is not None:
|
||||
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
||||
items.append(occurrence_dict)
|
||||
continue
|
||||
|
||||
# Expand recurring event occurrences within [date_from, date_to]
|
||||
duration = (event.end_dt - event.start_dt) if event.end_dt else None
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occurrences = rule.between(date_from, date_to, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
base = event.to_dict()
|
||||
for occ in occurrences:
|
||||
# Ensure occurrence is UTC-aware
|
||||
if occ.tzinfo is None:
|
||||
occ = occ.replace(tzinfo=timezone.utc)
|
||||
occurrence_dict = dict(base)
|
||||
occurrence_dict["start_dt"] = occ.isoformat()
|
||||
if duration is not None:
|
||||
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
||||
items.append(occurrence_dict)
|
||||
# Non-recurring: refine the coarse prefilter in Python using the
|
||||
# derived end_dt. A point event (duration None) is included when
|
||||
# its start is at or after date_from. A timed event is included
|
||||
# when its end is at or after date_from.
|
||||
derived_end = event.end_dt
|
||||
if derived_end is None:
|
||||
if event.start_dt >= date_from:
|
||||
items.append(event.to_dict())
|
||||
else:
|
||||
if derived_end >= date_from:
|
||||
items.append(event.to_dict())
|
||||
|
||||
items.sort(key=lambda x: x["start_dt"])
|
||||
return items
|
||||
@@ -173,7 +248,13 @@ async def search_events(
|
||||
|
||||
|
||||
async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
"""Partial update. Returns updated event or None if not found."""
|
||||
"""Partial update. Returns updated event or None if not found.
|
||||
|
||||
Accepts ``end_dt`` or ``duration_minutes`` (or both, validated for
|
||||
agreement). The service converts to ``duration_minutes`` before
|
||||
persisting; ``end_dt`` is never stored. Raises ``ValueError`` for
|
||||
invalid combinations against the post-update state.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
||||
@@ -182,10 +263,39 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
if event is None:
|
||||
return None
|
||||
old_title = event.title # capture before mutation for CalDAV lookup
|
||||
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
|
||||
"location", "color", "recurrence", "project_id", "reminder_minutes"}
|
||||
# Nullable fields that callers can explicitly set to None to clear
|
||||
nullable = {"end_dt", "recurrence", "project_id", "reminder_minutes"}
|
||||
|
||||
# Resolve any end_dt/duration_minutes inputs against the
|
||||
# post-update start_dt. If neither is in the patch, leave the
|
||||
# existing duration_minutes alone.
|
||||
post_update_start = (
|
||||
fields["start_dt"]
|
||||
if fields.get("start_dt") is not None
|
||||
else event.start_dt
|
||||
)
|
||||
if "end_dt" in fields or "duration_minutes" in fields:
|
||||
new_end = fields.pop("end_dt", None)
|
||||
new_duration = fields.pop("duration_minutes", None)
|
||||
# If end_dt is in the patch but explicitly None, that's a
|
||||
# clear → duration_minutes = None. Same shape duration_minutes=None.
|
||||
if new_end is None and new_duration is None:
|
||||
fields["duration_minutes"] = None
|
||||
else:
|
||||
fields["duration_minutes"] = _normalize_duration(
|
||||
start_dt=post_update_start,
|
||||
end_dt=new_end,
|
||||
duration_minutes=new_duration,
|
||||
)
|
||||
|
||||
allowed = {
|
||||
"title", "start_dt", "duration_minutes", "all_day",
|
||||
"description", "location", "color", "recurrence",
|
||||
"project_id", "reminder_minutes",
|
||||
}
|
||||
# Nullable fields callers can explicitly clear by passing None
|
||||
nullable = {
|
||||
"duration_minutes", "recurrence", "project_id",
|
||||
"reminder_minutes",
|
||||
}
|
||||
for key, value in fields.items():
|
||||
if key in allowed and (value is not None or key in nullable):
|
||||
setattr(event, key, value)
|
||||
@@ -255,11 +365,12 @@ async def _push_create(event: Event, user_id: int, extra: dict) -> None:
|
||||
)
|
||||
if not await is_caldav_configured(user_id):
|
||||
return
|
||||
derived_end = event.end_dt # property: start + duration_minutes
|
||||
await caldav_create(
|
||||
user_id=user_id,
|
||||
title=event.title,
|
||||
start=event.start_dt.isoformat(),
|
||||
end=event.end_dt.isoformat() if event.end_dt else None,
|
||||
end=derived_end.isoformat() if derived_end else None,
|
||||
description=event.description or None,
|
||||
location=event.location or None,
|
||||
all_day=event.all_day,
|
||||
@@ -296,12 +407,13 @@ async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
|
||||
return
|
||||
# Use old_title so CalDAV can find the event even if the title was changed
|
||||
query_title = old_title or event.title
|
||||
derived_end = event.end_dt
|
||||
await caldav_update(
|
||||
user_id=user_id,
|
||||
query=query_title,
|
||||
title=event.title,
|
||||
start=event.start_dt.isoformat(),
|
||||
end=event.end_dt.isoformat() if event.end_dt else None,
|
||||
end=derived_end.isoformat() if derived_end else None,
|
||||
description=event.description or None,
|
||||
location=event.location or None,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Journal closeout — nightly extraction of profile observations.
|
||||
|
||||
Runs once per user per day at day_rollover_hour. Reads yesterday's /journal
|
||||
conversation, filters out assistant-authored auto-content (daily prep),
|
||||
asks the background LLM to extract user-side patterns/habits, and appends
|
||||
the bullets to user_profiles.observations_raw via append_observations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.services.user_profile import append_observations
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Message kinds whose content must NEVER be sent to the closeout LLM.
|
||||
# These are assistant-authored auto-blocks that would otherwise dominate
|
||||
# attention and leak back into "what the assistant has learned."
|
||||
EXCLUDED_KINDS: set[str] = {"daily_prep"}
|
||||
|
||||
|
||||
def _filter_messages(messages):
|
||||
"""Drop messages whose msg_metadata.kind is in EXCLUDED_KINDS.
|
||||
|
||||
Accepts any iterable of message-like objects with `role`, `content`,
|
||||
and `msg_metadata` attributes (real Message rows or SimpleNamespace).
|
||||
"""
|
||||
kept = []
|
||||
for m in messages:
|
||||
meta = getattr(m, "msg_metadata", None) or {}
|
||||
if meta.get("kind") in EXCLUDED_KINDS:
|
||||
continue
|
||||
kept.append(m)
|
||||
return kept
|
||||
|
||||
|
||||
_TRANSCRIPT_WINDOW = 20
|
||||
_CONTENT_CAP = 500
|
||||
|
||||
|
||||
def _build_transcript(messages) -> str:
|
||||
"""Format the last 20 messages as `ROLE: content[:500]` lines."""
|
||||
tail = list(messages)[-_TRANSCRIPT_WINDOW:]
|
||||
return "\n".join(
|
||||
f"{m.role.upper()}: {m.content[:_CONTENT_CAP]}" for m in tail
|
||||
)
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are reviewing a day's journal conversation to extract preference "
|
||||
"observations the USER revealed about themselves.\n\n"
|
||||
"Rules:\n"
|
||||
"- Only extract patterns, habits, recurring frustrations, or contextual "
|
||||
"facts the user said or demonstrated.\n"
|
||||
"- DO NOT restate facts that belong in structured fields: name, job title, "
|
||||
"industry, expertise level, response style, tone, interests. Those are "
|
||||
"handled separately.\n"
|
||||
"- DO NOT extract anything from the ASSISTANT turns about the user — only "
|
||||
"what the user themselves stated or demonstrated by their choices.\n"
|
||||
"- Write 2-5 short bullet points. Be specific and factual.\n"
|
||||
"- If nothing notable, output only: (nothing to note)"
|
||||
)
|
||||
|
||||
|
||||
async def run_for_user(user_id: int, yesterday: datetime.date) -> None:
|
||||
"""Extract preference observations from yesterday's journal conversation.
|
||||
|
||||
Skips silently when there is nothing meaningful to extract.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
conv_result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Conversation.day_date == yesterday,
|
||||
)
|
||||
)
|
||||
conv = conv_result.scalar_one_or_none()
|
||||
if conv is None:
|
||||
logger.debug("closeout: no journal conv for user %d on %s", user_id, yesterday)
|
||||
return
|
||||
|
||||
msg_result = await session.execute(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.conversation_id == conv.id,
|
||||
Message.role.in_(("user", "assistant")),
|
||||
or_(
|
||||
Message.msg_metadata.is_(None),
|
||||
~Message.msg_metadata["kind"].astext.in_(EXCLUDED_KINDS),
|
||||
),
|
||||
)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = list(msg_result.scalars().all())
|
||||
|
||||
# Defensive second-pass filter (covers any message with metadata the
|
||||
# SQL JSON path can't reach, e.g. older rows where kind nesting differs).
|
||||
messages = _filter_messages(messages)
|
||||
|
||||
if len(messages) < 2:
|
||||
logger.debug("closeout: not enough messages for user %d (%d)", user_id, len(messages))
|
||||
return
|
||||
|
||||
transcript = _build_transcript(messages)
|
||||
model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
|
||||
|
||||
try:
|
||||
output = (await generate_completion(
|
||||
[
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": transcript},
|
||||
],
|
||||
model,
|
||||
)).strip()
|
||||
except Exception:
|
||||
logger.warning("closeout LLM failed for user %d", user_id, exc_info=True)
|
||||
return
|
||||
|
||||
if not output or "(nothing to note)" in output.lower():
|
||||
logger.debug("closeout: nothing to note for user %d", user_id)
|
||||
return
|
||||
|
||||
await append_observations(user_id, output)
|
||||
logger.info("closeout: appended observations for user %d (%s)", user_id, yesterday)
|
||||
@@ -79,6 +79,15 @@ MOMENT ENTITY LINKING — be conservative.
|
||||
are NOT places — drop them and let the user name the real one if it
|
||||
matters.
|
||||
|
||||
EXISTING WORK — search before recording.
|
||||
- If the user describes ongoing or completed work that references a specific
|
||||
project or task by name or partial name (e.g. "the sebring task",
|
||||
"continuing on the AT&T circuit", "finished the auth refactor"), CALL
|
||||
search_notes FIRST to locate the existing task. Update its status or log
|
||||
work on it instead of recording a new moment when an obvious match exists.
|
||||
- Only call record_moment for that beat if no matching task surfaces and the
|
||||
user confirms they want a moment recorded.
|
||||
|
||||
WHEN LINKING ENTITIES: use the *_names parameters (person_names,
|
||||
place_names, task_titles, note_titles). Server resolves them to IDs by
|
||||
lookup. Do NOT pass *_ids unless you have an exact ID returned from
|
||||
|
||||
@@ -178,7 +178,15 @@ async def gather_daily_sections(
|
||||
sections["events"] = []
|
||||
|
||||
try:
|
||||
weather_rows = await get_cached_weather_rows(user_id)
|
||||
# Lazy import: journal_scheduler imports this module for prep generation,
|
||||
# so a top-level import would cycle.
|
||||
from fabledassistant.services.journal_scheduler import get_journal_config
|
||||
cfg = await get_journal_config(user_id)
|
||||
valid_weather_keys = {
|
||||
key for key, loc in (cfg.get("locations") or {}).items()
|
||||
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
|
||||
}
|
||||
weather_rows = await get_cached_weather_rows(user_id, valid_weather_keys)
|
||||
sections["weather"] = [w.to_dict() for w in weather_rows]
|
||||
except Exception:
|
||||
logger.exception("daily_prep weather section failed for user %d", user_id)
|
||||
|
||||
@@ -35,6 +35,7 @@ DEFAULT_CONFIG = {
|
||||
"day_rollover_hour": 4,
|
||||
"morning_end_hour": 12,
|
||||
"midday_end_hour": 18,
|
||||
"closeout_enabled": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -88,30 +89,96 @@ def _run_daily_prep_threadsafe(user_id: int) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop)
|
||||
|
||||
|
||||
async def _do_closeout(user_id: int) -> None:
|
||||
try:
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
now = datetime.datetime.now(tz)
|
||||
# We just rolled into a new day in user-local time. The day that
|
||||
# just ended is yesterday's calendar date regardless of whether
|
||||
# rollover_hour is 0 or 4 — APScheduler fires precisely at the
|
||||
# configured hour so no clock-skew correction is needed.
|
||||
yesterday = now.date() - datetime.timedelta(days=1)
|
||||
from fabledassistant.services.journal_closeout import run_for_user
|
||||
await run_for_user(user_id=user_id, yesterday=yesterday)
|
||||
except Exception:
|
||||
logger.exception("Closeout failed for user %d", user_id)
|
||||
|
||||
|
||||
def _run_closeout_threadsafe(user_id: int) -> None:
|
||||
if _loop is None:
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(_do_closeout(user_id), _loop)
|
||||
|
||||
|
||||
async def _closeout_catchup(user_id: int) -> None:
|
||||
"""On startup, run yesterday's closeout once if the slot already passed
|
||||
and no entry for yesterday exists in observations_raw.
|
||||
"""
|
||||
try:
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
config = await get_journal_config(user_id)
|
||||
if not config.get("closeout_enabled", True):
|
||||
return
|
||||
rollover_hour = int(config.get("day_rollover_hour", 4))
|
||||
now = datetime.datetime.now(tz)
|
||||
# Slot hasn't passed yet today → wait for the cron.
|
||||
if now.hour < rollover_hour:
|
||||
return
|
||||
yesterday = (now - datetime.timedelta(days=1)).date()
|
||||
|
||||
from fabledassistant.services.user_profile import get_profile
|
||||
profile = await get_profile(user_id)
|
||||
existing_dates = {
|
||||
(e or {}).get("date") for e in (profile.observations_raw or [])
|
||||
}
|
||||
if yesterday.isoformat() in existing_dates:
|
||||
return
|
||||
|
||||
from fabledassistant.services.journal_closeout import run_for_user
|
||||
await run_for_user(user_id=user_id, yesterday=yesterday)
|
||||
except Exception:
|
||||
logger.exception("Closeout catch-up failed for user %d", user_id)
|
||||
|
||||
|
||||
async def update_user_schedule(user_id: int) -> None:
|
||||
"""Add or replace this user's daily-prep job using their current config."""
|
||||
"""Add or replace this user's daily-prep + closeout jobs from current config."""
|
||||
if _scheduler is None:
|
||||
return
|
||||
job_id = f"journal_prep_{user_id}"
|
||||
if _scheduler.get_job(job_id):
|
||||
_scheduler.remove_job(job_id)
|
||||
|
||||
config = await get_journal_config(user_id)
|
||||
if not config.get("prep_enabled", True):
|
||||
return
|
||||
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
prep_hour = int(config.get("prep_hour", 5))
|
||||
prep_minute = int(config.get("prep_minute", 0))
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_daily_prep_threadsafe,
|
||||
trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz),
|
||||
args=[user_id],
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
# ── Prep job ──────────────────────────────────────────────────────────
|
||||
prep_job_id = f"journal_prep_{user_id}"
|
||||
if _scheduler.get_job(prep_job_id):
|
||||
_scheduler.remove_job(prep_job_id)
|
||||
if config.get("prep_enabled", True):
|
||||
prep_hour = int(config.get("prep_hour", 5))
|
||||
prep_minute = int(config.get("prep_minute", 0))
|
||||
_scheduler.add_job(
|
||||
_run_daily_prep_threadsafe,
|
||||
trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz),
|
||||
args=[user_id],
|
||||
id=prep_job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# ── Closeout job ──────────────────────────────────────────────────────
|
||||
closeout_job_id = f"journal_closeout_{user_id}"
|
||||
if _scheduler.get_job(closeout_job_id):
|
||||
_scheduler.remove_job(closeout_job_id)
|
||||
if config.get("closeout_enabled", True):
|
||||
rollover_hour = int(config.get("day_rollover_hour", 4))
|
||||
_scheduler.add_job(
|
||||
_run_closeout_threadsafe,
|
||||
trigger=CronTrigger(hour=rollover_hour, minute=0, timezone=tz),
|
||||
args=[user_id],
|
||||
id=closeout_job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
|
||||
async def _register_all_user_jobs() -> None:
|
||||
@@ -119,6 +186,9 @@ async def _register_all_user_jobs() -> None:
|
||||
users = (await session.execute(select(User))).scalars().all()
|
||||
for user in users:
|
||||
await update_user_schedule(user.id)
|
||||
# Fire catch-up asynchronously so a slow LLM call doesn't block startup
|
||||
if _loop is not None:
|
||||
asyncio.run_coroutine_threadsafe(_closeout_catchup(user.id), _loop)
|
||||
|
||||
|
||||
def start_journal_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
|
||||
@@ -620,6 +620,7 @@ async def build_context(
|
||||
"CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.",
|
||||
"GROUNDING: When the user asks about their own data — tasks, notes, events, projects, news, anything stored in this system — call the relevant tool to see what actually exists before answering. Never assert facts about the user's data from memory, prior context, or assumption. If you are unsure whether something exists, check with a tool.",
|
||||
"HONESTY WHEN EMPTY: If a tool returns empty results (no matching tasks, no events in the date range, no search hits, no notes found), tell the user plainly that nothing matched. Do not fabricate example items, do not invent plausible-sounding meetings or deadlines to fill the response, and do not hedge with generic suggestions dressed up as real data. A direct 'you don't have anything on your calendar today' is always better than an invented event.",
|
||||
"EXISTING WORK: When the user describes ongoing or completed work that references a specific project or task by name or partial name, call search_notes first to locate the existing item. Only call record_moment, create_task, or create_note if no matching task surfaces and the user confirms.",
|
||||
]
|
||||
actions = [
|
||||
"create_note (also creates tasks — set status='todo')", "update_note", "delete_note",
|
||||
|
||||
@@ -22,6 +22,17 @@ def _normalize_tags(tags: list[str]) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
# Type-nouns the LLM tends to include in search queries. Treating them as
|
||||
# required ILIKE terms drops literal-title matches; we strip them server-side
|
||||
# and let the `type` / `project` parameters scope results instead.
|
||||
_SEARCH_TYPE_NOUNS = {"task", "tasks", "note", "notes", "project", "projects"}
|
||||
|
||||
|
||||
def _strip_type_nouns(q: str) -> list[str]:
|
||||
"""Return q's tokens with type-nouns removed (case-insensitive)."""
|
||||
return [t for t in q.split() if t.lower() not in _SEARCH_TYPE_NOUNS]
|
||||
|
||||
|
||||
async def _maybe_reactivate_project(project_id: int) -> None:
|
||||
"""If a project is paused, reactivate it — activity indicates resumed work."""
|
||||
from fabledassistant.models.project import Project
|
||||
@@ -155,7 +166,7 @@ async def list_notes(
|
||||
count_query = count_query.where(Note.status.is_(None))
|
||||
|
||||
if q:
|
||||
terms = q.split()
|
||||
terms = _strip_type_nouns(q)
|
||||
for term in terms:
|
||||
escaped_term = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
pattern = f"%{escaped_term}%"
|
||||
|
||||
@@ -22,31 +22,71 @@ def schedule_embedding(note_id: int, user_id: int, title: str, body: str) -> Non
|
||||
asyncio.create_task(upsert_note_embedding(note_id, user_id, text))
|
||||
|
||||
|
||||
_PROJECT_QUERY_NOISE = {"project", "projects"}
|
||||
|
||||
|
||||
def _normalize(s: str) -> str:
|
||||
"""Lowercase and collapse non-alphanumerics to single spaces."""
|
||||
return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()
|
||||
|
||||
|
||||
def _normalize_query(query: str) -> str:
|
||||
"""Normalize plus drop trailing type-nouns ('project' / 'projects')
|
||||
that users add as filler when referring to a project by name."""
|
||||
tokens = [t for t in _normalize(query).split() if t not in _PROJECT_QUERY_NOISE]
|
||||
return " ".join(tokens)
|
||||
|
||||
|
||||
def score_project_match(query: str, project) -> float:
|
||||
"""Score how well `query` matches `project`. Range [0.0, 1.0].
|
||||
|
||||
Tiered: exact title → 1.0, substring either-way → 0.85, query found in
|
||||
description/summary → 0.70, otherwise SequenceMatcher ratio against the
|
||||
title. Substring tiers exist because LLM-generated colloquial queries
|
||||
(e.g. "famous supply project" for "Famous-Supply Work topics") would
|
||||
otherwise score too low under pure SequenceMatcher and be treated as
|
||||
no match. Filler words like "project" are stripped from the query so
|
||||
the substring check still fires.
|
||||
"""
|
||||
q = _normalize_query(query)
|
||||
if not q:
|
||||
return 0.0
|
||||
title = _normalize(project.title)
|
||||
description = _normalize(project.description or "")
|
||||
summary = _normalize(project.auto_summary or "")
|
||||
combined = f"{title} {description} {summary}".strip()
|
||||
|
||||
if q == title:
|
||||
return 1.0
|
||||
if q in title or title in q:
|
||||
return 0.85
|
||||
if q in combined:
|
||||
return 0.70
|
||||
# SequenceMatcher against the title — comparing against `combined`
|
||||
# dilutes the ratio with long description/summary text and produces
|
||||
# uniformly low scores even for plausible matches.
|
||||
return SequenceMatcher(None, q, title).ratio()
|
||||
|
||||
|
||||
async def resolve_project(user_id: int, project_name: str):
|
||||
"""Exact-then-fuzzy project lookup. Returns the Project or None.
|
||||
"""Exact-then-scored 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
|
||||
3. Existing title is a substring of project_name
|
||||
4. SequenceMatcher ratio >= 0.55
|
||||
1. Exact title match (case-insensitive via DB query).
|
||||
2. Highest `score_project_match` across all projects, threshold 0.55.
|
||||
"""
|
||||
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)
|
||||
best, best_score = None, 0.0
|
||||
for p in all_p:
|
||||
haystack = p.title.lower().strip()
|
||||
if needle in haystack or haystack in needle:
|
||||
return p
|
||||
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
|
||||
score = score_project_match(project_name, p)
|
||||
if score >= 0.55 and score > best_score:
|
||||
best, best_score = p, score
|
||||
return best
|
||||
|
||||
|
||||
|
||||
@@ -108,6 +108,88 @@ async def _resolve_event_end(
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_summary(event) -> dict:
|
||||
"""Compact event summary used in ambiguous-match responses.
|
||||
|
||||
Keeps the candidate list small so the model can disambiguate from
|
||||
the same turn without bloating context. Includes id (for the
|
||||
follow-up call), title, start_dt, and location when present.
|
||||
"""
|
||||
return {
|
||||
"id": event.id,
|
||||
"title": event.title,
|
||||
"start_dt": event.start_dt.isoformat() if event.start_dt else None,
|
||||
"location": event.location or None,
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_event_for_action(
|
||||
*, user_id: int, arguments: dict, action: str,
|
||||
):
|
||||
"""Pick the single event the model intends to update or delete.
|
||||
|
||||
Resolution rules:
|
||||
- ``event_id`` in arguments → exact lookup (skip query). Used by
|
||||
the model to disambiguate after a multi-match refusal.
|
||||
- else ``query`` → ``find_events_by_query``:
|
||||
- 0 results → return error tuple ("not_found", ...)
|
||||
- 1 result → return that event
|
||||
- 2+ results → return ("ambiguous", error, candidates) so the
|
||||
caller can refuse the call and show candidates to the model.
|
||||
|
||||
Returns either an Event (success) or a 2- or 3-tuple of
|
||||
``(error_kind, error_dict)`` for the caller to translate into a
|
||||
tool-call response.
|
||||
"""
|
||||
from fabledassistant.services.events import get_event
|
||||
|
||||
event_id = arguments.get("event_id")
|
||||
if event_id is not None:
|
||||
try:
|
||||
event_id_int = int(event_id)
|
||||
except (TypeError, ValueError):
|
||||
return ("invalid_id", {
|
||||
"success": False,
|
||||
"error": f"event_id must be an integer; got {event_id!r}.",
|
||||
})
|
||||
ev = await get_event(user_id=user_id, event_id=event_id_int)
|
||||
if ev is None:
|
||||
return ("not_found", {
|
||||
"success": False,
|
||||
"error": f"No event found with id={event_id_int}.",
|
||||
})
|
||||
return ev
|
||||
|
||||
query = arguments.get("query", "")
|
||||
if not query:
|
||||
return ("invalid_query", {
|
||||
"success": False,
|
||||
"error": "Either query or event_id is required.",
|
||||
})
|
||||
matches = await find_events_by_query(user_id=user_id, query=query)
|
||||
if not matches:
|
||||
return ("not_found", {
|
||||
"success": False,
|
||||
"error": f"No event found matching {query!r}.",
|
||||
})
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
# Multi-match: refuse and surface candidates so the model can
|
||||
# disambiguate via event_id on the next call. Prevents the silent-
|
||||
# picks-matches[0] failure mode that mutated the wrong event in the
|
||||
# 2026-04-29 dentist-appointment incident (Fable #161).
|
||||
return ("ambiguous", {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Found {len(matches)} events matching {query!r}. "
|
||||
f"Pick one by passing `event_id` instead of `query`, "
|
||||
f"or refine the search term to match a single event."
|
||||
),
|
||||
"action": action,
|
||||
"candidates": [_candidate_summary(m) for m in matches[:8]],
|
||||
})
|
||||
|
||||
|
||||
def _validate_weekday(start_dt_utc: datetime, user_tz, expected: str | None) -> str | None:
|
||||
"""Verify the resolved local date falls on the expected day of the week.
|
||||
|
||||
@@ -149,7 +231,17 @@ def _validate_weekday(start_dt_utc: datetime, user_tz, expected: str | None) ->
|
||||
"When the user names a weekday ('this Friday', 'next Monday'), "
|
||||
"state the resolved calendar date in your reply BEFORE calling "
|
||||
"this tool, and pass `expected_weekday` so the server can verify "
|
||||
"the date falls on the day you intended."
|
||||
"the date falls on the day you intended.\n\n"
|
||||
"DON'T call this tool with placeholder values. If the user "
|
||||
"mentions an event without giving you concrete details (a real "
|
||||
"title, a specific time, and location when it applies), record "
|
||||
"a moment instead and ask for the missing pieces — do NOT create "
|
||||
"an event with a stand-in title like 'Appointment', 'Meeting', "
|
||||
"or 'Event' and a description that says 'details TBD'. Wait for "
|
||||
"the user's reply, then call create_event ONCE with the actual "
|
||||
"title, time, and location. Premature placeholder events pollute "
|
||||
"the calendar and require an immediate update_event to fix — "
|
||||
"both visible to the user, neither what they asked for."
|
||||
),
|
||||
parameters={
|
||||
"title": {"type": "string", "description": "A descriptive event title"},
|
||||
@@ -295,10 +387,17 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
|
||||
"When the user names a weekday ('move to Friday'), state the "
|
||||
"resolved calendar date in your reply BEFORE calling this tool, "
|
||||
"and pass `expected_weekday` so the server can verify the date "
|
||||
"falls on the day you intended."
|
||||
"falls on the day you intended.\n\n"
|
||||
"Identify the event with EITHER `query` (a title substring) OR "
|
||||
"`event_id` (when you already have an exact id from a prior tool "
|
||||
"result). If `query` matches multiple events, the tool returns "
|
||||
"an ambiguity error with a candidate list — pick one by passing "
|
||||
"its `event_id` on the next call, or refine the query so it "
|
||||
"matches a single event."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "Search term to find the event to update (matches against title)"},
|
||||
"query": {"type": "string", "description": "Search term to find the event to update (matches against title). Required unless event_id is set."},
|
||||
"event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."},
|
||||
"title": {"type": "string", "description": "New title for the event"},
|
||||
"start_date": {"type": "string", "description": "New start calendar date as YYYY-MM-DD in the user's local time. No timezone suffix."},
|
||||
"start_time": {"type": "string", "description": "New start wall-clock time as HH:MM. No timezone suffix."},
|
||||
@@ -315,14 +414,15 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
|
||||
"start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."},
|
||||
"end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."},
|
||||
},
|
||||
required=["query"],
|
||||
required=[],
|
||||
)
|
||||
async def update_event_tool(*, user_id, arguments, **_ctx):
|
||||
query = arguments.get("query", "")
|
||||
matches = await find_events_by_query(user_id=user_id, query=query)
|
||||
if not matches:
|
||||
return {"success": False, "error": f"No event found matching '{query}'."}
|
||||
event_to_update = matches[0]
|
||||
resolved = await _resolve_event_for_action(
|
||||
user_id=user_id, arguments=arguments, action="update",
|
||||
)
|
||||
if isinstance(resolved, tuple):
|
||||
return resolved[1] # error dict from the resolver
|
||||
event_to_update = resolved
|
||||
fields: dict = {}
|
||||
for str_field in ("title", "description", "location", "color", "recurrence"):
|
||||
if arguments.get(str_field) is not None:
|
||||
@@ -358,18 +458,29 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
|
||||
|
||||
@tool(
|
||||
name="delete_event",
|
||||
description="Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.",
|
||||
description=(
|
||||
"Delete a calendar event. Use this when the user asks to cancel, "
|
||||
"remove, or delete an event. Identify the event with EITHER "
|
||||
"`query` (a title substring) OR `event_id` (when you have an "
|
||||
"exact id). If `query` matches multiple events, the tool returns "
|
||||
"an ambiguity error with a candidate list — pick one by passing "
|
||||
"its `event_id` on the next call, or refine the query so it "
|
||||
"matches a single event. Deleting the wrong event is a costly "
|
||||
"user error; never guess between candidates."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "Search term to find the event to delete (matches against title)"},
|
||||
"query": {"type": "string", "description": "Search term to find the event to delete (matches against title). Required unless event_id is set."},
|
||||
"event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."},
|
||||
},
|
||||
required=["query"],
|
||||
required=[],
|
||||
)
|
||||
async def delete_event_tool(*, user_id, arguments, **_ctx):
|
||||
query = arguments.get("query", "")
|
||||
matches = await find_events_by_query(user_id=user_id, query=query)
|
||||
if not matches:
|
||||
return {"success": False, "error": f"No event found matching '{query}'."}
|
||||
event_to_delete = matches[0]
|
||||
resolved = await _resolve_event_for_action(
|
||||
user_id=user_id, arguments=arguments, action="delete",
|
||||
)
|
||||
if isinstance(resolved, tuple):
|
||||
return resolved[1]
|
||||
event_to_delete = resolved
|
||||
await events_delete_event(user_id=user_id, event_id=event_to_delete.id)
|
||||
return {"success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}}
|
||||
|
||||
|
||||
@@ -165,7 +165,11 @@ async def _resolve_entity_ids_by_name(
|
||||
"STRONGLY PREFER the *_names parameters when linking entities — the server "
|
||||
"resolves names to IDs by lookup, so you cannot accidentally invent or "
|
||||
"re-use the wrong ID. Use *_ids only when you have an exact ID returned "
|
||||
"from another tool call in this same turn."
|
||||
"from another tool call in this same turn. "
|
||||
"`task_titles` and `note_titles` must be exact titles returned by a prior "
|
||||
"search_notes call in this same turn. Do NOT pass user-typed phrases, "
|
||||
"project names, or invented titles. If you have not searched yet, call "
|
||||
"search_notes first."
|
||||
),
|
||||
parameters={
|
||||
"content": {
|
||||
|
||||
@@ -235,7 +235,13 @@ async def update_note_tool(*, user_id, arguments, **_ctx):
|
||||
|
||||
@tool(
|
||||
name="search_notes",
|
||||
description="Find notes or tasks by meaning. Returns a ranked list of matches with short previews. Use this when looking for items on a topic but you don't know the exact title. For the full body of a known item, use read_note instead.",
|
||||
description=(
|
||||
"Find notes or tasks by meaning. Returns a ranked list of matches with "
|
||||
"short previews. Use this when looking for items on a topic but you "
|
||||
"don't know the exact title. For the full body of a known item, use "
|
||||
"read_note instead. Do not include 'task', 'note', or 'project' in the "
|
||||
"`query` — use the `type` and `project` parameters instead."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "A natural-language description of what you're looking for — concepts, themes, topics, or keywords"},
|
||||
"type": {"type": "string", "enum": ["note", "task"], "description": "Restrict results to only notes or only tasks. Omit to search both."},
|
||||
|
||||
@@ -135,20 +135,12 @@ async def update_project_tool(*, user_id, arguments, **_ctx):
|
||||
briefing=True,
|
||||
)
|
||||
async def search_projects_tool(*, user_id, arguments, **_ctx):
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from fabledassistant.services.projects import list_projects
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
|
||||
query = str(arguments.get("query", "")).lower()
|
||||
query = str(arguments.get("query", ""))
|
||||
projects = await list_projects(user_id)
|
||||
scored: list[tuple[float, object]] = []
|
||||
for p in projects:
|
||||
combined = f"{p.title} {p.description or ''} {p.auto_summary or ''}".lower()
|
||||
base_score = SequenceMatcher(None, query, combined).ratio()
|
||||
query_words = set(query.split())
|
||||
overlap = sum(1 for w in query_words if w in combined)
|
||||
score = base_score + overlap * 0.05
|
||||
scored.append((score, p))
|
||||
scored: list[tuple[float, object]] = [(score_project_match(query, p), p) for p in projects]
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
results = []
|
||||
for score, p in scored[:5]:
|
||||
@@ -158,7 +150,7 @@ async def search_projects_tool(*, user_id, arguments, **_ctx):
|
||||
"summary_snippet": (p.auto_summary or p.description or "")[:200],
|
||||
"score": round(score, 3),
|
||||
})
|
||||
return {"type": "projects_list", "data": {"projects": results}}
|
||||
return {"success": True, "type": "projects_list", "data": {"projects": results}}
|
||||
|
||||
|
||||
@tool(
|
||||
|
||||
@@ -232,12 +232,23 @@ def parse_weather_card_data(
|
||||
}
|
||||
|
||||
|
||||
async def get_cached_weather_rows(user_id: int) -> list:
|
||||
"""Return raw WeatherCache ORM rows for a user (for card parsing)."""
|
||||
async def get_cached_weather_rows(
|
||||
user_id: int,
|
||||
valid_keys: set[str] | None = None,
|
||||
) -> list:
|
||||
"""Return raw WeatherCache ORM rows for a user (for card parsing).
|
||||
|
||||
If ``valid_keys`` is provided, only rows whose ``location_key`` is in the
|
||||
set are returned. This is how callers drop orphaned cache rows whose
|
||||
location is no longer in the user's ``journal_config.locations`` (e.g.
|
||||
leftovers from the briefing era, or a location that's been removed).
|
||||
Passing an empty set returns no rows.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WeatherCache).where(WeatherCache.user_id == user_id)
|
||||
)
|
||||
stmt = select(WeatherCache).where(WeatherCache.user_id == user_id)
|
||||
if valid_keys is not None:
|
||||
stmt = stmt.where(WeatherCache.location_key.in_(list(valid_keys)))
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
|
||||
@@ -577,6 +577,188 @@ async def test_update_event_expected_weekday_mismatch_rejects():
|
||||
assert update_called is False
|
||||
|
||||
|
||||
# ── Multi-match disambiguation (Fable #161) ──────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_refuses_ambiguous_query_with_candidates():
|
||||
"""The reported failure: model called update_event(query='Appointment')
|
||||
when two events matched. Pre-fix: silently mutated matches[0] (the
|
||||
wrong event). Post-fix: returns success=False with a candidates list
|
||||
so the model can pick the right one."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
# Two events both matching "Appointment"
|
||||
ev_a = AsyncMock()
|
||||
ev_a.id = 2
|
||||
ev_a.title = "Appointment"
|
||||
ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
|
||||
ev_a.location = ""
|
||||
ev_b = AsyncMock()
|
||||
ev_b.id = 15
|
||||
ev_b.title = "Appointment"
|
||||
ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
ev_b.location = "Dentist"
|
||||
|
||||
update_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
return [ev_a, ev_b]
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
nonlocal update_called
|
||||
update_called = True
|
||||
return AsyncMock()
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_update_event",
|
||||
side_effect=fake_update,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={"query": "Appointment", "title": "Dentist"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Found 2 events" in result["error"]
|
||||
assert "event_id" in result["error"].lower()
|
||||
assert "candidates" in result
|
||||
candidate_ids = [c["id"] for c in result["candidates"]]
|
||||
assert candidate_ids == [2, 15]
|
||||
# Critical: nothing was mutated.
|
||||
assert update_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_with_event_id_skips_query_lookup():
|
||||
"""Once the model picks a candidate via event_id, the call proceeds
|
||||
against that exact event — no query, no ambiguity."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
# find_events_by_query should NOT be called when event_id is supplied
|
||||
find_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
nonlocal find_called
|
||||
find_called = True
|
||||
return []
|
||||
|
||||
target = AsyncMock()
|
||||
target.id = 15
|
||||
target.title = "Appointment"
|
||||
target.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
target.location = ""
|
||||
|
||||
async def fake_get_event(*, user_id, event_id):
|
||||
return target if event_id == 15 else None
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
captured["event_id"] = event_id
|
||||
captured.update(fields)
|
||||
ev = AsyncMock()
|
||||
ev.to_dict.return_value = {"id": event_id, **fields}
|
||||
return ev
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.events.get_event",
|
||||
side_effect=fake_get_event,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_update_event",
|
||||
side_effect=fake_update,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={"event_id": 15, "title": "Dentist: Permanent Crown Fitting"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["event_id"] == 15
|
||||
assert captured["title"] == "Dentist: Permanent Crown Fitting"
|
||||
assert find_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_with_event_id_not_found_returns_error():
|
||||
"""A bad event_id (non-existent) must surface clearly, not silently
|
||||
fall back to query-based lookup."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
async def fake_get_event(*, user_id, event_id):
|
||||
return None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.events.get_event",
|
||||
side_effect=fake_get_event,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={"event_id": 999, "title": "anything"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "999" in result["error"]
|
||||
assert "No event found" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_neither_query_nor_event_id_returns_error():
|
||||
"""Calling update_event with neither identifier is a usage error."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
result = await update_event_tool(user_id=1, arguments={"title": "x"})
|
||||
assert result["success"] is False
|
||||
assert "query or event_id" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_event_refuses_ambiguous_query_with_candidates():
|
||||
"""delete_event must enforce the same disambiguation. Costly to get
|
||||
wrong — silent matches[0] would delete the wrong event entirely."""
|
||||
from fabledassistant.services.tools.calendar import delete_event_tool
|
||||
|
||||
ev_a = AsyncMock()
|
||||
ev_a.id = 2; ev_a.title = "Appointment"
|
||||
ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
|
||||
ev_a.location = ""
|
||||
ev_b = AsyncMock()
|
||||
ev_b.id = 15; ev_b.title = "Appointment"
|
||||
ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
ev_b.location = "Dentist"
|
||||
|
||||
delete_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
return [ev_a, ev_b]
|
||||
|
||||
async def fake_delete(*, user_id, event_id):
|
||||
nonlocal delete_called
|
||||
delete_called = True
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_delete_event",
|
||||
side_effect=fake_delete,
|
||||
):
|
||||
result = await delete_event_tool(
|
||||
user_id=1, arguments={"query": "Appointment"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert len(result["candidates"]) == 2
|
||||
# Most important assertion: nothing was actually deleted.
|
||||
assert delete_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_weekday_check_uses_local_not_utc():
|
||||
"""The weekday check must use the LOCAL date, not the UTC date.
|
||||
|
||||
@@ -14,7 +14,7 @@ def _make_mock_session():
|
||||
|
||||
|
||||
def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
|
||||
caldav_uid="", color=""):
|
||||
caldav_uid="", color="", duration_minutes=60):
|
||||
e = MagicMock()
|
||||
e.id = id
|
||||
e.user_id = user_id
|
||||
@@ -23,7 +23,14 @@ def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
|
||||
e.caldav_uid = caldav_uid
|
||||
e.color = color
|
||||
e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc)
|
||||
e.end_dt = datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc)
|
||||
e.duration_minutes = duration_minutes
|
||||
# end_dt is derived; mirror the property's behavior on the mock so
|
||||
# service code that reads `event.end_dt` gets a sensible value.
|
||||
if duration_minutes is None:
|
||||
e.end_dt = None
|
||||
else:
|
||||
from datetime import timedelta
|
||||
e.end_dt = e.start_dt + timedelta(minutes=duration_minutes)
|
||||
e.all_day = False
|
||||
e.description = ""
|
||||
e.location = ""
|
||||
@@ -32,8 +39,9 @@ def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
|
||||
e.to_dict.return_value = {
|
||||
"id": id, "uid": uid, "title": title,
|
||||
"caldav_uid": caldav_uid, "color": color,
|
||||
"start_dt": datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc).isoformat(),
|
||||
"end_dt": datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc).isoformat(),
|
||||
"start_dt": e.start_dt.isoformat(),
|
||||
"end_dt": e.end_dt.isoformat() if e.end_dt else None,
|
||||
"duration_minutes": duration_minutes,
|
||||
}
|
||||
return e
|
||||
|
||||
@@ -124,6 +132,195 @@ async def test_update_event_fires_caldav_push():
|
||||
assert mock_task.called
|
||||
|
||||
|
||||
# ── Duration-model write-side guarantees (Fable #160) ─────────────────────────
|
||||
|
||||
|
||||
def test_normalize_duration_from_end_dt():
|
||||
"""end_dt sugar converts to a positive minute count anchored on start."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 5, 1, 9, 30, tzinfo=timezone.utc)
|
||||
assert _normalize_duration(start_dt=start, end_dt=end, duration_minutes=None) == 90
|
||||
|
||||
|
||||
def test_normalize_duration_zero_is_valid_point_event():
|
||||
"""end_dt == start_dt → duration 0. The point-with-zero-duration case
|
||||
is rare but legal (e.g. an instant marker); the duration model treats
|
||||
it the same as duration None for display purposes."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
same = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
|
||||
assert _normalize_duration(start_dt=same, end_dt=same, duration_minutes=None) == 0
|
||||
|
||||
|
||||
def test_normalize_duration_rejects_end_before_start():
|
||||
"""The exact 2026-04-29 prod failure: end 32 days before start.
|
||||
The duration model makes this inexpressible at the schema level
|
||||
via a CHECK constraint, but write-path callers still get a
|
||||
helpful ValueError if they construct an inconsistent (start, end)
|
||||
pair via the end_dt sugar."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
|
||||
with pytest.raises(ValueError, match="at or after start_dt"):
|
||||
_normalize_duration(
|
||||
start_dt=start, end_dt=end_before, duration_minutes=None,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_duration_rejects_negative_duration():
|
||||
"""Direct duration_minutes < 0 is rejected. Mirrors the DB CHECK
|
||||
constraint at the service boundary so callers get a clean error
|
||||
rather than a constraint violation from psycopg."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
with pytest.raises(ValueError, match="must be >= 0"):
|
||||
_normalize_duration(start_dt=start, end_dt=None, duration_minutes=-15)
|
||||
|
||||
|
||||
def test_normalize_duration_rejects_inconsistent_end_and_duration():
|
||||
"""If a caller passes both end_dt AND duration_minutes that disagree,
|
||||
the inconsistency is surfaced rather than silently picking one."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 5, 1, 13, 0, tzinfo=timezone.utc) # implies 60 min
|
||||
with pytest.raises(ValueError, match="implies 60 minutes"):
|
||||
_normalize_duration(
|
||||
start_dt=start, end_dt=end, duration_minutes=30,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_duration_none_for_open_ended():
|
||||
"""Both inputs None → None duration (open-ended event)."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
assert _normalize_duration(
|
||||
start_dt=start, end_dt=None, duration_minutes=None,
|
||||
) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_rejects_end_before_start():
|
||||
"""Service-level rejection — same scenario as the prod bug, surfaced
|
||||
cleanly for tool / route callers via ValueError."""
|
||||
from fabledassistant.services.events import create_event
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
|
||||
with pytest.raises(ValueError, match="at or after start_dt"):
|
||||
await create_event(
|
||||
user_id=1, title="Bad",
|
||||
start_dt=start, end_dt=end_before,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_preserves_duration_when_only_start_changes():
|
||||
"""Sliding semantics: when the user moves an event by changing only
|
||||
start_dt, the existing duration_minutes is preserved as-is. The new
|
||||
effective end_dt slides forward with the start. This is a behavioral
|
||||
upgrade vs. the old end_dt model, where moving start past the
|
||||
stored end made the event 'go backward in time'."""
|
||||
mock_event = _make_mock_event(duration_minutes=60) # start 10:00, end 11:00
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_event
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("fabledassistant.services.events.async_session") as mock_cls, \
|
||||
patch("fabledassistant.services.events.asyncio.create_task"):
|
||||
mock_cls.return_value = mock_session
|
||||
from fabledassistant.services.events import update_event
|
||||
# Move start to 12:00; effective end becomes 13:00 automatically.
|
||||
result = await update_event(
|
||||
user_id=1, event_id=1,
|
||||
start_dt=datetime(2026, 3, 25, 12, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
assert result is not None
|
||||
# duration_minutes was NOT touched; mock_event still has 60.
|
||||
assert mock_event.duration_minutes == 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_clearing_end_dt_clears_duration():
|
||||
"""Passing end_dt=None on update is the documented way to clear the
|
||||
end (turn a timed event into a point event). The service must
|
||||
translate that into duration_minutes=None, not leave the prior
|
||||
value in place."""
|
||||
mock_event = _make_mock_event(duration_minutes=60)
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_event
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("fabledassistant.services.events.async_session") as mock_cls, \
|
||||
patch("fabledassistant.services.events.asyncio.create_task"):
|
||||
mock_cls.return_value = mock_session
|
||||
from fabledassistant.services.events import update_event
|
||||
await update_event(user_id=1, event_id=1, end_dt=None)
|
||||
assert mock_event.duration_minutes is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_includes_point_event_in_window():
|
||||
"""A point event (duration_minutes=None) surfaces when its start
|
||||
is in the window. Replaces the prior 'corrupt end_dt' regression
|
||||
test — the duration model can't represent that state, but the
|
||||
same code path is exercised here for point events."""
|
||||
mock_event = _make_mock_event(duration_minutes=None)
|
||||
# Point event in the upcoming window
|
||||
mock_event.start_dt = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
mock_event.end_dt = None
|
||||
mock_event.to_dict.return_value = {
|
||||
"id": 1, "title": "Point",
|
||||
"start_dt": mock_event.start_dt.isoformat(),
|
||||
"end_dt": None,
|
||||
"duration_minutes": None,
|
||||
}
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_event]
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("fabledassistant.services.events.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from fabledassistant.services.events import list_events
|
||||
results = await list_events(
|
||||
user_id=1,
|
||||
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
|
||||
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_excludes_timed_event_that_already_ended():
|
||||
"""A timed event whose start + duration is before the window must
|
||||
NOT surface. Verifies the Python-side refinement actually works
|
||||
against the coarse SQL prefilter."""
|
||||
mock_event = _make_mock_event(duration_minutes=60)
|
||||
# Start 4/20 12:00, end 4/20 13:00; window is 4/29 → 5/27 — fully past.
|
||||
mock_event.start_dt = datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc)
|
||||
mock_event.end_dt = datetime(2026, 4, 20, 13, 0, tzinfo=timezone.utc)
|
||||
mock_event.to_dict.return_value = {
|
||||
"id": 1, "start_dt": mock_event.start_dt.isoformat(),
|
||||
"end_dt": mock_event.end_dt.isoformat(), "duration_minutes": 60,
|
||||
}
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_event]
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("fabledassistant.services.events.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from fabledassistant.services.events import list_events
|
||||
results = await list_events(
|
||||
user_id=1,
|
||||
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
|
||||
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
|
||||
)
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_calendar_always_available():
|
||||
"""Calendar tools must appear in get_tools_for_user even without CalDAV."""
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Tests for journal closeout extraction helpers."""
|
||||
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _msg(role: str, content: str, kind: str | None = None):
|
||||
"""Build a Message-like stand-in for the filter helpers."""
|
||||
metadata = {"kind": kind} if kind else None
|
||||
return SimpleNamespace(role=role, content=content, msg_metadata=metadata)
|
||||
|
||||
|
||||
def _fake_conv(conv_id: int = 1):
|
||||
return SimpleNamespace(id=conv_id)
|
||||
|
||||
|
||||
def _patch_db(messages, conv=None):
|
||||
"""Build a context manager that fakes async_session() and the two
|
||||
select() calls (conversation lookup + messages query)."""
|
||||
session = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
conv_result = MagicMock()
|
||||
conv_result.scalar_one_or_none = MagicMock(return_value=conv)
|
||||
msg_result = MagicMock()
|
||||
scalars = MagicMock()
|
||||
scalars.all = MagicMock(return_value=list(messages))
|
||||
msg_result.scalars = MagicMock(return_value=scalars)
|
||||
session.execute.side_effect = [conv_result, msg_result]
|
||||
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__aenter__ = AsyncMock(return_value=session)
|
||||
session_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
return session_ctx
|
||||
|
||||
|
||||
def test_filter_excludes_daily_prep_messages():
|
||||
from fabledassistant.services.journal_closeout import _filter_messages
|
||||
|
||||
msgs = [
|
||||
_msg("assistant", "Good morning — here is today's plan…", kind="daily_prep"),
|
||||
_msg("user", "I want to focus on the auth refactor today."),
|
||||
_msg("assistant", "Got it. I'll keep tool calls quiet."),
|
||||
]
|
||||
kept = _filter_messages(msgs)
|
||||
contents = [m.content for m in kept]
|
||||
assert "Good morning — here is today's plan…" not in contents
|
||||
assert "I want to focus on the auth refactor today." in contents
|
||||
assert "Got it. I'll keep tool calls quiet." in contents
|
||||
|
||||
|
||||
def test_build_transcript_labels_roles_and_caps_content():
|
||||
from fabledassistant.services.journal_closeout import _build_transcript
|
||||
|
||||
msgs = [
|
||||
_msg("user", "hello"),
|
||||
_msg("assistant", "hi"),
|
||||
_msg("user", "x" * 600),
|
||||
]
|
||||
out = _build_transcript(msgs)
|
||||
lines = out.splitlines()
|
||||
assert lines[0] == "USER: hello"
|
||||
assert lines[1] == "ASSISTANT: hi"
|
||||
# Third line content truncated to 500 chars
|
||||
assert lines[2].startswith("USER: ")
|
||||
assert len(lines[2]) == len("USER: ") + 500
|
||||
|
||||
|
||||
def test_build_transcript_keeps_only_last_20_messages():
|
||||
from fabledassistant.services.journal_closeout import _build_transcript
|
||||
|
||||
msgs = [_msg("user", f"msg-{i}") for i in range(30)]
|
||||
out = _build_transcript(msgs)
|
||||
lines = out.splitlines()
|
||||
assert len(lines) == 20
|
||||
# Newest 20 means msg-10 through msg-29
|
||||
assert lines[0] == "USER: msg-10"
|
||||
assert lines[-1] == "USER: msg-29"
|
||||
|
||||
|
||||
def test_system_prompt_lists_structured_fields_to_exclude():
|
||||
"""The prompt must explicitly tell the LLM not to restate structured
|
||||
fields, so the freeform learned_summary stays a narrow lane."""
|
||||
from fabledassistant.services.journal_closeout import SYSTEM_PROMPT
|
||||
|
||||
text = SYSTEM_PROMPT.lower()
|
||||
for field in ("name", "job title", "industry", "expertise", "response style", "tone", "interests"):
|
||||
assert field in text, f"system prompt should mention '{field}'"
|
||||
assert "(nothing to note)" in SYSTEM_PROMPT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_happy_path_appends_bullets():
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
yesterday = datetime.date(2026, 5, 11)
|
||||
msgs = [
|
||||
_msg("assistant", "Good morning — here's today.", kind="daily_prep"),
|
||||
_msg("user", "Skip the news section — I never read it."),
|
||||
_msg("assistant", "Noted. Will drop it."),
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())),
|
||||
patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")),
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="- User skips news section")),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=yesterday)
|
||||
|
||||
mock_append.assert_awaited_once_with(42, "- User skips news section")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_skips_when_no_conversation():
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db([], conv=None)),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
||||
|
||||
mock_append.assert_not_awaited()
|
||||
mock_llm.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_skips_when_only_prep_message_after_filter():
|
||||
"""If only the daily_prep message exists, the in-Python filter pass
|
||||
leaves zero messages and we short-circuit before the LLM."""
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
msgs_post_sql = [
|
||||
_msg("assistant", "Daily prep block.", kind="daily_prep"),
|
||||
]
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs_post_sql, conv=_fake_conv())),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
||||
|
||||
mock_append.assert_not_awaited()
|
||||
mock_llm.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_respects_nothing_to_note_sentinel():
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
msgs = [_msg("user", "hi"), _msg("assistant", "hi back")]
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())),
|
||||
patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")),
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="(nothing to note)")),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
||||
|
||||
mock_append.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_schedule_registers_closeout_when_enabled(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
fake_scheduler = MagicMock()
|
||||
fake_scheduler.get_job = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"prep_enabled": False, # isolate the closeout job
|
||||
"closeout_enabled": True,
|
||||
"day_rollover_hour": 4,
|
||||
}))
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
|
||||
await sched.update_user_schedule(user_id=7)
|
||||
|
||||
added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
|
||||
assert "journal_closeout_7" in added
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_schedule_does_not_register_closeout_when_disabled(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
fake_scheduler = MagicMock()
|
||||
fake_scheduler.get_job = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"prep_enabled": False,
|
||||
"closeout_enabled": False,
|
||||
"day_rollover_hour": 4,
|
||||
}))
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
|
||||
await sched.update_user_schedule(user_id=7)
|
||||
|
||||
added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
|
||||
assert "journal_closeout_7" not in added
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closeout_catchup_skips_when_already_have_entry(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"closeout_enabled": True,
|
||||
"day_rollover_hour": 0, # slot has always passed
|
||||
}))
|
||||
yesterday = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)).date()
|
||||
fake_profile = SimpleNamespace(observations_raw=[{"date": yesterday.isoformat(), "bullets": "..."}])
|
||||
|
||||
from fabledassistant.services import user_profile as up
|
||||
monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))
|
||||
|
||||
run_mock = AsyncMock()
|
||||
from fabledassistant.services import journal_closeout as jc
|
||||
monkeypatch.setattr(jc, "run_for_user", run_mock)
|
||||
|
||||
await sched._closeout_catchup(user_id=7)
|
||||
run_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closeout_catchup_runs_when_no_entry_yet(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"closeout_enabled": True,
|
||||
"day_rollover_hour": 0,
|
||||
}))
|
||||
fake_profile = SimpleNamespace(observations_raw=[])
|
||||
|
||||
from fabledassistant.services import user_profile as up
|
||||
monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))
|
||||
|
||||
run_mock = AsyncMock()
|
||||
from fabledassistant.services import journal_closeout as jc
|
||||
monkeypatch.setattr(jc, "run_for_user", run_mock)
|
||||
|
||||
await sched._closeout_catchup(user_id=7)
|
||||
run_mock.assert_awaited_once()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for the tool-use fixes from the 2026-05-08 journal session."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _project(pid: int, title: str, description: str = "", auto_summary: str = ""):
|
||||
return SimpleNamespace(
|
||||
id=pid, title=title, description=description, auto_summary=auto_summary,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_projects_tool_returns_success_true():
|
||||
"""The dispatcher sets status from result['success']; without this key
|
||||
the call gets labeled 'error' even when data is returned."""
|
||||
from fabledassistant.services.tools import projects as projects_tool
|
||||
|
||||
fake_projects = [_project(5, "Famous-Supply Work topics", "AT&T fiber circuit")]
|
||||
|
||||
# `list_projects` is imported locally inside search_projects_tool, so we
|
||||
# patch the source module rather than the consumer.
|
||||
with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)):
|
||||
result = await projects_tool.search_projects_tool(
|
||||
user_id=1, arguments={"query": "famous supply"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "projects_list"
|
||||
assert len(result["data"]["projects"]) == 1
|
||||
|
||||
|
||||
def test_strip_type_nouns_removes_task_word():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("sebring secondary task") == ["sebring", "secondary"]
|
||||
|
||||
|
||||
def test_strip_type_nouns_removes_all_variants():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("project notes task") == []
|
||||
|
||||
|
||||
def test_strip_type_nouns_case_insensitive():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("Sebring Task NOTES") == ["Sebring"]
|
||||
|
||||
|
||||
def test_strip_type_nouns_preserves_real_content_words():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("circuit configuration") == ["circuit", "configuration"]
|
||||
|
||||
|
||||
def test_strip_type_nouns_handles_empty_string():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("") == []
|
||||
assert _strip_type_nouns(" ") == []
|
||||
|
||||
|
||||
def test_score_project_match_exact_title_returns_1():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(5, "Famous-Supply Work topics")
|
||||
assert score_project_match("Famous-Supply Work topics", p) == 1.0
|
||||
|
||||
|
||||
def test_score_project_match_colloquial_substring_at_least_85():
|
||||
"""'famous supply' is a substring of normalized 'famous supply work topics'
|
||||
after stripping the hyphen. Substring match returns 0.85."""
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit")
|
||||
score = score_project_match("famous supply project", p)
|
||||
assert score >= 0.85, f"expected substring tier (>=0.85), got {score}"
|
||||
|
||||
|
||||
def test_score_project_match_query_in_summary_returns_70():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(12, "Minstrel", auto_summary="self-hosted music server")
|
||||
assert score_project_match("music server", p) == 0.70
|
||||
|
||||
|
||||
def test_score_project_match_unrelated_returns_low():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(12, "Minstrel", auto_summary="self-hosted music server")
|
||||
assert score_project_match("garden renovation", p) < 0.5
|
||||
|
||||
|
||||
def test_score_project_match_empty_query_returns_zero():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(5, "Famous-Supply Work topics")
|
||||
assert score_project_match("", p) == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_projects_tool_ranks_substring_match_above_others():
|
||||
"""Substring hits (score 0.85) must rank above SequenceMatcher misses
|
||||
for unrelated projects."""
|
||||
from fabledassistant.services.tools import projects as projects_tool
|
||||
|
||||
fake_projects = [
|
||||
_project(12, "Minstrel", auto_summary="self-hosted music server"),
|
||||
_project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit"),
|
||||
_project(13, "ImageRepo", auto_summary="self-hosted Flask app"),
|
||||
]
|
||||
|
||||
with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)):
|
||||
result = await projects_tool.search_projects_tool(
|
||||
user_id=1, arguments={"query": "famous supply project"},
|
||||
)
|
||||
|
||||
top = result["data"]["projects"][0]
|
||||
assert top["id"] == 5, f"expected Famous-Supply to rank first, got {top}"
|
||||
assert top["score"] >= 0.85
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_finds_colloquial_match(monkeypatch):
|
||||
"""resolve_project must surface 'Famous-Supply Work topics' when the
|
||||
user passes 'famous supply project' — substring match via the shared
|
||||
score helper, score 0.85 ≥ 0.55 threshold."""
|
||||
from fabledassistant.services.tools import _helpers
|
||||
|
||||
fake_projects = [
|
||||
_project(12, "Minstrel", auto_summary="self-hosted music server"),
|
||||
_project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit"),
|
||||
]
|
||||
|
||||
async def fake_get_project_by_title(uid, name):
|
||||
return None # force the scored path
|
||||
|
||||
async def fake_list_projects(uid):
|
||||
return fake_projects
|
||||
|
||||
monkeypatch.setattr(
|
||||
"fabledassistant.services.projects.get_project_by_title",
|
||||
fake_get_project_by_title,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"fabledassistant.services.projects.list_projects",
|
||||
fake_list_projects,
|
||||
)
|
||||
|
||||
result = await _helpers.resolve_project(user_id=1, project_name="famous supply project")
|
||||
assert result is not None
|
||||
assert result.id == 5
|
||||
Reference in New Issue
Block a user