Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f85b92a885 | |||
| 84640a0dc4 | |||
| 4c58603009 | |||
| b7e7073425 | |||
| 94b169f31c | |||
| 2db23cec7a | |||
| b81c4aa600 | |||
| 35fab6cbf7 | |||
| 404698521f | |||
| 9f8b451d15 | |||
| 4f18023284 | |||
| 6c309f1331 | |||
| 03d725ea3e | |||
| 611c940527 | |||
| 3b2a0a119f | |||
| a7de3296b3 |
@@ -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")
|
||||
@@ -568,6 +568,17 @@ Items deliberately not addressed in this round; revisit when a real need surface
|
||||
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
|
||||
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
|
||||
|
||||
### Flutter app port — shipped 2026-04-28
|
||||
|
||||
The companion mobile app (`fabled_app` / FabledApp repo) tracks the same design system. Two commits:
|
||||
|
||||
- **Foundation port** — `0f05f47`. `lib/core/theme.dart` rewritten with the Obsidian/Iron/Pewter dark palette, warm parchment light palette, dusty violet `#5B4A8A` primary. Inter loaded for body, JetBrains Mono available at call sites, Fraunces for headlines ≥18px. New `ActionColors` ThemeExtension exposes Moss/Bronze/Oxblood/Pewter outside the `ColorScheme` (Material's primary/secondary/tertiary slots all carry brand accent, so action tokens need their own home). `GradientButton` recolored to dusty-violet gradient.
|
||||
- **Surface phase** — `b9e68e3`. `lucide_icons ^0.257.0` installed; 107 `Icons.*` references across 21 files swapped to `LucideIcons.*`. Input border radius 24 → 8 in both themes. ChatMessageBubble Illuminated Transcript fixes — neutral border on user bubbles, `surface`/Iron bg on assistant bubbles, asymmetric corner restoration (only bottom-left clipped, not both left corners), accent-tinted glow shadow added. 5 destructive confirm buttons across notes / tasks / chat / calendar wired to `ActionColors.destructive`. Calendar event Save wired to `ActionColors.primary` as the reference Moss site. 4 hardcoded indigo Color literals → dusty-violet equivalents.
|
||||
|
||||
The Flutter port doesn't decompose into 7 PRs the way web did because Flutter's centralized `theme.dart` means most palette/font work happens in one file. Per-screen Save / Cancel reclassification beyond the calendar event Save is opportunistic — the wiring pattern (`Theme.of(context).extension<ActionColors>()!.primary`) is established and applied incrementally as files are touched.
|
||||
|
||||
Pattern reference for downstream screens: see `lib/screens/calendar/event_form_sheet.dart` for `ActionColors.primary` usage on Save buttons; see the dialog spots in `note_edit_screen.dart` / `task_edit_screen.dart` / `note_detail_screen.dart` / `conversations_tab_screen.dart` for `ActionColors.destructive` on confirm-Delete buttons.
|
||||
|
||||
### Open threads
|
||||
|
||||
*New threads will accumulate here as gaps surface in real use.*
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "fable-mcp"
|
||||
version = "0.2.6"
|
||||
version = "0.3.0"
|
||||
description = "MCP server for Fabled Scribe"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -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";
|
||||
@@ -177,26 +177,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))
|
||||
@@ -216,7 +259,6 @@ async function save() {
|
||||
project_id: projectId.value ?? undefined,
|
||||
};
|
||||
const updated = await updateEvent(props.event.id, payload);
|
||||
toast.show("Event updated", "success");
|
||||
emit("updated", updated);
|
||||
} else {
|
||||
const payload: EventCreatePayload = {
|
||||
@@ -230,7 +272,6 @@ async function save() {
|
||||
project_id: projectId.value ?? undefined,
|
||||
};
|
||||
const created = await createEvent(payload);
|
||||
toast.show("Event created", "success");
|
||||
emit("created", created);
|
||||
}
|
||||
} catch {
|
||||
@@ -256,23 +297,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 +356,55 @@ 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>
|
||||
|
||||
<!-- 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 +412,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,14 +526,14 @@ 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);
|
||||
@@ -482,65 +567,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 +582,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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -18,10 +18,12 @@ from fabledassistant.services.user_profile import build_profile_context
|
||||
|
||||
JOURNAL_PERSONA = (
|
||||
"You are the user's assistant. They've opened their journal — a day-anchored "
|
||||
"conversation surface where they record their day. Behave like the rest of "
|
||||
"the app's chat: respond conversationally, ask follow-up questions about what "
|
||||
"they just said, verify details from earlier in the conversation when "
|
||||
"relevant, and use tools naturally to act on their behalf when it makes sense. "
|
||||
"conversation surface where they record their day. Your primary job is to "
|
||||
"CAPTURE what they share, not to advise on it. Treat journal mode as a quiet "
|
||||
"thinking-companion surface: record the beat, optionally ask one short "
|
||||
"follow-up that doesn't presume they want help, and let the user lead. "
|
||||
"Use tools to act on their behalf when they ask, not when you think they "
|
||||
"might find it useful. "
|
||||
"The day's prep message at the top of the conversation is your context — "
|
||||
"build on it, don't restate it."
|
||||
)
|
||||
@@ -57,6 +59,26 @@ these beats; if you skip the call, the beat is lost.
|
||||
Multiple distinct beats in one message → multiple record_moment calls,
|
||||
one per beat.
|
||||
|
||||
MOMENT PHRASING — write it the way the user would jot it themselves.
|
||||
First-person or imperative, never third-person observer voice.
|
||||
- GOOD: "Restaging Docker on the Bedford swarm; one Windows node had
|
||||
network breakage."
|
||||
- GOOD: "Appointment this Friday — details TBD."
|
||||
- GOOD: "Coffee with Sarah; she's hiring."
|
||||
- BAD: "The user mentioned having an appointment this Friday but
|
||||
hasn't provided details yet."
|
||||
- BAD: "User reports Docker swarm restage in progress."
|
||||
Strip "the user…" / "user mentioned…" / "user is…" framings entirely.
|
||||
|
||||
MOMENT ENTITY LINKING — be conservative.
|
||||
- Only attach a `task_titles` link when the user *explicitly references
|
||||
that task* in the message. Do NOT link to a task just because it's
|
||||
in the prep context or the only task currently open.
|
||||
- Only attach `place_names` you can ground in something the user
|
||||
actually said. Generic placeholders like "work" / "home" / "office"
|
||||
are NOT places — drop them and let the user name the real one if it
|
||||
matters.
|
||||
|
||||
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
|
||||
@@ -89,6 +111,15 @@ RESPONSE STYLE:
|
||||
- Don't repeat a prior reply verbatim. If the user circles back on a theme,
|
||||
pick a specific concrete detail from the new message to react to.
|
||||
- Match the user's length. Short message → short reply. Don't pad.
|
||||
- DON'T offer troubleshooting steps, checklists, or generic process advice
|
||||
for the user's work unless they explicitly ask. When the user is logging
|
||||
what they're doing, they want to be heard, not coached. A statement like
|
||||
"I'm prepping for an ISP migration" should be acknowledged and recorded —
|
||||
not met with "Are you handling the network configuration yourself? Are
|
||||
there checks you need to do first?" If a follow-up would presume they
|
||||
want help, drop it.
|
||||
- No emojis. The journal is a thinking-companion surface; emojis read as
|
||||
chat-bot warmth that's out of register.
|
||||
"""
|
||||
|
||||
PHASE_GREETINGS = {
|
||||
@@ -134,7 +165,11 @@ async def build_journal_system_prompt(
|
||||
static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}"
|
||||
|
||||
today_iso = day_date.isoformat()
|
||||
tz_block = f"Today is {today_iso} ({user_timezone})."
|
||||
# Include the day-of-week explicitly. LLMs are unreliable at deriving
|
||||
# weekday names from ISO dates, which causes "this Friday" / "next
|
||||
# Monday" to land on the wrong calendar day.
|
||||
weekday = day_date.strftime("%A")
|
||||
tz_block = f"Today is {weekday}, {today_iso} ({user_timezone})."
|
||||
|
||||
profile_context = await build_profile_context(user_id)
|
||||
profile_section = f"\n\n{profile_context}" if profile_context else ""
|
||||
|
||||
@@ -23,6 +23,7 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -38,6 +39,65 @@ from fabledassistant.services.weather import get_cached_weather_rows
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# How many days out from today an event needs to be before the prep treats
|
||||
# it as too far-future to surface. Catches recurring-event canonical rows
|
||||
# whose RRULE expansion missed (an `rrulestr` failure falls back to the
|
||||
# canonical event in `list_events`, which leaks far-future occurrences
|
||||
# into today's prep).
|
||||
_EVENT_PROXIMITY_DAYS = 7
|
||||
|
||||
|
||||
def _task_to_prep_dict(task, today: datetime.date) -> dict:
|
||||
"""Render a Note row as a prep-payload task entry, tagging overdue
|
||||
staleness when relevant so the prompt can frame it correctly."""
|
||||
d = {
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"status": task.status,
|
||||
"priority": task.priority,
|
||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||
}
|
||||
if task.due_date and task.due_date < today:
|
||||
d["days_overdue"] = (today - task.due_date).days
|
||||
return d
|
||||
|
||||
|
||||
def _filter_proximate_events(
|
||||
events: list[dict], *, day_date: datetime.date, user_tz: ZoneInfo
|
||||
) -> list[dict]:
|
||||
"""Drop events whose start_dt is more than ``_EVENT_PROXIMITY_DAYS``
|
||||
away from ``day_date`` in the user's local timezone.
|
||||
|
||||
Belt-and-suspenders against `list_events` returning a canonical
|
||||
far-future event (e.g. when RRULE expansion fails and the loop falls
|
||||
back to the original event row, regardless of date). The user
|
||||
observed "Birthday — 2026-09-29 (FREQ=YEARLY)" surfacing in every
|
||||
daily prep 5 months out; this filter keeps the prep proximate.
|
||||
"""
|
||||
proximate: list[dict] = []
|
||||
for e in events:
|
||||
raw = e.get("start_dt") or ""
|
||||
try:
|
||||
start_dt = datetime.datetime.fromisoformat(
|
||||
raw.replace("Z", "+00:00") if isinstance(raw, str) else ""
|
||||
)
|
||||
local_date = start_dt.astimezone(user_tz).date()
|
||||
delta = abs((local_date - day_date).days)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
# Unparseable date — keep the event rather than suppress real data.
|
||||
proximate.append(e)
|
||||
continue
|
||||
if delta <= _EVENT_PROXIMITY_DAYS:
|
||||
proximate.append(e)
|
||||
else:
|
||||
logger.info(
|
||||
"daily_prep: dropping non-proximate event %r start_local=%s "
|
||||
"(%d days from day %s)",
|
||||
e.get("title"), local_date.isoformat(), delta, day_date.isoformat(),
|
||||
)
|
||||
return proximate
|
||||
|
||||
|
||||
async def gather_daily_sections(
|
||||
*,
|
||||
user_id: int,
|
||||
@@ -48,41 +108,71 @@ async def gather_daily_sections(
|
||||
|
||||
Pure data fetching — no LLM call. Each section degrades to an empty
|
||||
list/dict on failure so the caller always gets a complete shape.
|
||||
|
||||
Tasks are returned in three explicit buckets so the prompt can frame
|
||||
overdue items correctly (instead of calling them "due today" — the
|
||||
pre-2026-04-29 behavior, before this rewrite).
|
||||
"""
|
||||
sections: dict = {}
|
||||
|
||||
next_day = day_date + datetime.timedelta(days=1)
|
||||
upcoming_end = day_date + datetime.timedelta(days=8)
|
||||
try:
|
||||
tasks_today, _ = await list_notes(
|
||||
user_id=user_id,
|
||||
is_task=True,
|
||||
status=["todo", "in_progress"],
|
||||
due_before=day_date,
|
||||
limit=20,
|
||||
sort="due_date",
|
||||
order="asc",
|
||||
due_today_rows, _ = await list_notes(
|
||||
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
||||
due_after=day_date, due_before=next_day,
|
||||
limit=20, sort="due_date", order="asc",
|
||||
)
|
||||
upcoming_rows, _ = await list_notes(
|
||||
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
||||
due_after=next_day, due_before=upcoming_end,
|
||||
limit=20, sort="due_date", order="asc",
|
||||
)
|
||||
overdue_rows, _ = await list_notes(
|
||||
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
||||
due_before=day_date,
|
||||
limit=20, sort="due_date", order="asc",
|
||||
)
|
||||
sections["tasks_due_today"] = [_task_to_prep_dict(t, day_date) for t in due_today_rows]
|
||||
sections["tasks_upcoming"] = [_task_to_prep_dict(t, day_date) for t in upcoming_rows]
|
||||
sections["tasks_overdue"] = [_task_to_prep_dict(t, day_date) for t in overdue_rows]
|
||||
# Backwards-compat alias for any consumers still reading sections["tasks"].
|
||||
# The combined view is more useful than the prior overdue-only behavior.
|
||||
sections["tasks"] = (
|
||||
sections["tasks_due_today"]
|
||||
+ sections["tasks_upcoming"]
|
||||
+ sections["tasks_overdue"]
|
||||
)
|
||||
sections["tasks"] = [
|
||||
{
|
||||
"id": t.id,
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"priority": t.priority,
|
||||
"due_date": t.due_date.isoformat() if t.due_date else None,
|
||||
}
|
||||
for t in tasks_today
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("daily_prep tasks section failed for user %d", user_id)
|
||||
sections["tasks_due_today"] = []
|
||||
sections["tasks_upcoming"] = []
|
||||
sections["tasks_overdue"] = []
|
||||
sections["tasks"] = []
|
||||
|
||||
try:
|
||||
day_start = datetime.datetime.combine(day_date, datetime.time.min)
|
||||
day_end = datetime.datetime.combine(day_date, datetime.time.max)
|
||||
sections["events"] = await list_events(
|
||||
try:
|
||||
user_tz = ZoneInfo(user_timezone)
|
||||
except Exception:
|
||||
logger.warning("daily_prep: invalid user_timezone %r — defaulting to UTC", user_timezone)
|
||||
user_tz = ZoneInfo("UTC")
|
||||
# Build the local-day window in the user's TZ, then convert to UTC
|
||||
# for the DB / RRULE expansion. A naive datetime here previously
|
||||
# caused rrule.between() to throw, falling back to the canonical
|
||||
# event row regardless of date — the source of stale recurring
|
||||
# events polluting every daily prep.
|
||||
day_start_local = datetime.datetime.combine(day_date, datetime.time.min, tzinfo=user_tz)
|
||||
day_end_local = datetime.datetime.combine(day_date, datetime.time.max, tzinfo=user_tz)
|
||||
day_start = day_start_local.astimezone(datetime.timezone.utc)
|
||||
day_end = day_end_local.astimezone(datetime.timezone.utc)
|
||||
all_events = await list_events(
|
||||
user_id=user_id,
|
||||
date_from=day_start,
|
||||
date_to=day_end,
|
||||
)
|
||||
sections["events"] = _filter_proximate_events(
|
||||
all_events, day_date=day_date, user_tz=user_tz,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("daily_prep events section failed for user %d", user_id)
|
||||
sections["events"] = []
|
||||
@@ -148,22 +238,40 @@ async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def _render_task_line(t: dict, *, include_due: bool, include_overdue: bool) -> str:
|
||||
line = f" - {t.get('title', '?')}"
|
||||
if include_overdue and t.get("days_overdue"):
|
||||
line += f" (due {t['due_date']}, {t['days_overdue']} days ago)"
|
||||
elif include_due and t.get("due_date"):
|
||||
line += f" (due {t['due_date']})"
|
||||
if t.get("priority") and t["priority"] not in (None, "none"):
|
||||
line += f" [{t['priority']} priority]"
|
||||
if t.get("status") == "in_progress":
|
||||
line += " [in progress]"
|
||||
return line
|
||||
|
||||
|
||||
def _render_sections_for_prompt(sections: dict) -> str:
|
||||
"""Render the gathered sections as a structured plain-text block for the LLM."""
|
||||
lines: list[str] = []
|
||||
|
||||
tasks = sections.get("tasks") or []
|
||||
if tasks:
|
||||
lines.append("TASKS (todo or in-progress):")
|
||||
for t in tasks[:12]:
|
||||
line = f" - {t.get('title', '?')}"
|
||||
if t.get("due_date"):
|
||||
line += f" (due {t['due_date']})"
|
||||
if t.get("priority") and t["priority"] not in (None, "none"):
|
||||
line += f" [{t['priority']} priority]"
|
||||
if t.get("status") == "in_progress":
|
||||
line += " [in progress]"
|
||||
lines.append(line)
|
||||
due_today = sections.get("tasks_due_today") or []
|
||||
upcoming = sections.get("tasks_upcoming") or []
|
||||
overdue = sections.get("tasks_overdue") or []
|
||||
if due_today:
|
||||
lines.append("TASKS DUE TODAY:")
|
||||
for t in due_today[:8]:
|
||||
lines.append(_render_task_line(t, include_due=False, include_overdue=False))
|
||||
lines.append("")
|
||||
if upcoming:
|
||||
lines.append("UPCOMING TASKS (next 7 days):")
|
||||
for t in upcoming[:8]:
|
||||
lines.append(_render_task_line(t, include_due=True, include_overdue=False))
|
||||
lines.append("")
|
||||
if overdue:
|
||||
lines.append("OVERDUE TASKS (still on the list, not currently due):")
|
||||
for t in overdue[:8]:
|
||||
lines.append(_render_task_line(t, include_due=False, include_overdue=True))
|
||||
lines.append("")
|
||||
|
||||
events = sections.get("events") or []
|
||||
@@ -242,6 +350,12 @@ _PREP_SYSTEM_PROMPT = (
|
||||
"keep the prose factual and useful, not sentimental.\n"
|
||||
"- 4 to 7 sentences total. Tight. No padding, no flowery openings, no \"Good morning\" "
|
||||
"greetings unless the actual content warrants two clauses' worth.\n"
|
||||
"- TASK BUCKETS — three sections may appear: TASKS DUE TODAY, UPCOMING TASKS, "
|
||||
"OVERDUE TASKS. Lead with TASKS DUE TODAY when present. Do NOT call overdue items "
|
||||
"\"due today\" — they aren't. When OVERDUE TASKS appears, surface it with the "
|
||||
"staleness duration (\"still on the list 68 days\") and frame it as something to "
|
||||
"revisit, not as today's work. If the only data is overdue, lead with it but "
|
||||
"frame it as a backlog reminder.\n"
|
||||
"- If RECENT JOURNAL MOMENTS or OPEN THREADS are present, mention one or two BRIEFLY "
|
||||
"at the end as context — not as the lead. Skip them if nothing notable.\n"
|
||||
"- Close with one short invitation to journal: \"What's on your mind?\", "
|
||||
|
||||
@@ -602,7 +602,12 @@ async def build_context(
|
||||
}
|
||||
|
||||
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
|
||||
today = date_type.today().isoformat()
|
||||
_today_obj = date_type.today()
|
||||
today = _today_obj.isoformat()
|
||||
# Day-of-week paired with the ISO date so the model doesn't have to
|
||||
# derive the weekday — that derivation is a documented failure mode
|
||||
# for "this Friday" / "next Monday"-style requests.
|
||||
today_weekday = _today_obj.strftime("%A")
|
||||
has_caldav = await is_caldav_configured(user_id)
|
||||
|
||||
# Build tool usage guidance based on available integrations
|
||||
@@ -678,7 +683,7 @@ async def build_context(
|
||||
entities_context = await get_people_and_places_context(user_id)
|
||||
entities_section = f"\n\n{entities_context}" if entities_context else ""
|
||||
|
||||
dynamic_tail = f"\n\nToday's date is {today}.{tz_line}{profile_section}{entities_section}"
|
||||
dynamic_tail = f"\n\nToday is {today_weekday}, {today}.{tz_line}{profile_section}{entities_section}"
|
||||
|
||||
# --- System message: stable content only ---
|
||||
# Workspace context and history summary stay here because they carry
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from datetime import date as _date, datetime, time as _time, timezone
|
||||
|
||||
from fabledassistant.services.events import (
|
||||
create_event as events_create_event,
|
||||
@@ -17,10 +18,49 @@ from fabledassistant.services.tools._registry import tool
|
||||
from fabledassistant.services.tz import get_user_tz
|
||||
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
_TIME_RE = re.compile(r"^\d{2}:\d{2}(:\d{2})?$")
|
||||
|
||||
|
||||
async def _combine_local_in_user_tz(
|
||||
user_id: int, date_str: str, time_str: str | None
|
||||
) -> datetime:
|
||||
"""Build a UTC datetime from separate date and time strings.
|
||||
|
||||
The whole point of this helper: a `YYYY-MM-DD` string carries no TZ
|
||||
metadata that a model could mis-tag, so the calendar day cannot drift
|
||||
across the local→UTC boundary. The wall-clock `HH:MM` likewise has no
|
||||
TZ; we attach the user's local zone explicitly via ``datetime.combine``
|
||||
and then convert to UTC for storage.
|
||||
|
||||
Strict shape validation rejects anything that isn't a bare date or a
|
||||
bare time — no `2026-05-01Z`, no `08:00 UTC` slipping through.
|
||||
"""
|
||||
if not _DATE_RE.match(date_str):
|
||||
raise ValueError(
|
||||
f"start_date / end_date must be YYYY-MM-DD with no timezone; got {date_str!r}"
|
||||
)
|
||||
d = _date.fromisoformat(date_str)
|
||||
if time_str is None or time_str == "":
|
||||
t = _time(0, 0)
|
||||
else:
|
||||
if not _TIME_RE.match(time_str):
|
||||
raise ValueError(
|
||||
f"start_time / end_time must be HH:MM (or HH:MM:SS), no timezone; got {time_str!r}"
|
||||
)
|
||||
t = _time.fromisoformat(time_str)
|
||||
user_tz = await get_user_tz(user_id)
|
||||
local = datetime.combine(d, t, tzinfo=user_tz)
|
||||
return local.astimezone(timezone.utc)
|
||||
|
||||
|
||||
async def _parse_datetime_in_user_tz(
|
||||
user_id: int, value: str
|
||||
) -> tuple[datetime, bool]:
|
||||
"""Parse a date/datetime string from the model into a UTC-aware datetime.
|
||||
"""Legacy single-string parser. Kept as a fallback when the model
|
||||
emits the older `start` / `end` shape; new calls should use
|
||||
`start_date`+`start_time` (and `end_date`+`end_time`) which sidestep
|
||||
the TZ-tagging foot-gun this parser is vulnerable to.
|
||||
|
||||
Naive inputs are interpreted in the **user's local timezone** and then
|
||||
converted to UTC for storage. Never default to UTC for naive inputs —
|
||||
@@ -38,14 +78,178 @@ async def _parse_datetime_in_user_tz(
|
||||
return dt.astimezone(timezone.utc), was_date_only
|
||||
|
||||
|
||||
async def _resolve_event_start(
|
||||
user_id: int, args: dict
|
||||
) -> tuple[datetime, bool]:
|
||||
"""Resolve the start datetime from either the new split fields
|
||||
(`start_date` + optional `start_time`) or the legacy combined `start`.
|
||||
Returns ``(utc_datetime, was_date_only)``."""
|
||||
if "start_date" in args and args["start_date"]:
|
||||
date_str = args["start_date"]
|
||||
time_str = args.get("start_time") or None
|
||||
return await _combine_local_in_user_tz(user_id, date_str, time_str), time_str is None
|
||||
if "start" in args and args["start"]:
|
||||
return await _parse_datetime_in_user_tz(user_id, args["start"])
|
||||
raise ValueError("Either start_date or start is required")
|
||||
|
||||
|
||||
async def _resolve_event_end(
|
||||
user_id: int, args: dict
|
||||
) -> datetime | None:
|
||||
"""Resolve the end datetime from either the new split fields or the
|
||||
legacy combined `end`. Returns ``None`` when no end fields are set."""
|
||||
if "end_date" in args and args["end_date"]:
|
||||
date_str = args["end_date"]
|
||||
time_str = args.get("end_time") or None
|
||||
return await _combine_local_in_user_tz(user_id, date_str, time_str)
|
||||
if "end" in args and args["end"]:
|
||||
dt, _ = await _parse_datetime_in_user_tz(user_id, args["end"])
|
||||
return dt
|
||||
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.
|
||||
|
||||
Models routinely miscompute "this Friday" / "next Monday" when the
|
||||
system prompt only carries an ISO date without a weekday. When the
|
||||
model passes `expected_weekday`, the backend rejects mismatches with
|
||||
a self-correcting error message naming the actual weekday.
|
||||
|
||||
Returns an error string on mismatch, or ``None`` when the check
|
||||
passes (or no expected weekday was supplied).
|
||||
"""
|
||||
if not expected:
|
||||
return None
|
||||
expected_norm = expected.strip().lower()
|
||||
valid = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
|
||||
if expected_norm not in valid:
|
||||
return f"expected_weekday must be a full English weekday name; got {expected!r}."
|
||||
local = start_dt_utc.astimezone(user_tz)
|
||||
actual = local.strftime("%A").lower()
|
||||
if actual == expected_norm:
|
||||
return None
|
||||
return (
|
||||
f"Date {local.date().isoformat()} falls on {actual.title()}, "
|
||||
f"not {expected_norm.title()}. Recompute the date for "
|
||||
f"{expected_norm.title()} or confirm with the user before retrying."
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_event",
|
||||
description="Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event. Pass dates and datetimes in the user's local time — a bare date like '2026-09-30' is interpreted as that day in the user's configured timezone.",
|
||||
description=(
|
||||
"Create a calendar event for the user. Use this when the user asks "
|
||||
"to schedule, add, or create a meeting, appointment, or event. "
|
||||
"Always pass `start_date` (YYYY-MM-DD) and `start_time` (HH:MM) as "
|
||||
"separate fields in the user's local time — never combine them and "
|
||||
"never include a timezone suffix. The server attaches the user's "
|
||||
"configured timezone. Omit `start_time` (or set `all_day=true`) "
|
||||
"for all-day events like birthdays or holidays. "
|
||||
"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.\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"},
|
||||
"start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (YYYY-MM-DDTHH:MM) in the user's local time. Include a timezone offset only if the user explicitly mentions one."},
|
||||
"end": {"type": "string", "description": "Optional end date or datetime in the user's local time"},
|
||||
"duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)"},
|
||||
"start_date": {"type": "string", "description": "Start calendar date as YYYY-MM-DD in the user's local time. No timezone suffix."},
|
||||
"start_time": {"type": "string", "description": "Start wall-clock time as HH:MM (24-hour). Omit for all-day events. No timezone suffix."},
|
||||
"end_date": {"type": "string", "description": "Optional end calendar date as YYYY-MM-DD."},
|
||||
"end_time": {"type": "string", "description": "Optional end wall-clock time as HH:MM."},
|
||||
"duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end_date/end_time is set or all_day is true)"},
|
||||
"description": {"type": "string", "description": "Optional event description"},
|
||||
"location": {"type": "string", "description": "Optional event location"},
|
||||
"color": {"type": "string", "description": "Optional hex color for the event (e.g. '#6366f1')"},
|
||||
@@ -55,30 +259,36 @@ async def _parse_datetime_in_user_tz(
|
||||
"attendees": {"type": "array", "items": {"type": "string"}, "description": "Optional list of attendee email addresses"},
|
||||
"calendar_name": {"type": "string", "description": "Optional calendar name to create the event in. Falls back to default calendar."},
|
||||
"project": {"type": "string", "description": "Optional project name to associate this event with"},
|
||||
"expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the start_date should fall on. Pass this whenever the user names a weekday so the server can verify the date is correct. Rejects with a corrective error if the date falls on a different day."},
|
||||
# Legacy combined fields kept for backward compatibility with saved
|
||||
# tool-call payloads in conversation history. New calls should use
|
||||
# start_date + start_time. Hidden from typical model output via the
|
||||
# description above; still accepted by the resolver as a fallback.
|
||||
"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=["title", "start"],
|
||||
required=["title"],
|
||||
)
|
||||
async def create_event_tool(*, user_id, arguments, **_ctx):
|
||||
start_str = arguments["start"]
|
||||
end_str = arguments.get("end")
|
||||
all_day = arguments.get("all_day", False)
|
||||
try:
|
||||
# Naive dates/datetimes are interpreted in the user's local
|
||||
# timezone, not UTC. Storing UTC for naive inputs caused all-day
|
||||
# events to land on the previous day for negative-offset users.
|
||||
start_dt, start_was_date_only = await _parse_datetime_in_user_tz(
|
||||
user_id, start_str
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid start datetime: {start_str!r}"}
|
||||
start_dt, start_was_date_only = await _resolve_event_start(user_id, arguments)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
except TypeError as exc:
|
||||
return {"success": False, "error": f"Invalid start: {exc}"}
|
||||
if start_was_date_only:
|
||||
all_day = True
|
||||
end_dt = None
|
||||
if end_str:
|
||||
try:
|
||||
end_dt, _ = await _parse_datetime_in_user_tz(user_id, end_str)
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid end datetime: {end_str!r}"}
|
||||
user_tz = await get_user_tz(user_id)
|
||||
weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday"))
|
||||
if weekday_err:
|
||||
return {"success": False, "error": weekday_err}
|
||||
try:
|
||||
end_dt = await _resolve_event_end(user_id, arguments)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
except TypeError as exc:
|
||||
return {"success": False, "error": f"Invalid end: {exc}"}
|
||||
project_id = None
|
||||
project_name = arguments.get("project")
|
||||
if project_name:
|
||||
@@ -168,27 +378,51 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
|
||||
|
||||
@tool(
|
||||
name="update_event",
|
||||
description="Update an existing calendar event. Use this when the user asks to change, move, reschedule, or modify an event.",
|
||||
description=(
|
||||
"Update an existing calendar event. Use this when the user asks to "
|
||||
"change, move, reschedule, or modify an event. Pass `start_date` "
|
||||
"(YYYY-MM-DD) and `start_time` (HH:MM) as separate fields in the "
|
||||
"user's local time when rescheduling — never combine them, never "
|
||||
"include a timezone suffix. "
|
||||
"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.\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": {"type": "string", "description": "New start datetime in ISO 8601 format"},
|
||||
"end": {"type": "string", "description": "New end datetime in ISO 8601 format"},
|
||||
"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."},
|
||||
"end_date": {"type": "string", "description": "New end calendar date as YYYY-MM-DD."},
|
||||
"end_time": {"type": "string", "description": "New end wall-clock time as HH:MM."},
|
||||
"all_day": {"type": "boolean", "description": "Whether the event is all-day"},
|
||||
"description": {"type": "string", "description": "New event description"},
|
||||
"location": {"type": "string", "description": "New event location"},
|
||||
"color": {"type": "string", "description": "New hex color for the event (e.g. '#6366f1')"},
|
||||
"recurrence": {"type": "string", "description": "New iCalendar RRULE"},
|
||||
"reminder_minutes": {"type": "integer", "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder."},
|
||||
"expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the new start_date should fall on. Pass whenever the user names a weekday."},
|
||||
# Legacy combined fields kept for backcompat — see create_event.
|
||||
"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:
|
||||
@@ -198,16 +432,24 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
|
||||
if "reminder_minutes" in arguments:
|
||||
rm = arguments["reminder_minutes"]
|
||||
fields["reminder_minutes"] = None if rm == 0 else rm
|
||||
for dt_field, key in (("start_dt", "start"), ("end_dt", "end")):
|
||||
val = arguments.get(key)
|
||||
if val:
|
||||
try:
|
||||
# Naive datetimes are user-local, not UTC — see
|
||||
# ``_parse_datetime_in_user_tz`` docstring.
|
||||
dt, _ = await _parse_datetime_in_user_tz(user_id, val)
|
||||
fields[dt_field] = dt
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
|
||||
# Resolve start: split fields preferred, legacy `start` as fallback.
|
||||
if arguments.get("start_date") or arguments.get("start"):
|
||||
try:
|
||||
start_dt, _ = await _resolve_event_start(user_id, arguments)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return {"success": False, "error": f"Invalid start: {exc}"}
|
||||
user_tz = await get_user_tz(user_id)
|
||||
weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday"))
|
||||
if weekday_err:
|
||||
return {"success": False, "error": weekday_err}
|
||||
fields["start_dt"] = start_dt
|
||||
if arguments.get("end_date") or arguments.get("end"):
|
||||
try:
|
||||
end_dt = await _resolve_event_end(user_id, arguments)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return {"success": False, "error": f"Invalid end: {exc}"}
|
||||
if end_dt is not None:
|
||||
fields["end_dt"] = end_dt
|
||||
updated = await events_update_event(user_id=user_id, event_id=event_to_update.id, **fields)
|
||||
if updated is None:
|
||||
return {"success": False, "error": "Event not found or update failed."}
|
||||
@@ -216,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}}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
@@ -15,6 +16,97 @@ from fabledassistant.services.tools._registry import tool
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Generic placeholders the model occasionally emits for `place_names` when no
|
||||
# real place was named. Filtered out server-side as belt-and-suspenders to the
|
||||
# prompt-layer guidance — these aren't places, just role-labels for locations
|
||||
# the user already has named (Home / Work weather targets, etc.).
|
||||
_PLACEHOLDER_PLACE_NAMES = frozenset({
|
||||
"work", "home", "office", "the office", "my office", "my work",
|
||||
"my home", "house", "my house", "the house",
|
||||
})
|
||||
|
||||
# Short closed-class words excluded from the keyword-overlap check below.
|
||||
# Case is normalized to lowercase before comparison.
|
||||
_STOPWORDS = frozenset({
|
||||
"the", "a", "an", "and", "or", "but", "at", "in", "on", "of", "to",
|
||||
"for", "with", "by", "from", "about", "as", "is", "was", "were", "be",
|
||||
"been", "being", "have", "has", "had", "do", "does", "did", "will",
|
||||
"would", "could", "should", "may", "might", "must", "this", "that",
|
||||
"these", "those", "i", "you", "he", "she", "we", "they", "it", "his",
|
||||
"her", "their", "my", "your", "our", "its", "me", "him", "us", "them",
|
||||
"are", "if", "so", "no", "not", "yes", "now", "then", "than", "too",
|
||||
"very", "just", "also", "any", "all", "some", "one", "two", "out",
|
||||
"up", "down", "off", "over", "under", "into", "onto", "upon",
|
||||
})
|
||||
|
||||
|
||||
def _content_keywords(text: str) -> set[str]:
|
||||
"""Tokenize text into the meaningful keyword set used for overlap checks.
|
||||
|
||||
Lowercased, alphanumeric runs only, stopwords removed, tokens shorter
|
||||
than 3 chars dropped. Numeric tokens are kept (e.g. "Branch 14" yields
|
||||
{"branch", "14"}) because they often anchor task references.
|
||||
"""
|
||||
tokens = re.split(r"[^a-z0-9]+", text.lower())
|
||||
return {t for t in tokens if len(t) >= 3 and t not in _STOPWORDS}
|
||||
|
||||
|
||||
def _filter_placeholder_places(names: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""Drop generic placeholders from a `place_names` list.
|
||||
|
||||
Returns ``(kept, dropped)``. The dropped list is used purely for log
|
||||
visibility — the moment is still created, the bogus links are just
|
||||
not persisted.
|
||||
"""
|
||||
kept: list[str] = []
|
||||
dropped: list[str] = []
|
||||
for n in names:
|
||||
norm = (n or "").strip()
|
||||
if norm and norm.lower() in _PLACEHOLDER_PLACE_NAMES:
|
||||
dropped.append(norm)
|
||||
else:
|
||||
kept.append(norm)
|
||||
return kept, dropped
|
||||
|
||||
|
||||
async def _filter_task_ids_by_keyword_overlap(
|
||||
*, user_id: int, content: str, task_ids: list[int]
|
||||
) -> list[int]:
|
||||
"""Drop task links whose title shares no meaningful keyword with the
|
||||
moment content.
|
||||
|
||||
Reproducer this guards against (2026-04-27): the model emitted
|
||||
`task_titles=["Research Weston's ADHD Evaluation"]` on a moment about
|
||||
restaging Docker — the title was real (Weston's task is in the prep
|
||||
context) but the user never referenced it. Without this guard, the
|
||||
only task surfaced in the prep gets attached to every moment as
|
||||
filler. After this guard the link is dropped (zero-overlap) and a
|
||||
log entry is emitted at INFO so we can observe how often this fires.
|
||||
"""
|
||||
if not task_ids:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
stmt = select(Note.id, Note.title).where(
|
||||
Note.user_id == user_id,
|
||||
Note.id.in_(task_ids),
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
title_by_id = {nid: (title or "") for nid, title in rows}
|
||||
content_kw = _content_keywords(content)
|
||||
kept: list[int] = []
|
||||
for tid in task_ids:
|
||||
title = title_by_id.get(tid, "")
|
||||
title_kw = _content_keywords(title)
|
||||
if title_kw and content_kw & title_kw:
|
||||
kept.append(tid)
|
||||
else:
|
||||
logger.info(
|
||||
"record_moment: dropped task link id=%s title=%r — no keyword overlap with content %r",
|
||||
tid, title, content[:80],
|
||||
)
|
||||
return kept
|
||||
|
||||
|
||||
async def _resolve_entity_ids_by_name(
|
||||
*,
|
||||
user_id: int,
|
||||
@@ -78,7 +170,16 @@ async def _resolve_entity_ids_by_name(
|
||||
parameters={
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "1-2 sentence distillation of the moment in the user's voice or third-person.",
|
||||
"description": (
|
||||
"1-2 sentence distillation of the moment in the user's "
|
||||
"voice — first-person or imperative, like a journal jot. "
|
||||
"GOOD: 'Restaging Docker on the Bedford swarm; one "
|
||||
"Windows node had network breakage.' / 'Appointment "
|
||||
"Friday — details TBD.' "
|
||||
"BAD: 'The user mentioned having an appointment.' / "
|
||||
"'User reports Docker swarm restage.' Strip 'the user…' "
|
||||
"/ 'user mentioned…' framings entirely."
|
||||
),
|
||||
},
|
||||
"occurred_at": {
|
||||
"type": "string",
|
||||
@@ -171,10 +272,17 @@ async def record_moment_tool(*, user_id, arguments, conv_id=None, **_ctx):
|
||||
note_type="person",
|
||||
)
|
||||
)
|
||||
raw_place_names = arguments.get("place_names") or []
|
||||
kept_place_names, dropped_place_names = _filter_placeholder_places(raw_place_names)
|
||||
if dropped_place_names:
|
||||
logger.info(
|
||||
"record_moment: dropped placeholder place_names %r — not real places",
|
||||
dropped_place_names,
|
||||
)
|
||||
place_ids.extend(
|
||||
await _resolve_entity_ids_by_name(
|
||||
user_id=user_id,
|
||||
names=arguments.get("place_names") or [],
|
||||
names=kept_place_names,
|
||||
note_type="place",
|
||||
)
|
||||
)
|
||||
@@ -199,6 +307,15 @@ async def record_moment_tool(*, user_id, arguments, conv_id=None, **_ctx):
|
||||
task_ids = list(dict.fromkeys(task_ids))
|
||||
note_ids = list(dict.fromkeys(note_ids))
|
||||
|
||||
# Drop task links that don't share a keyword with the moment content.
|
||||
# Belt-and-suspenders to the prompt-layer rule "only link tasks the user
|
||||
# explicitly references" — if the model attaches a task anyway (because
|
||||
# it's in the prep context), the keyword check refuses to persist a link
|
||||
# the moment can't justify.
|
||||
task_ids = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=user_id, content=content, task_ids=task_ids,
|
||||
)
|
||||
|
||||
moment = await create_moment(
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
|
||||
@@ -132,3 +132,664 @@ async def test_list_events_bare_date_range_covers_local_day():
|
||||
assert (df.year, df.month, df.day, df.hour) == (2026, 9, 30, 4)
|
||||
# 2026-09-30 23:59:59 NY (EDT) = 03:59:59 UTC next day
|
||||
assert (dt.year, dt.month, dt.day, dt.hour) == (2026, 10, 1, 3)
|
||||
|
||||
|
||||
# ── Split date/time field tests (durable shape) ──────────────────────────────
|
||||
#
|
||||
# These exercise the start_date + start_time path that the model is now
|
||||
# steered toward. The split-field shape is structurally immune to the
|
||||
# class of bugs where a model emits a TZ-tagged combined datetime that
|
||||
# the parser correctly honors but lands on the wrong calendar day for
|
||||
# negative-offset users.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_fields_friday_8am_no_drift_eastern():
|
||||
"""The reported bug: 'next Friday at 8am' for a NY user must land on
|
||||
2026-05-01 08:00 NY, never 04-30 19:00. With split fields the model
|
||||
can't TZ-tag the date string, so the calendar day is fixed."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Meeting",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 08:00 NY (EDT, UTC-4) on 2026-05-01 = 12:00 UTC same day
|
||||
assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 5, 1, 12, 0)
|
||||
assert captured["all_day"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_fields_no_drift_pacific():
|
||||
"""Same scenario for a UTC-8 user — the calendar day must be 5/1
|
||||
regardless of how big the offset gets."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/Los_Angeles")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Standup",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 08:00 LA (PDT, UTC-7) = 15:00 UTC same day
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 15)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_fields_no_drift_positive_offset():
|
||||
"""A positive-offset user (Tokyo, UTC+9) — 08:00 Tokyo on 2026-05-01
|
||||
is 23:00 UTC on 2026-04-30, but the local calendar day must still be
|
||||
stored as 2026-05-01 from the user's perspective."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Sync",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 08:00 Tokyo (UTC+9) = 23:00 UTC previous day; the round-trip back
|
||||
# to Tokyo TZ recovers 2026-05-01 08:00.
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 4, 30, 23)
|
||||
tokyo = captured["start_dt"].astimezone(__import__("zoneinfo").ZoneInfo("Asia/Tokyo"))
|
||||
assert (tokyo.year, tokyo.month, tokyo.day, tokyo.hour) == (2026, 5, 1, 8)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_date_only_is_all_day():
|
||||
"""Omitting start_time means the event is all-day; cache row uses
|
||||
local midnight."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Birthday", "start_date": "2026-09-30"},
|
||||
)
|
||||
|
||||
assert captured["all_day"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 9, 30, 4)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_rejects_tz_suffix_in_date():
|
||||
"""The whole point of split fields: a TZ suffix on the date string
|
||||
must be rejected, not silently honored."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "x", "start_date": "2026-05-01Z", "start_time": "08:00"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "YYYY-MM-DD" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_rejects_tz_suffix_in_time():
|
||||
"""A TZ suffix on the time string must also be rejected."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "x", "start_date": "2026-05-01", "start_time": "08:00 UTC"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "HH:MM" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_legacy_combined_start_still_works():
|
||||
"""Backcompat: saved tool-call payloads using the old `start` field
|
||||
must still produce the same UTC datetime as before."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Legacy", "start": "2026-05-01T08:00"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_split_fields_reschedule_no_drift():
|
||||
"""update_event with split fields must drift no calendar day either."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
ev = AsyncMock()
|
||||
ev.id = 99
|
||||
ev.title = "Coffee"
|
||||
return [ev]
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
captured.update(fields)
|
||||
ev = AsyncMock()
|
||||
ev.to_dict.return_value = {"id": event_id, **fields}
|
||||
return ev
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), 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": "Coffee",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12)
|
||||
|
||||
|
||||
# ── expected_weekday verification (catches "this Friday" → Thursday bugs) ────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_match_succeeds():
|
||||
"""When expected_weekday agrees with the resolved local date's
|
||||
weekday, the event is created normally."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Meeting",
|
||||
"start_date": "2026-05-01", # is a Friday
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["start_dt"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_mismatch_rejects_and_names_actual():
|
||||
"""The reported failure mode: model picks Thursday and calls it
|
||||
Friday. With expected_weekday set, the create is rejected and the
|
||||
error names the actual weekday so the model can self-correct."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
create_called = False
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
nonlocal create_called
|
||||
create_called = True
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Dentist",
|
||||
"start_date": "2026-04-30", # Thursday
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Thursday" in result["error"]
|
||||
assert "Friday" in result["error"]
|
||||
# Critical: the event must NOT have been created when the check failed.
|
||||
assert create_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_omitted_skips_check():
|
||||
"""Backcompat: when expected_weekday isn't passed, no validation
|
||||
runs — the existing create flow is unchanged."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "x",
|
||||
"start_date": "2026-04-30",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_invalid_value_rejects():
|
||||
"""Garbage in expected_weekday produces a clear validation error,
|
||||
not a silent pass."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "x",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "fri", # abbreviation not accepted
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "weekday" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_expected_weekday_mismatch_rejects():
|
||||
"""update_event must enforce the same weekday check on reschedules."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
update_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
ev = AsyncMock()
|
||||
ev.id = 99
|
||||
ev.title = "Coffee"
|
||||
return [ev]
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
nonlocal update_called
|
||||
update_called = True
|
||||
ev = AsyncMock()
|
||||
ev.to_dict.return_value = {"id": event_id}
|
||||
return ev
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), 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": "Coffee",
|
||||
"start_date": "2026-04-30", # Thursday
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Thursday" in result["error"]
|
||||
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.
|
||||
A late-evening Friday event in Tokyo (UTC+9) crosses midnight UTC,
|
||||
so a UTC-day check would call it Saturday — but the user's calendar
|
||||
says Friday. The check must respect the user's local view."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Friday night",
|
||||
"start_date": "2026-05-01", # Friday in Tokyo
|
||||
"start_time": "23:00", # 14:00 UTC same day; safe
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -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,193 @@
|
||||
"""Tests for the journal prep filtering helpers added in #159."""
|
||||
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── _task_to_prep_dict ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_task_to_prep_dict_marks_overdue_with_days_count():
|
||||
from fabledassistant.services.journal_prep import _task_to_prep_dict
|
||||
|
||||
today = datetime.date(2026, 4, 29)
|
||||
task = SimpleNamespace(
|
||||
id=2, title="Research Weston's ADHD Evaluation",
|
||||
status="todo", priority="high",
|
||||
due_date=datetime.date(2026, 2, 20),
|
||||
)
|
||||
d = _task_to_prep_dict(task, today)
|
||||
assert d["days_overdue"] == 68
|
||||
assert d["due_date"] == "2026-02-20"
|
||||
|
||||
|
||||
def test_task_to_prep_dict_due_today_no_overdue_marker():
|
||||
from fabledassistant.services.journal_prep import _task_to_prep_dict
|
||||
|
||||
today = datetime.date(2026, 4, 29)
|
||||
task = SimpleNamespace(
|
||||
id=10, title="Pick up dry cleaning",
|
||||
status="todo", priority="none",
|
||||
due_date=today,
|
||||
)
|
||||
d = _task_to_prep_dict(task, today)
|
||||
assert "days_overdue" not in d
|
||||
|
||||
|
||||
def test_task_to_prep_dict_handles_no_due_date():
|
||||
from fabledassistant.services.journal_prep import _task_to_prep_dict
|
||||
|
||||
task = SimpleNamespace(
|
||||
id=11, title="Long-running side project",
|
||||
status="in_progress", priority="medium",
|
||||
due_date=None,
|
||||
)
|
||||
d = _task_to_prep_dict(task, datetime.date(2026, 4, 29))
|
||||
assert d["due_date"] is None
|
||||
assert "days_overdue" not in d
|
||||
|
||||
|
||||
# ── _filter_proximate_events ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_filter_proximate_drops_far_future_recurring_event():
|
||||
"""The reported failure: a recurring Birthday event with start_dt
|
||||
2026-09-29 surfaced in every daily prep. The proximity filter drops it."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 13, "title": "Birthday", "start_dt": "2026-09-29T00:00:00+00:00"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
assert kept == []
|
||||
|
||||
|
||||
def test_filter_proximate_keeps_events_within_window():
|
||||
"""Events within ±7 days of the prep date stay."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 1, "title": "Today", "start_dt": "2026-04-29T13:00:00+00:00"},
|
||||
{"id": 2, "title": "Tomorrow", "start_dt": "2026-04-30T12:00:00+00:00"},
|
||||
{"id": 3, "title": "Next week", "start_dt": "2026-05-05T15:00:00+00:00"},
|
||||
{"id": 4, "title": "Two weeks out", "start_dt": "2026-05-15T15:00:00+00:00"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
kept_ids = [e["id"] for e in kept]
|
||||
assert 1 in kept_ids
|
||||
assert 2 in kept_ids
|
||||
assert 3 in kept_ids
|
||||
assert 4 not in kept_ids
|
||||
|
||||
|
||||
def test_filter_proximate_uses_local_date_not_utc():
|
||||
"""An event at 04:30 UTC on 2026-04-30 = 00:30 local on 2026-04-30 in
|
||||
NY (EDT, UTC-4). Local date is 2026-04-30, delta from 2026-04-29 = 1
|
||||
day. Must NOT cross-classify based on UTC date alone."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 99, "title": "Late-night dentist", "start_dt": "2026-04-30T04:30:00+00:00"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
assert len(kept) == 1
|
||||
|
||||
|
||||
def test_filter_proximate_keeps_unparseable_dates():
|
||||
"""Bad date strings are kept rather than silently suppressing real
|
||||
events on a parser bug."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 50, "title": "Garbage", "start_dt": "not-a-date"},
|
||||
{"id": 51, "title": "Empty", "start_dt": ""},
|
||||
{"id": 52, "title": "Missing"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
assert len(kept) == 3
|
||||
|
||||
|
||||
# ── _render_sections_for_prompt — overdue framing ────────────────────────────
|
||||
|
||||
|
||||
def test_render_overdue_includes_staleness_duration():
|
||||
"""The rendered prompt block must surface days_overdue so the LLM
|
||||
can frame stale tasks correctly."""
|
||||
from fabledassistant.services.journal_prep import _render_sections_for_prompt
|
||||
|
||||
sections = {
|
||||
"tasks_due_today": [],
|
||||
"tasks_upcoming": [],
|
||||
"tasks_overdue": [{
|
||||
"id": 2, "title": "Research Weston's ADHD Evaluation",
|
||||
"status": "todo", "priority": "high",
|
||||
"due_date": "2026-02-20", "days_overdue": 68,
|
||||
}],
|
||||
}
|
||||
rendered = _render_sections_for_prompt(sections)
|
||||
assert "OVERDUE TASKS" in rendered
|
||||
assert "68 days ago" in rendered
|
||||
assert "2026-02-20" in rendered
|
||||
# Crucially, NOT framed as due today.
|
||||
assert "DUE TODAY" not in rendered
|
||||
|
||||
|
||||
def test_render_due_today_no_due_date_repetition():
|
||||
"""Tasks in the DUE TODAY bucket don't need the (due 2026-04-29)
|
||||
parenthetical — the section header already says 'today'."""
|
||||
from fabledassistant.services.journal_prep import _render_sections_for_prompt
|
||||
|
||||
sections = {
|
||||
"tasks_due_today": [{
|
||||
"id": 1, "title": "Pick up dry cleaning",
|
||||
"status": "todo", "priority": "none",
|
||||
"due_date": "2026-04-29",
|
||||
}],
|
||||
"tasks_upcoming": [],
|
||||
"tasks_overdue": [],
|
||||
}
|
||||
rendered = _render_sections_for_prompt(sections)
|
||||
assert "TASKS DUE TODAY" in rendered
|
||||
assert "Pick up dry cleaning" in rendered
|
||||
# The line itself shouldn't repeat the due date.
|
||||
line = next(
|
||||
(l for l in rendered.splitlines() if "Pick up dry cleaning" in l),
|
||||
"",
|
||||
)
|
||||
assert "2026-04-29" not in line
|
||||
|
||||
|
||||
def test_render_three_buckets_in_correct_order():
|
||||
"""When all three buckets have content, the prompt sees them in
|
||||
DUE TODAY → UPCOMING → OVERDUE order so the LLM leads with today."""
|
||||
from fabledassistant.services.journal_prep import _render_sections_for_prompt
|
||||
|
||||
sections = {
|
||||
"tasks_due_today": [{"id": 1, "title": "Today task", "status": "todo", "priority": "none", "due_date": "2026-04-29"}],
|
||||
"tasks_upcoming": [{"id": 2, "title": "Upcoming task", "status": "todo", "priority": "none", "due_date": "2026-05-02"}],
|
||||
"tasks_overdue": [{"id": 3, "title": "Stale task", "status": "todo", "priority": "none", "due_date": "2026-02-20", "days_overdue": 68}],
|
||||
}
|
||||
rendered = _render_sections_for_prompt(sections)
|
||||
today_idx = rendered.index("TASKS DUE TODAY")
|
||||
upcoming_idx = rendered.index("UPCOMING TASKS")
|
||||
overdue_idx = rendered.index("OVERDUE TASKS")
|
||||
assert today_idx < upcoming_idx < overdue_idx
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for the record_moment server-side data-hygiene guards.
|
||||
|
||||
These guard against two failure modes observed in real journal usage:
|
||||
|
||||
1. The LLM emits ``task_titles`` referencing a task that's only in the
|
||||
prep context — not actually mentioned by the user. Without a check,
|
||||
every moment ends up linked to whatever's open in the user's queue.
|
||||
|
||||
2. The LLM emits generic placeholder ``place_names`` like ``"work"`` /
|
||||
``"home"`` instead of real place notes. These role-labels aren't
|
||||
places.
|
||||
|
||||
Both are exercised through the pure helpers; full-stack handler tests
|
||||
would require a session-bound DB fixture this suite doesn't have yet.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_content_keywords_drops_stopwords_and_short_tokens():
|
||||
from fabledassistant.services.tools.journal import _content_keywords
|
||||
|
||||
kw = _content_keywords(
|
||||
"I went to the store to pick up milk and bread for the kids."
|
||||
)
|
||||
# Stopwords (the, to, for, and, i) gone; short tokens (kids? 4 chars - kept)
|
||||
# filtered. Real content words kept.
|
||||
assert "store" in kw
|
||||
assert "milk" in kw
|
||||
assert "bread" in kw
|
||||
assert "kids" in kw
|
||||
assert "the" not in kw
|
||||
assert "to" not in kw
|
||||
assert "for" not in kw
|
||||
assert "i" not in kw
|
||||
|
||||
|
||||
def test_content_keywords_extracts_named_entities():
|
||||
"""Place names and project nouns survive tokenization. Short numeric
|
||||
fragments (e.g. '14') get filtered alongside other <3-char tokens —
|
||||
the surrounding alpha keywords carry the overlap weight in practice."""
|
||||
from fabledassistant.services.tools.journal import _content_keywords
|
||||
|
||||
kw = _content_keywords("Migration prep at Branch 14 Bedford.")
|
||||
assert "branch" in kw
|
||||
assert "bedford" in kw
|
||||
assert "migration" in kw
|
||||
|
||||
|
||||
def test_filter_placeholder_places_drops_generic_role_labels():
|
||||
from fabledassistant.services.tools.journal import _filter_placeholder_places
|
||||
|
||||
kept, dropped = _filter_placeholder_places(
|
||||
["work", "Famous Supply", "home", "Branch 14 Bedford", "the office"]
|
||||
)
|
||||
assert "Famous Supply" in kept
|
||||
assert "Branch 14 Bedford" in kept
|
||||
assert "work" in dropped
|
||||
assert "home" in dropped
|
||||
assert "the office" in dropped
|
||||
|
||||
|
||||
def test_filter_placeholder_places_is_case_insensitive():
|
||||
from fabledassistant.services.tools.journal import _filter_placeholder_places
|
||||
|
||||
kept, dropped = _filter_placeholder_places(["WORK", "Home", "OFFICE"])
|
||||
assert kept == []
|
||||
assert set(dropped) == {"WORK", "Home", "OFFICE"}
|
||||
|
||||
|
||||
def test_filter_placeholder_places_preserves_real_places_named_similarly():
|
||||
"""A user-defined place that happens to be ONE word but isn't a generic
|
||||
role-label (e.g. 'Akron') stays."""
|
||||
from fabledassistant.services.tools.journal import _filter_placeholder_places
|
||||
|
||||
kept, dropped = _filter_placeholder_places(["Akron", "Cleveland"])
|
||||
assert kept == ["Akron", "Cleveland"]
|
||||
assert dropped == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_drops_unrelated_task_link():
|
||||
"""The reported failure mode: model attached Weston's ADHD task to a
|
||||
Docker-swarm moment. With the keyword guard, the link gets dropped."""
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
# Mock the DB lookup to return the offending task title for id=2.
|
||||
fake_session = AsyncMock()
|
||||
fake_session.execute = AsyncMock()
|
||||
fake_session.execute.return_value.all = lambda: [
|
||||
(2, "Research Weston's ADHD Evaluation"),
|
||||
]
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.journal.async_session",
|
||||
return_value=fake_session,
|
||||
):
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1,
|
||||
content="Working on restaging Docker in the swarm; encountered network breakage on a Windows node.",
|
||||
task_ids=[2],
|
||||
)
|
||||
|
||||
assert kept == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_keeps_genuinely_referenced_task():
|
||||
"""When the moment content shares a meaningful keyword with the task
|
||||
title, the link is preserved."""
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.execute = AsyncMock()
|
||||
fake_session.execute.return_value.all = lambda: [
|
||||
(5, "Restage Docker on Fam-dockerwin04"),
|
||||
]
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.journal.async_session",
|
||||
return_value=fake_session,
|
||||
):
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1,
|
||||
content="Reinstalled Microsoft container support; Docker restage now works.",
|
||||
task_ids=[5],
|
||||
)
|
||||
|
||||
# 'docker' appears in both → keep
|
||||
assert kept == [5]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_handles_partial_relevance():
|
||||
"""Mixed ids: keep the related one, drop the unrelated one."""
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.execute = AsyncMock()
|
||||
fake_session.execute.return_value.all = lambda: [
|
||||
(2, "Research Weston's ADHD Evaluation"),
|
||||
(5, "Restage Docker on Fam-dockerwin04"),
|
||||
]
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.journal.async_session",
|
||||
return_value=fake_session,
|
||||
):
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1,
|
||||
content="Working on restaging Docker in the swarm.",
|
||||
task_ids=[2, 5],
|
||||
)
|
||||
|
||||
assert kept == [5]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_empty_task_ids_returns_empty():
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1, content="anything", task_ids=[],
|
||||
)
|
||||
assert kept == []
|
||||
Reference in New Issue
Block a user