Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 057b30b89e | |||
| a3071a53cb | |||
| 4faaa5246b | |||
| 0ed9cbf666 | |||
| 4668c0950b | |||
| b728acd841 | |||
| c5498273c3 | |||
| 590a07bc13 | |||
| c9f2134ad4 | |||
| dda62265c9 | |||
| 297f2252a3 | |||
| dbd9f00061 | |||
| cacfcac86a | |||
| 873e70c7ea | |||
| 0c91ab026d | |||
| ce76a003f7 | |||
| ac188c40a5 | |||
| d9ab538ef4 | |||
| 7602bf2293 | |||
| d352e9264b | |||
| 6752765e2b | |||
| 0fe7141949 | |||
| b20d6dec66 | |||
| e652dece9b |
@@ -57,7 +57,7 @@ permissions:
|
||||
|
||||
env:
|
||||
REGISTRY: git.fabledsword.com
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledscribe
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Fabled Assistant
|
||||
# Fabled Scribe
|
||||
|
||||
A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware.
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""rename briefing_date to day_date and drop existing briefing conversations
|
||||
|
||||
Revision ID: 0040
|
||||
Revises: 0039
|
||||
Create Date: 2026-04-25
|
||||
|
||||
This is a hard-cut migration accompanying the Journal feature. The user
|
||||
has accepted destruction of existing briefing data per the design spec.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0040"
|
||||
down_revision = "0039"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DELETE FROM conversations WHERE conversation_type = 'briefing'")
|
||||
op.alter_column("conversations", "briefing_date", new_column_name="day_date")
|
||||
op.execute("DELETE FROM settings WHERE key = 'briefing_config'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("conversations", "day_date", new_column_name="briefing_date")
|
||||
@@ -0,0 +1,128 @@
|
||||
"""add moments + junction tables + moment embeddings
|
||||
|
||||
Revision ID: 0041
|
||||
Revises: 0040
|
||||
Create Date: 2026-04-25
|
||||
|
||||
People, Places, Tasks, and Notes all live in the `notes` table (distinguished
|
||||
by note_type and is_task). The four junction tables FK to notes(id) but stay
|
||||
separate so per-link-kind queries don't require a discriminator filter.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
|
||||
|
||||
revision = "0041"
|
||||
down_revision = "0040"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"moments",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"conversation_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("conversations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"source_message_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("day_date", sa.Date, nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"recorded_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("raw_excerpt", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"tags",
|
||||
ARRAY(sa.Text),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::text[]"),
|
||||
),
|
||||
sa.Column(
|
||||
"pinned",
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_moments_user_day", "moments", ["user_id", "day_date"])
|
||||
op.create_index("ix_moments_user_occurred", "moments", ["user_id", "occurred_at"])
|
||||
|
||||
# Four junction tables, all FK to notes(id). Separate (vs. one merged
|
||||
# table with a discriminator) so per-kind queries don't need a filter.
|
||||
for table_name, fk_col in [
|
||||
("moment_people", "person_id"),
|
||||
("moment_places", "place_id"),
|
||||
("moment_tasks", "task_id"),
|
||||
("moment_notes", "note_id"),
|
||||
]:
|
||||
op.create_table(
|
||||
table_name,
|
||||
sa.Column(
|
||||
"moment_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("moments.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
fk_col,
|
||||
sa.Integer,
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
op.create_index(f"ix_{table_name}_{fk_col}", table_name, [fk_col])
|
||||
|
||||
op.create_table(
|
||||
"moment_embeddings",
|
||||
sa.Column(
|
||||
"moment_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("moments.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column("user_id", sa.Integer, nullable=False),
|
||||
sa.Column("embedding", JSONB, nullable=False),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_moment_embeddings_user", "moment_embeddings", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_moment_embeddings_user", table_name="moment_embeddings")
|
||||
op.drop_table("moment_embeddings")
|
||||
for table_name, fk_col in [
|
||||
("moment_notes", "note_id"),
|
||||
("moment_tasks", "task_id"),
|
||||
("moment_places", "place_id"),
|
||||
("moment_people", "person_id"),
|
||||
]:
|
||||
op.drop_index(f"ix_{table_name}_{fk_col}", table_name=table_name)
|
||||
op.drop_table(table_name)
|
||||
op.drop_index("ix_moments_user_occurred", table_name="moments")
|
||||
op.drop_index("ix_moments_user_day", table_name="moments")
|
||||
op.drop_table("moments")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""drop RSS infrastructure (tables + leftover briefing settings)
|
||||
|
||||
Revision ID: 0042
|
||||
Revises: 0041
|
||||
Create Date: 2026-04-26
|
||||
|
||||
Hard-cut removal of the RSS feature. Drops rss_feeds, rss_items,
|
||||
rss_item_reactions, rss_item_embeddings, and clears the leftover
|
||||
briefing-era settings rows that referenced RSS topic preferences.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0042"
|
||||
down_revision = "0041"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop in dependency order — children first.
|
||||
op.execute("DROP TABLE IF EXISTS rss_item_embeddings CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_item_reactions CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_items CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_feeds CASCADE")
|
||||
op.execute(
|
||||
"DELETE FROM settings WHERE key IN "
|
||||
"('rss_enabled', 'briefing_include_topics', 'briefing_exclude_topics')"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No downgrade — this is a destructive cleanup. The original RSS tables
|
||||
# would have to be recreated by replaying migrations 0026, 0028, 0035,
|
||||
# 0038, 0039 manually (and the data would not come back).
|
||||
raise NotImplementedError("RSS feature is permanently removed in 0042")
|
||||
@@ -1,4 +1,4 @@
|
||||
# Fabled Assistant — Quick Start
|
||||
# Fabled Scribe — Quick Start
|
||||
#
|
||||
# No build required. Pulls the latest pre-built image from the registry.
|
||||
#
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
services:
|
||||
app:
|
||||
image: git.fabledsword.com/bvandeusen/fabledassistant:latest
|
||||
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
# FabledSword Design System
|
||||
|
||||
A house-style design system for the FabledSword family of self-hosted applications. FabledSword is the umbrella identity; individual apps share a common visual language but each carries its own signature accent color.
|
||||
|
||||
## Brand model
|
||||
|
||||
FabledSword is a **house style**, not a single brand. Apps share:
|
||||
|
||||
- Typography
|
||||
- Surfaces and dark-mode foundation
|
||||
- Component shapes (pills, cards, buttons)
|
||||
- Spacing system
|
||||
- Semantic colors (success, warning, error, info)
|
||||
- Action button colors (moss primary, bronze secondary)
|
||||
- Voice and tone
|
||||
|
||||
Each public app has its own **signature accent** used for: the wordmark, the app icon, active nav state, "you are here" indicators, cursor/selection color, and key brand moments. Accents do **not** appear on action buttons — those stay system-wide.
|
||||
|
||||
## Aesthetic direction
|
||||
|
||||
Modern mythic with heraldic restraint. Tech-forward execution, but the visual language borrows from manuscripts, heraldry, and forged objects rather than from gaming or fantasy iconography. Dark-mode-first because that's where these apps live.
|
||||
|
||||
The reference points: a well-printed book, a well-kept armory, a steward's ledger. Not: a fantasy novel cover, a tabletop RPG character sheet, a Renaissance Faire poster.
|
||||
|
||||
---
|
||||
|
||||
## Color system
|
||||
|
||||
### Universal surfaces (dark mode foundation)
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Obsidian | `#14171A` | Page background, deepest surface |
|
||||
| Iron | `#1E2228` | Card surfaces, raised elements |
|
||||
| Slate | `#2C313A` | Hovered surfaces, secondary elevation |
|
||||
| Pewter | `#3F4651` | Borders, dividers, ghost button outlines |
|
||||
|
||||
### Universal text and parchment
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Parchment | `#E8E4D8` | Primary text on dark surfaces |
|
||||
| Vellum | `#C2BFB4` | Secondary text, captions |
|
||||
| Ash | `#9C9A92` | Tertiary text, hints, metadata |
|
||||
|
||||
Parchment is intentionally not pure white — it's slightly warm to feel like aged paper. Pure white (#FFFFFF) is never used as text color.
|
||||
|
||||
### Universal action colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Moss | `#4A5D3F` | Primary action buttons (Save, Submit, Confirm) |
|
||||
| Bronze | `#8B7355` | Secondary action buttons (Cancel-but-not-destructive, alternate paths) |
|
||||
| Pewter | `#3F4651` | Tertiary/ghost buttons |
|
||||
|
||||
**Critical rule:** Action button colors are universal across all apps. A Save button in Scribe and a Save button in Minstrel look identical. Per-app accents do not appear on buttons.
|
||||
|
||||
### Semantic colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Success | `#4A5D3F` | Success states (same as Moss — they're aligned by design) |
|
||||
| Warning | `#8B6F1E` | Warnings, caution states |
|
||||
| Error | `#C04A1F` | Error messages, validation failures |
|
||||
| Info | `#3D5A6E` | Informational callouts |
|
||||
| Destructive | `#6B2118` | Destructive action buttons (Delete, Remove, irreversible) |
|
||||
|
||||
**Why error and destructive are different:** Error is the orange-red used in alerts and validation messages. Destructive (oxblood) is reserved for buttons that perform irreversible actions — it carries more weight precisely because it's used sparingly. Pair destructive buttons with an icon (trash, X) so color is reinforcement, not the only signal.
|
||||
|
||||
### Per-app signature accents
|
||||
|
||||
| App | Hex | Mood |
|
||||
|---|---|---|
|
||||
| Fabled Scribe | `#5B4A8A` | Dusty violet — ink, manuscripts |
|
||||
| Minstrel | `#4A6B5C` | Forest teal — music, performance |
|
||||
| Fabled Forge | `#8B5A2B` | Forge bronze — creation, craft |
|
||||
| Roundtable | `#4A5D7E` | Slate blue — stewardship, infrastructure |
|
||||
| FabledSword (umbrella) | `#6B2118` | Oxblood — house identity, ceremonial use only |
|
||||
|
||||
**Accent usage rules:**
|
||||
- The accent appears on the app's wordmark and icon.
|
||||
- The accent indicates active/current state in nav (the selected page, the active tab).
|
||||
- The accent is the cursor color and text-selection color in long-form surfaces (Scribe notes, Forge story drafts).
|
||||
- The accent does NOT appear on primary or secondary action buttons.
|
||||
- The accent does NOT appear in body text or chrome.
|
||||
- One accent per app. Don't mix accents within a single app.
|
||||
|
||||
### Color contrast
|
||||
|
||||
All text-on-surface combinations meet WCAG AA at minimum. Parchment on Obsidian is the maximum-contrast pairing; Vellum on Iron is the lowest-contrast pairing still considered acceptable for body text. Ash is for hints only — never load-bearing information.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Type families
|
||||
|
||||
| Family | Role | Source |
|
||||
|---|---|---|
|
||||
| Fraunces | Display, headings, wordmarks | Google Fonts |
|
||||
| Inter | Body, UI, labels | Google Fonts |
|
||||
| JetBrains Mono | Code, terminal output, monospaced data | Google Fonts |
|
||||
|
||||
### Why this pairing
|
||||
|
||||
Fraunces is a contemporary serif with personality — it has the warmth and authority of a book serif without feeling like costume. It signals "this is considered" without signaling "this is a fantasy product." Inter is the workhorse — neutral, ubiquitous, designed for screens, doesn't compete with the serif. JetBrains Mono is the natural choice for any developer-adjacent product and supports ligatures.
|
||||
|
||||
### Type scale
|
||||
|
||||
| Token | Size | Weight | Family | Usage |
|
||||
|---|---|---|---|---|
|
||||
| Display | 40px | 500 | Fraunces | App wordmark, hero text |
|
||||
| H1 | 32px | 500 | Fraunces | Page titles |
|
||||
| H2 | 24px | 500 | Fraunces | Section headings |
|
||||
| H3 | 18px | 500 | Inter | Subsection headings, card titles |
|
||||
| Body | 15px | 400 | Inter | Paragraphs, default text |
|
||||
| Body small | 13px | 400 | Inter | Captions, metadata |
|
||||
| Label | 12px | 500 | Inter | Buttons, form labels, badges |
|
||||
| Code | 13px | 400 | JetBrains Mono | Inline code, code blocks |
|
||||
| Tiny | 11px | 500 | Inter | Micro-labels (`UPPERCASE LETTERSPACED`) |
|
||||
|
||||
### Typography rules
|
||||
|
||||
- **Sentence case everywhere.** Never Title Case for headings, never ALL CAPS except for the Tiny micro-label style.
|
||||
- **Two weights only:** 400 regular and 500 medium. Never 600 or 700 — they read heavy in dark mode.
|
||||
- **Fraunces only at 18px and above.** Below that it loses too much detail and feels fragile. For h3 and below, use Inter.
|
||||
- **Line height** 1.5 for body, 1.3 for headings, 1.7 for long-form reading surfaces (Scribe notes, Forge drafts).
|
||||
- **Letter-spacing** at default for everything except the Tiny micro-label, which gets `0.08em` letter-spacing and uppercase styling.
|
||||
|
||||
---
|
||||
|
||||
## Spacing and layout
|
||||
|
||||
### Spacing scale (px)
|
||||
|
||||
`4, 8, 12, 16, 20, 24, 32, 48, 64, 96`
|
||||
|
||||
Use rem units for vertical rhythm in long-form content (paragraph spacing). Use px for component-internal spacing (padding, gaps).
|
||||
|
||||
### Border radius
|
||||
|
||||
| Token | Size | Usage |
|
||||
|---|---|---|
|
||||
| Small | 4px | Pills, tags, code spans |
|
||||
| Medium | 8px | Buttons, inputs, small cards |
|
||||
| Large | 12px | Cards, panels, modals |
|
||||
| Extra large | 16px | Hero containers, major surfaces |
|
||||
|
||||
### Borders
|
||||
|
||||
- Default border: `0.5px solid Pewter` (#3F4651)
|
||||
- Hovered/emphasized border: `0.5px solid Vellum` at 30% opacity
|
||||
- Featured/active border: `2px solid [accent]` (only for emphasizing a selected card or active tab)
|
||||
|
||||
The 0.5px default is deliberate — it reads as a hairline at most pixel densities and avoids the heavy "boxed-in" feeling that 1px+ borders create on dark backgrounds.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Buttons
|
||||
|
||||
| Variant | Background | Text | Border |
|
||||
|---|---|---|---|
|
||||
| Primary | Moss `#4A5D3F` | Parchment | None |
|
||||
| Secondary | Bronze `#8B7355` | Parchment | None |
|
||||
| Ghost | Transparent | Parchment | 0.5px Pewter |
|
||||
| Destructive | Oxblood `#6B2118` | Parchment | None — pair with icon |
|
||||
|
||||
Padding: `8px 16px` for default, `6px 12px` for compact, `10px 20px` for prominent. Border-radius: 8px. Font: Inter 12px/500 with default letter-spacing.
|
||||
|
||||
### Pills and tags
|
||||
|
||||
Used for tags, hashtags, code spans, status badges. Background is the accent color at ~15% opacity, text is the accent at full strength. Border-radius 4px, padding `2px 8px`, Inter 11px/500.
|
||||
|
||||
In Scribe specifically, hashtags and tags use the dusty violet accent. In Minstrel, they'd use forest teal. The pattern is shared; the color follows the app.
|
||||
|
||||
### Cards
|
||||
|
||||
- Background: Iron (#1E2228)
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 12px
|
||||
- Padding: 20px
|
||||
|
||||
For featured/selected cards, swap to a 2px solid accent border. Don't change the background.
|
||||
|
||||
### Inputs
|
||||
|
||||
- Background: Obsidian (#14171A) — darker than the page surface to feel "inset"
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 8px
|
||||
- Padding: 8px 12px
|
||||
- Focus state: 2px solid accent ring (using `box-shadow: 0 0 0 2px [accent]` to avoid layout shift)
|
||||
|
||||
### Code blocks
|
||||
|
||||
- Background: Obsidian (#14171A)
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 8px
|
||||
- Padding: 12px 16px
|
||||
- Font: JetBrains Mono 13px/400
|
||||
- Inline code: same family, with 4px-radius pill background using the app accent at 15% opacity
|
||||
|
||||
---
|
||||
|
||||
## The FabledSword lockup
|
||||
|
||||
A small, persistent FS mark appears in the navigation chrome of every app — the way an Apple logo persists across macOS apps. This is the only place oxblood appears in normal app usage.
|
||||
|
||||
**Specification:**
|
||||
- 16-20px height in nav contexts
|
||||
- Oxblood (#6B2118) on dark surfaces
|
||||
- Positioned in the bottom-left of nav rails or top-left when there's no rail
|
||||
- Hover/click reveals a small menu: link to other apps in the family, link to FabledSword.com, version info
|
||||
|
||||
The lockup itself is a small heraldic mark — a stylized FS monogram — *not* a literal sword icon. We're avoiding sword imagery in app chrome because it would clash with the restrained, modern-mythic aesthetic. The wordmark "FabledSword" appears only on the umbrella site and in About/Settings dialogs.
|
||||
|
||||
---
|
||||
|
||||
## Voice and tone
|
||||
|
||||
The FabledSword voice is **understated mythic** — it borrows the register of stewardship, craft, and considered making, but never tips into roleplay or affectation.
|
||||
|
||||
### Do
|
||||
|
||||
- Use plain language for everything functional. ("Save", "Cancel", "Add note")
|
||||
- Reserve flavored language for moments where the user is *waiting* or *failing* — loading states, empty states, error pages, 404s.
|
||||
- Borrow vocabulary from craft and stewardship: "draft", "ledger", "kept", "set aside", "to come", "in progress", "abandoned".
|
||||
- Be brief. The mythic register is undermined by verbosity.
|
||||
|
||||
### Don't
|
||||
|
||||
- Don't use thee/thou/thy or pseudo-archaic spelling.
|
||||
- Don't address the user as "traveler", "wanderer", "adventurer", or any RPG-adjacent epithet.
|
||||
- Don't use sword/blade/forge metaphors in error messages. ("Your save was forged successfully" — no.)
|
||||
- Don't make the user feel like they're playing a game when they're just trying to use software.
|
||||
|
||||
### Examples
|
||||
|
||||
| Context | Plain | FabledSword voice |
|
||||
|---|---|---|
|
||||
| Empty list | "No items yet" | "Nothing kept here yet." |
|
||||
| 404 | "Page not found" | "This page is not in the ledger." |
|
||||
| Loading | "Loading..." | "Fetching..." (just keep it plain — the mythic note is reserved for moments with more space) |
|
||||
| Save success | "Saved!" | "Saved." (plain — success doesn't need flavor) |
|
||||
| Save error | "Error saving" | "Couldn't save. The change has been kept locally — try again in a moment." |
|
||||
| Delete confirm | "Delete this?" | "Remove this from the ledger? This can't be undone." |
|
||||
|
||||
The pattern: action-adjacent language stays plain; absence/failure/waiting gets the flavor.
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
### Style
|
||||
|
||||
- Stroke-based, 1.5px stroke weight at 24px, 1px at 16px
|
||||
- Rounded line caps and joins
|
||||
- 24px or 16px grid
|
||||
- Outline style by default; filled style only for active/selected states
|
||||
|
||||
Use **Lucide** (https://lucide.dev) as the base icon set — it matches this style exactly and is open-source. Only commission custom icons for app-specific concepts that Lucide doesn't cover.
|
||||
|
||||
### Don't
|
||||
|
||||
- No filled icons in default UI (reserve for active states)
|
||||
- No icon styles that mix stroke and fill chaotically
|
||||
- No literal medieval imagery (swords, scrolls with curls, banners) in functional UI
|
||||
- No emoji as icons
|
||||
|
||||
---
|
||||
|
||||
## Per-app application
|
||||
|
||||
### Fabled Scribe (#5B4A8A — dusty violet)
|
||||
|
||||
A second-brain notes and task management tool. The accent appears in: the wordmark, hashtags and tag pills, the active nav item, text selection color, and the cursor in the editor. Notes are presented on Iron-surfaced cards with generous reading line-height. The hashtag system uses Scribe's accent for visual continuity.
|
||||
|
||||
### Minstrel (#4A6B5C — forest teal)
|
||||
|
||||
Self-hosted music. The accent appears on: now-playing indicators, active track highlights, the wordmark, equalizer/visualization elements. Album art dominates visually, so the accent should appear in chrome and metadata, never overlapping cover imagery.
|
||||
|
||||
### Fabled Forge (#8B5A2B — forge bronze)
|
||||
|
||||
Story-building and worldbuilding tool. The accent appears on: the wordmark, character/location/object markers in story trees, the editor cursor, "kept/canon" indicators distinguishing finalized story elements from drafts. This app benefits from Fraunces being used more aggressively — for entity titles, chapter headings, etc.
|
||||
|
||||
### Roundtable (#4A5D7E — slate blue)
|
||||
|
||||
Home server management. The accent appears on: the wordmark, healthy/online status indicators, the active dashboard panel border. Status colors here are critical — green for healthy, amber for warning, red (the orange-red error tone, not oxblood) for failed. The accent itself indicates "this is the panel I'm currently looking at."
|
||||
|
||||
**Naming note:** "Roundtable" leans the wrong direction — its connotation is *equal participants in discussion* rather than *one steward managing a domain*. Consider "Steward" or "Castellan" if you revisit naming. Castellan in particular is good — it specifically means "the officer in charge of a castle."
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes
|
||||
|
||||
### CSS custom properties
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--fs-obsidian: #14171A;
|
||||
--fs-iron: #1E2228;
|
||||
--fs-slate: #2C313A;
|
||||
--fs-pewter: #3F4651;
|
||||
|
||||
/* Text */
|
||||
--fs-parchment: #E8E4D8;
|
||||
--fs-vellum: #C2BFB4;
|
||||
--fs-ash: #9C9A92;
|
||||
|
||||
/* Action */
|
||||
--fs-moss: #4A5D3F;
|
||||
--fs-bronze: #8B7355;
|
||||
|
||||
/* Semantic */
|
||||
--fs-warning: #8B6F1E;
|
||||
--fs-error: #C04A1F;
|
||||
--fs-info: #3D5A6E;
|
||||
--fs-oxblood: #6B2118;
|
||||
|
||||
/* Typography */
|
||||
--fs-font-display: 'Fraunces', Georgia, serif;
|
||||
--fs-font-body: 'Inter', system-ui, sans-serif;
|
||||
--fs-font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
/* Layout */
|
||||
--fs-radius-sm: 4px;
|
||||
--fs-radius-md: 8px;
|
||||
--fs-radius-lg: 12px;
|
||||
--fs-radius-xl: 16px;
|
||||
}
|
||||
|
||||
/* Per-app accent — set ONE of these on the root for each app */
|
||||
[data-app="scribe"] { --fs-accent: #5B4A8A; }
|
||||
[data-app="minstrel"] { --fs-accent: #4A6B5C; }
|
||||
[data-app="forge"] { --fs-accent: #8B5A2B; }
|
||||
[data-app="roundtable"]{ --fs-accent: #4A5D7E; }
|
||||
```
|
||||
|
||||
### Tailwind integration
|
||||
|
||||
If using Tailwind, extend the theme with these tokens rather than relying on default colors. The default Tailwind palette will fight this system — you'll get drift back toward bright defaults if you don't lock down the palette explicitly.
|
||||
|
||||
---
|
||||
|
||||
## What this kit deliberately does NOT include
|
||||
|
||||
- **Logo files.** The lockup design is described conceptually but the actual mark needs to be drawn. Hire a designer or use Claude Design to iterate on a heraldic FS monogram.
|
||||
- **Marketing site design.** This kit is for application UI. The umbrella marketing site (FabledSword.com) can use this system but will need additional patterns (hero layouts, feature grids, etc.).
|
||||
- **Email templates.** Different constraints, different problem.
|
||||
- **Print collateral.** Not in scope.
|
||||
- **Mobile native app patterns.** This is web-first. iOS/Android conventions would override several choices here (button shapes, navigation patterns).
|
||||
|
||||
---
|
||||
|
||||
*Last updated: April 25, 2026. Iterate as the family of apps grows.*
|
||||
|
||||
---
|
||||
|
||||
# Scribe-specific decisions in progress
|
||||
|
||||
> This section tracks decisions made while adapting the FabledSword baseline above for Scribe specifically. Items here are *in progress* — once they feel solid, they get folded into the main body of the document (either as Scribe-specific extensions in the per-app section, or as updates to the universal rules where Scribe's needs reveal a gap in the baseline).
|
||||
|
||||
*Iteration started: 2026-04-26.*
|
||||
|
||||
## Decisions made so far
|
||||
|
||||
### Accent footprint — Hybrid rule (not Strict)
|
||||
|
||||
The doc baseline says the per-app accent only appears on wordmark, active nav, cursor, and text selection — never on action buttons. Scribe currently uses indigo on essentially every interactive surface (CTAs, scrollbars, glows, borders, focus rings). Hard-cutting to the doc baseline would lose too much identity in one swing.
|
||||
|
||||
**Hybrid rule:** the accent reserves a slightly larger footprint than the doc baseline, but still much smaller than today.
|
||||
|
||||
- **Accent (dusty violet) lives on:** wordmark; active nav; cursor and text selection in editor surfaces; tags/pills/wikilinks; in-progress task badge; focus rings; **brand-moment CTAs** — chat Send, "Create note" empty-state CTA, journal Send, "Start journaling" empty-state.
|
||||
- **Moss (sage-green primary) lives on:** Save / Submit / Confirm in forms and modals; generic affirmative actions where the button just means "do this thing" with no brand pretense.
|
||||
- **Bronze (secondary):** Cancel-but-not-destructive, alternative paths.
|
||||
- **Oxblood (destructive):** Delete / Remove (paired with an icon).
|
||||
- **Pewter ghost:** tertiary actions, "later", "skip", "see also".
|
||||
|
||||
**Rule of thumb:** if the user is engaging with a *Scribe-feature moment* (sending a chat, opening a fresh note, jumping into the journal), accent. If they're just *operating the software* (saving an edit, confirming a dialog), Moss.
|
||||
|
||||
### Light mode — warm parchment, matched aesthetic
|
||||
|
||||
The doc is dark-only. Scribe today supports both light and dark, and we keep both. The light mode is derived to *match* the dark mode aesthetic rather than defaulting to system white-and-ink.
|
||||
|
||||
- Page background: in the `#F5F1E8` warm cream family (specific values TBD)
|
||||
- Cards: near-white but slightly tinted
|
||||
- Text: deep ink `#14171A` (mirroring Obsidian)
|
||||
- Accent: same dusty violet `#5B4A8A` (works on both themes)
|
||||
|
||||
The metaphor stays consistent across themes: ink on aged paper (light) ↔ parchment text on graphite (dark). Light mode is *not* the system standard look.
|
||||
|
||||
**Known downside:** warm parchment backgrounds can fight with embedded color content. Mitigation: code blocks get a slight cool wash in light mode specifically, to keep syntax highlighting readable.
|
||||
|
||||
### Status and priority palette — extend the doc's semantic set
|
||||
|
||||
The doc's semantic colors (Success / Warning / Error / Info / Destructive) are leaner than what Scribe needs for task management. Rather than running a parallel palette, Scribe extends the doc by mapping its status/priority tokens onto doc primitives where they fit and defining new app-level tokens for the rest.
|
||||
|
||||
**Status (task lifecycle):**
|
||||
|
||||
| Token | Color | Source | Logic |
|
||||
|---|---|---|---|
|
||||
| `status-todo` | Pewter `#3F4651` | doc | Neutral, "not started yet" |
|
||||
| `status-in-progress` | dusty violet `#5B4A8A` | accent | Active = brand moment per Hybrid |
|
||||
| `status-done` | Moss `#4A5D3F` | doc Success | Affirmative completion |
|
||||
| `status-cancelled` | Ash `#9C9A92` | doc | Faded, "let go" |
|
||||
| `status-paused` | Warning `#8B6F1E` | doc | Stalled, needs attention — replaces the old warm gold treatment |
|
||||
|
||||
**Priority (loudness scale):**
|
||||
|
||||
| Token | Color | Source | Logic |
|
||||
|---|---|---|---|
|
||||
| `priority-low` | Info `#3D5A6E` | doc | Cool, FYI — quietest end of the spectrum |
|
||||
| `priority-medium` | Warning `#8B6F1E` | doc | Golden, mid-attention |
|
||||
| `priority-high` | Error `#C04A1F` | doc | Terracotta, urgent |
|
||||
| `priority-none` | Vellum/Ash | doc | No signal |
|
||||
|
||||
The priority row reads as a clean cool→warm gradient (slate blue → golden brown → terracotta), which matches the semantic loudness — coherence the current ad-hoc palette doesn't have.
|
||||
|
||||
**Other functional tokens:**
|
||||
|
||||
| Token | Color | Logic |
|
||||
|---|---|---|
|
||||
| `wikilink` | dusty violet | Editorial brand moment per Hybrid |
|
||||
| `overdue` | Error `#C04A1F` | Same as priority-high — overdue IS a priority signal |
|
||||
| `toast-success` | Moss | doc semantic |
|
||||
| `toast-error` | Error | doc semantic |
|
||||
| `toast-info` | Info | doc semantic |
|
||||
| `tag-bg` / `tag-text` | accent at 15% / accent | Per doc pill recipe |
|
||||
|
||||
Each token gets a `*-bg` companion at low alpha (matching the existing pattern in `theme.css`).
|
||||
|
||||
**Removed:** the warm gold accent (`--color-accent-warm: #b8860b`). Its two jobs split:
|
||||
|
||||
- Dates and timestamps (knowledge cards, event details, chat) → use `text-secondary` instead. Dates are metadata, not a brand surface; muted is the correct register.
|
||||
- Paused project status → use the new `status-paused` (Warning `#8B6F1E`) row above. Same golden-brown family, semantically aligned.
|
||||
|
||||
### Typography — adopt the doc's stack and scale
|
||||
|
||||
Adopt the doc's type stack and scale verbatim, with one deferred verification (long-form line-height in practice).
|
||||
|
||||
- **Body font: Inter.** Replaces Scribe's current system-stack body font. Doc-defined; no Scribe-specific divergence.
|
||||
- **Type scale:** as in the doc table — Display 40 / H1 32 / H2 24 / H3 18 / Body 15 / Body small 13 / Label 12 / Code 13 / Tiny 11.
|
||||
- **Two weights only:** 400 regular, 500 medium. No 600/700 (reads heavy in dark mode and against the muted palette).
|
||||
- **Family rules:** Fraunces at 18px+ only (Display, H1, H2). H3 and below = Inter. Code = JetBrains Mono.
|
||||
- **Line height:** 1.5 body, 1.3 headings, **1.7 for long-form reading surfaces** (notes, journal entries).
|
||||
- **Sentence case for everything**, except the Tiny micro-label style which gets uppercase + 0.08em letter-spacing.
|
||||
|
||||
Mechanical rollout — value swaps in `theme.css` plus loading Inter and JetBrains Mono from Google Fonts (Fraunces is already loaded).
|
||||
|
||||
### Light mode — concrete palette
|
||||
|
||||
Fills in the warm-parchment direction picked earlier. Treat these as starting values; tune in practice.
|
||||
|
||||
| Token | Hex | Role |
|
||||
|---|---|---|
|
||||
| Page bg | `#F5F1E8` | Warm cream — the "paper" |
|
||||
| Card bg | `#FBF8F0` | Near-white, slightly warm — raised surfaces |
|
||||
| Inset bg (inputs, code) | `#EFEAE0` | Slightly darker than page, "inset" feeling |
|
||||
| Text primary | `#14171A` | Deep ink — Obsidian inverted |
|
||||
| Text secondary | `#5A5852` | Warm mid-grey |
|
||||
| Text muted | `#9A9890` | Warm light grey for hints/metadata |
|
||||
| Border default | `#D9D6CE` | Warm light pewter, hairline weight |
|
||||
|
||||
**Code-block exception:** in light mode specifically, code blocks use a slight cool wash (e.g. `#EBEDF0`) instead of the warm inset bg, so syntax highlighting reads cleanly. This is the mitigation for the "warm bg fights colored content" downside.
|
||||
|
||||
The accent (`#5B4A8A` dusty violet), Moss, Bronze, Oxblood, and the semantic color set are **identical across themes** — only the surface and text palettes flip.
|
||||
|
||||
### Chat-bubble codification — keep the Illuminated Transcript pattern
|
||||
|
||||
The existing chat-bubble pattern (informally called "Illuminated Transcript") gets written into the design system as a documented chat component. Other apps in the family that add a chat surface inherit the pattern; Scribe's existing implementation continues to work with only color shifts.
|
||||
|
||||
**User bubble (whisper):**
|
||||
- Background: transparent
|
||||
- Border: 0.5px Pewter (was: indigo-tinted)
|
||||
- Text color: secondary (Vellum dark / `#5A5852` light)
|
||||
- Right-aligned, rounded except bottom-right (subtle "from-me" tail)
|
||||
|
||||
**Assistant bubble (lit):**
|
||||
- Background: card surface (Iron dark / `#FBF8F0` light)
|
||||
- Border: none on top/right/bottom; **2px solid accent (dusty violet) on left edge only**
|
||||
- Box-shadow: accent-tinted glow + standard depth shadow (formula: `0 4px 28px rgba(<accent>, 0.14), 0 2px 8px rgba(0,0,0,0.4)` in dark; lower alphas in light)
|
||||
- Text color: primary (Parchment dark / Obsidian-inverted light)
|
||||
- Left-aligned, rounded except bottom-left
|
||||
|
||||
The 2px-accent left edge is the "illumination" — like an illuminated capital in a manuscript. The shadow is the lift. Together they make the assistant bubble read as the *primary* voice, while the user bubble is the *margin note*.
|
||||
|
||||
**Inline tool-call cards (`ToolCallCard`)** rendered inside an assistant bubble do NOT get their own border (per the border philosophy — the bubble already contains them). They use a slight surface tint to differentiate.
|
||||
|
||||
### Iconography — adopt Lucide, enforce a scale
|
||||
|
||||
Scribe currently hand-inlines SVG paths in 16+ Vue files, with 5 different stroke weights and 8+ different sizes. The visual style is already outline + rounded caps + `currentColor` stroke (matches the doc's intent), but there's no shared source and no scale discipline.
|
||||
|
||||
**Migration policy:**
|
||||
|
||||
1. **Install `lucide-vue-next`** as the icon source. Replace hand-inlined SVGs with imported components. Single source of truth.
|
||||
2. **Strict size scale: 16px and 24px only.** Today's mix of 12/13/14/15/17/18/20 collapses to those two. 16 for inline-with-text and small affordances; 24 for nav and primary actions.
|
||||
3. **Stroke weight per the doc: 1.5 at 24px, 1 at 16px.** Lighter than the current default of 2 — reads more refined, matches the muted palette philosophy. Overrides Lucide's default.
|
||||
4. **Outline by default; filled only for active/selected state.** Introduces a new affordance Scribe doesn't currently use — bookmark/pin/star icons can switch outline → filled to indicate active state. Reserve filled style strictly for this.
|
||||
5. **No emoji in chrome.** Replace the 3 files' emoji usage in UI labels/buttons/badges/empty states with Lucide equivalents. Emoji remain fine in *user content* (note bodies, chat messages the user typed).
|
||||
|
||||
Work cost: ~30-60 individual icon swaps across the 16 files. Mechanical; doesn't require redesign of any component.
|
||||
|
||||
### Voice and tone — adopt principles, defer formal audit
|
||||
|
||||
The doc's voice register applies to Scribe (understated mythic — plain for functional UI, flavored for empty/error/loading states). No formal sweep of every UI string yet.
|
||||
|
||||
**Approach:** apply the voice opportunistically as components are touched in the polish pass — when redesigning a settings tab, an empty state, or an error toast, rewrite the copy at the same time using the doc's register and examples table as the guide. A standalone audit pass is deferred unless drift becomes visible.
|
||||
|
||||
### Border philosophy — structural, not decorative
|
||||
|
||||
The doc treats borders as *structural* (Pewter neutral hairlines that say "boundary"), not decorative (Scribe today uses indigo-tinted borders that say "branded edge"). That principle suggests removing borders in places where surface tint and spacing already communicate separation.
|
||||
|
||||
**Borders to remove:**
|
||||
|
||||
- List rows (NotesListView, TasksListView, conversation history) — surface contrast + spacing should separate rows; current border reads as "boxed-in"
|
||||
- Inline `ToolCallCard` inside chat bubbles — the bubble is already a container; an extra border feels like double-wrapping
|
||||
- Filter chips and search-bar pills with a background tint — background does the work
|
||||
- Empty-state callouts with dashed/bordered "nothing here yet" boxes — tinted background reads cleaner
|
||||
|
||||
**Borders to keep (genuinely structural):**
|
||||
|
||||
- Standalone card containers (Notes viewer, Task viewer, the new daily prep card)
|
||||
- Modal / dialog edges
|
||||
- Code blocks (separates content type, not just space)
|
||||
- Focus rings (accessibility)
|
||||
- Major section dividers within a panel
|
||||
|
||||
Border weight is not load-bearing for Scribe — happy to use the doc's 0.5px hairline default; the *placement* discipline matters more than the weight.
|
||||
|
||||
## Open threads (next iterations)
|
||||
|
||||
*All initial-iteration threads resolved. Next phase is the polish pass — applying the system to existing UI, component by component. New threads will accumulate here as we discover gaps in the polish pass.*
|
||||
+127
-135
@@ -1,16 +1,16 @@
|
||||
"""Fable MCP server — exposes Fable Assistant as MCP tools via stdio transport."""
|
||||
"""Fable MCP server — exposes Fable Scribe as MCP tools via stdio transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, briefing
|
||||
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, journal
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_INSTRUCTIONS = """
|
||||
Fable Assistant is a self-hosted second-brain and project management system with LLM integration.
|
||||
Fabled Scribe is a self-hosted second-brain and project management system with LLM integration.
|
||||
|
||||
## Data model
|
||||
|
||||
@@ -68,10 +68,19 @@ Use the direct CRUD tools when:
|
||||
Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting
|
||||
its main body. Suitable for recording work sessions, decisions, or status updates over time.
|
||||
|
||||
## RSS / Briefing
|
||||
## Journal
|
||||
|
||||
Fable runs a daily briefing that summarises tasks, calendar events, and RSS feed items.
|
||||
Use `fable_add_rss_feed` / `fable_remove_rss_feed` to manage the feeds included in that briefing.
|
||||
Fable Scribe runs a per-day Journal — a conversational surface where the user narrates
|
||||
their day. Each day has its own conversation. The first assistant message in a day's
|
||||
conversation is the **daily prep**: an LLM-generated briefing covering today's tasks,
|
||||
calendar events, weather, active projects, and recent journal context. Subsequent turns
|
||||
are user/assistant journaling exchanges; the LLM may emit **Moments** (small structured
|
||||
extractions) via the `record_moment` tool during the conversation.
|
||||
|
||||
Use `fable_get_today_journal` to inspect today's prep + conversation. Use
|
||||
`fable_get_journal_day` for past days. Use `fable_list_moments` to query the structured
|
||||
journal extractions across days. Use `fable_trigger_journal_prep` to force-regenerate
|
||||
today's prep prose.
|
||||
|
||||
## Admin logs
|
||||
|
||||
@@ -582,148 +591,131 @@ async def fable_get_app_logs(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Briefing / RSS
|
||||
# Journal — daily prep, day payloads, moments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_rss_feeds() -> dict:
|
||||
"""List all RSS/Atom feeds configured in Fable for the current user.
|
||||
async def fable_get_today_journal() -> dict:
|
||||
"""Fetch today's Journal day payload.
|
||||
|
||||
Returns id, title, url, category, and last_fetched_at for each feed.
|
||||
These feeds are summarised in the user's daily briefing.
|
||||
Creates today's journal conversation and generates the daily prep
|
||||
message if neither exists yet. Returns:
|
||||
{
|
||||
"day_date": "YYYY-MM-DD",
|
||||
"conversation": { id, title, conversation_type, day_date, ... },
|
||||
"messages": [ ... ordered list of messages ... ]
|
||||
}
|
||||
|
||||
The first assistant message is the daily prep — a conversational
|
||||
opener generated by the LLM from gathered tasks/events/weather/
|
||||
projects/recent moments/open threads. Its ``msg_metadata.sections``
|
||||
carries the underlying structured data for inspection.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.list_rss_feeds(client)
|
||||
return await journal.get_today_journal(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_add_rss_feed(
|
||||
url: str,
|
||||
title: str = "",
|
||||
category: str = "",
|
||||
async def fable_get_journal_day(iso_date: str) -> dict:
|
||||
"""Fetch a specific day's Journal payload by ISO date.
|
||||
|
||||
Args:
|
||||
iso_date: YYYY-MM-DD format date.
|
||||
|
||||
Returns the same shape as fable_get_today_journal. If no journal
|
||||
exists for that day, ``conversation`` and ``messages`` will be
|
||||
null/empty respectively.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await journal.get_journal_day(client, iso_date=iso_date)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_journal_days() -> dict:
|
||||
"""List dates that have journal content for the current user, newest first.
|
||||
|
||||
Returns ``{"days": ["YYYY-MM-DD", ...]}``. Use these dates to query
|
||||
specific days via ``fable_get_journal_day``.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await journal.list_journal_days(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_trigger_journal_prep(iso_date: str = "") -> dict:
|
||||
"""Force-regenerate the daily prep prose for today (or a specific day).
|
||||
|
||||
The prep is the first assistant message in a day's journal — a
|
||||
conversational LLM-generated briefing built from tasks/events/weather/
|
||||
projects/recent moments/open threads. Use this to iterate on the prep
|
||||
prompt or refresh after data changes.
|
||||
|
||||
Args:
|
||||
iso_date: Optional YYYY-MM-DD. If empty, regenerates today.
|
||||
|
||||
Returns ``{"ok": true, "message_id": ...}``.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await journal.trigger_journal_prep(
|
||||
client, iso_date=iso_date or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_journal_config() -> dict:
|
||||
"""Fetch the user's journal config.
|
||||
|
||||
Includes prep schedule (prep_enabled / prep_hour / prep_minute),
|
||||
day rollover hour, phase boundaries, and any locations / temp_unit
|
||||
used for the prep's weather section.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await journal.get_journal_config(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_moments(
|
||||
query: str = "",
|
||||
person_id: int = 0,
|
||||
place_id: int = 0,
|
||||
tag: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
pinned_only: bool = False,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
"""Add an RSS or Atom feed to Fable's daily briefing.
|
||||
"""Search/list journal Moments — small structured extractions the LLM emits during journaling.
|
||||
|
||||
All filters are optional and combinable. Without a query, returns
|
||||
moments ordered by occurred_at DESC. With a query, returns
|
||||
semantically-ranked moments above the similarity threshold.
|
||||
|
||||
Args:
|
||||
url: The RSS/Atom feed URL (required).
|
||||
title: Optional display name. If omitted, auto-populated from feed metadata.
|
||||
category: Optional category label to group feeds (e.g. "news", "tech", "finance").
|
||||
query: Optional semantic query string.
|
||||
person_id: Filter to moments mentioning this person (0 = no filter).
|
||||
place_id: Filter to moments mentioning this place (0 = no filter).
|
||||
tag: Filter to moments with this tag.
|
||||
date_from: ISO YYYY-MM-DD lower bound (inclusive).
|
||||
date_to: ISO YYYY-MM-DD upper bound (inclusive).
|
||||
pinned_only: If True, only return pinned moments.
|
||||
limit: Max results, default 50.
|
||||
|
||||
Returns the created feed object including its assigned id.
|
||||
Returns ``{"moments": [...]}`` where each moment has id, day_date,
|
||||
occurred_at, content, raw_excerpt, tags, people, places, task_ids,
|
||||
note_ids, pinned, and (when query set) score.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.add_rss_feed(
|
||||
return await journal.list_moments(
|
||||
client,
|
||||
url=url,
|
||||
title=title or None,
|
||||
category=category or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_remove_rss_feed(feed_id: int) -> dict:
|
||||
"""Remove an RSS feed from Fable by its ID."""
|
||||
async with FableClient() as client:
|
||||
return await briefing.remove_rss_feed(client, feed_id=feed_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Briefing introspection & control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_briefings() -> dict:
|
||||
"""List the user's briefing conversations, newest first.
|
||||
|
||||
Each briefing has an associated date and conversation id. Use
|
||||
``fable_get_briefing_messages`` or ``fable_get_conversation`` with
|
||||
the id to pull the actual briefing text and tool-call receipts.
|
||||
|
||||
Returns a dict with a ``conversations`` list.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.list_briefing_conversations(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_today_briefing() -> dict:
|
||||
"""Fetch today's briefing conversation, creating it if needed.
|
||||
|
||||
Returns the full conversation object including all messages — the
|
||||
scheduled briefing assistant turns, any tool calls the agentic path
|
||||
made, and any chat replies the user has sent in the briefing thread.
|
||||
|
||||
Use this to inspect what a briefing actually said (and what tool
|
||||
results grounded it) without having to query by id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.get_today_briefing(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_briefing_messages(conversation_id: int) -> dict:
|
||||
"""Fetch all messages for a specific briefing conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The briefing conversation id from fable_list_briefings.
|
||||
|
||||
Returns a dict with a ``messages`` list. Each message includes
|
||||
role, content, tool_calls (with results), and metadata — the
|
||||
metadata carries ``briefing_slot`` tags on agentic briefing turns.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.get_briefing_messages(
|
||||
client, conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_trigger_briefing(slot: str = "compilation") -> dict:
|
||||
"""Manually run a briefing slot for the current user.
|
||||
|
||||
Fires the same data refresh the scheduler does (RSS, weather),
|
||||
runs the agentic briefing pipeline, and writes the result into
|
||||
today's briefing conversation. Use this to test prompt changes
|
||||
without waiting for the next scheduled slot.
|
||||
|
||||
Args:
|
||||
slot: One of ``compilation`` (full morning, default), ``morning``,
|
||||
``midday``, or ``afternoon``.
|
||||
|
||||
Returns a dict with ``conversation_id``, ``message_id``, and ``slot``.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.trigger_briefing(client, slot=slot)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_reset_today_briefing(run_compilation: bool = True) -> dict:
|
||||
"""Wipe today's briefing and (optionally) regenerate from scratch.
|
||||
|
||||
Deletes every message in today's briefing conversation — the
|
||||
conversation row itself is kept so its id stays stable for any
|
||||
open UI sessions. If ``run_compilation`` is True (the default),
|
||||
immediately fires the compilation slot afterward so a fresh
|
||||
briefing lands in place of the deleted content.
|
||||
|
||||
Use this when iterating on briefing prompts or tools and you want
|
||||
to start from a clean slate rather than append another slot update
|
||||
on top of stale output.
|
||||
|
||||
Args:
|
||||
run_compilation: When True, fire ``POST /api/briefing/trigger``
|
||||
for the ``compilation`` slot immediately after wiping.
|
||||
Set False to only wipe without regenerating.
|
||||
|
||||
Returns a dict with ``reset`` (the delete result: deleted count +
|
||||
conversation id) and ``triggered`` (the new message payload, or
|
||||
null if regeneration was skipped).
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.reset_today_briefing(
|
||||
client, run_compilation=run_compilation,
|
||||
query=query or None,
|
||||
person_id=person_id if person_id else None,
|
||||
place_id=place_id if place_id else None,
|
||||
tag=tag or None,
|
||||
date_from=date_from or None,
|
||||
date_to=date_to or None,
|
||||
pinned_only=pinned_only,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@@ -734,18 +726,18 @@ async def fable_reset_today_briefing(run_compilation: bool = True) -> dict:
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_conversation(conversation_id: int) -> dict:
|
||||
"""Fetch any conversation (chat or briefing) with its full message list.
|
||||
"""Fetch any conversation (chat or journal) with its full message list.
|
||||
|
||||
Returns conversation metadata plus an ordered ``messages`` array.
|
||||
Each message includes role, content, tool_calls (with results),
|
||||
context_note_id, and msg_metadata. Tool calls are in the stored
|
||||
flat format: ``[{"function": name, "arguments": {...}, "result": {...}}]``.
|
||||
|
||||
Useful for debugging agentic briefings, inspecting chat history,
|
||||
or verifying that a tool actually ran with the expected arguments.
|
||||
Useful for inspecting journal preps, chat history, or verifying
|
||||
that a tool actually ran with the expected arguments.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.get_conversation(
|
||||
return await journal.get_conversation(
|
||||
client, conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""MCP tools for Fable briefings and RSS feed management."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
# ── RSS feeds ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_rss_feeds(client: FableClient) -> dict[str, Any]:
|
||||
"""List the user's RSS feeds."""
|
||||
return await client.get("/api/briefing/feeds")
|
||||
|
||||
|
||||
async def add_rss_feed(
|
||||
client: FableClient,
|
||||
*,
|
||||
url: str,
|
||||
title: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a new RSS feed. Title is optional — auto-populated from feed metadata."""
|
||||
payload: dict[str, Any] = {"url": url}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
if category:
|
||||
payload["category"] = category
|
||||
return await client.post("/api/briefing/feeds", json=payload)
|
||||
|
||||
|
||||
async def remove_rss_feed(client: FableClient, *, feed_id: int) -> dict[str, Any]:
|
||||
"""Remove an RSS feed by ID."""
|
||||
return await client.delete(f"/api/briefing/feeds/{feed_id}")
|
||||
|
||||
|
||||
# ── Briefings ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_briefing_conversations(client: FableClient) -> dict[str, Any]:
|
||||
"""List the user's briefing conversations, newest first."""
|
||||
return await client.get("/api/briefing/conversations")
|
||||
|
||||
|
||||
async def get_today_briefing(client: FableClient) -> dict[str, Any]:
|
||||
"""Fetch today's briefing conversation with all messages."""
|
||||
return await client.get("/api/briefing/conversations/today")
|
||||
|
||||
|
||||
async def get_briefing_messages(
|
||||
client: FableClient,
|
||||
*,
|
||||
conversation_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch messages for a specific briefing conversation."""
|
||||
return await client.get(f"/api/briefing/conversations/{conversation_id}/messages")
|
||||
|
||||
|
||||
async def trigger_briefing(
|
||||
client: FableClient,
|
||||
*,
|
||||
slot: str = "compilation",
|
||||
) -> dict[str, Any]:
|
||||
"""Manually trigger a briefing slot — fires data refresh and runs the pipeline.
|
||||
|
||||
Slot is one of: compilation (full morning), morning, midday, afternoon.
|
||||
"""
|
||||
return await client.post("/api/briefing/trigger", json={"slot": slot})
|
||||
|
||||
|
||||
async def reset_today_briefing(
|
||||
client: FableClient,
|
||||
*,
|
||||
run_compilation: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Delete all messages in today's briefing and optionally regenerate.
|
||||
|
||||
Wipes the messages in today's briefing conversation (keeping the
|
||||
conversation row), then, if ``run_compilation`` is True, fires the
|
||||
compilation slot so a fresh briefing lands in its place.
|
||||
"""
|
||||
reset = await client.post("/api/briefing/reset-today")
|
||||
if not run_compilation:
|
||||
return {"reset": reset, "triggered": None}
|
||||
triggered = await client.post(
|
||||
"/api/briefing/trigger", json={"slot": "compilation"}
|
||||
)
|
||||
return {"reset": reset, "triggered": triggered}
|
||||
|
||||
|
||||
# ── Generic conversations ───────────────────────────────────────────────────
|
||||
|
||||
async def get_conversation(
|
||||
client: FableClient,
|
||||
*,
|
||||
conversation_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch any conversation (chat or briefing) with all messages and tool calls."""
|
||||
return await client.get(f"/api/chat/conversations/{conversation_id}")
|
||||
@@ -0,0 +1,96 @@
|
||||
"""MCP tools for inspecting and controlling the Fable Scribe Journal."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
# ── Day payloads ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_today_journal(client: FableClient) -> dict[str, Any]:
|
||||
"""Fetch today's journal day payload (creates today's conversation + prep if needed)."""
|
||||
return await client.get("/api/journal/today")
|
||||
|
||||
|
||||
async def get_journal_day(client: FableClient, *, iso_date: str) -> dict[str, Any]:
|
||||
"""Fetch a specific day's journal payload by ISO date (YYYY-MM-DD)."""
|
||||
return await client.get(f"/api/journal/day/{iso_date}")
|
||||
|
||||
|
||||
async def list_journal_days(client: FableClient) -> dict[str, Any]:
|
||||
"""List dates that have journal content for the current user."""
|
||||
return await client.get("/api/journal/days")
|
||||
|
||||
|
||||
# ── Daily prep ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def trigger_journal_prep(
|
||||
client: FableClient,
|
||||
*,
|
||||
iso_date: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Force-regenerate the daily prep for today (or a specific day)."""
|
||||
payload: dict[str, Any] = {}
|
||||
if iso_date:
|
||||
payload["date"] = iso_date
|
||||
return await client.post("/api/journal/trigger-prep", json=payload)
|
||||
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_journal_config(client: FableClient) -> dict[str, Any]:
|
||||
"""Fetch the user's journal config (prep schedule, day rollover, locations, etc.)."""
|
||||
return await client.get("/api/journal/config")
|
||||
|
||||
|
||||
# ── Moments ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def list_moments(
|
||||
client: FableClient,
|
||||
*,
|
||||
query: str | None = None,
|
||||
person_id: int | None = None,
|
||||
place_id: int | None = None,
|
||||
tag: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
pinned_only: bool = False,
|
||||
limit: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
"""List/search journal moments with optional filters.
|
||||
|
||||
All params are optional. Returns a dict with a ``moments`` array.
|
||||
"""
|
||||
params: dict[str, str] = {"limit": str(limit)}
|
||||
if query:
|
||||
params["query"] = query
|
||||
if person_id is not None:
|
||||
params["person_id"] = str(person_id)
|
||||
if place_id is not None:
|
||||
params["place_id"] = str(place_id)
|
||||
if tag:
|
||||
params["tag"] = tag
|
||||
if date_from:
|
||||
params["date_from"] = date_from
|
||||
if date_to:
|
||||
params["date_to"] = date_to
|
||||
if pinned_only:
|
||||
params["pinned_only"] = "true"
|
||||
return await client.get("/api/journal/moments", params=params)
|
||||
|
||||
|
||||
# ── Generic conversation access (still works for journal conversations) ───────
|
||||
|
||||
|
||||
async def get_conversation(
|
||||
client: FableClient,
|
||||
*,
|
||||
conversation_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch any conversation (chat or journal) with its full message list."""
|
||||
return await client.get(f"/api/chat/conversations/{conversation_id}")
|
||||
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
||||
[project]
|
||||
name = "fable-mcp"
|
||||
version = "0.2.6"
|
||||
description = "MCP server for Fabled Assistant"
|
||||
description = "MCP server for Fabled Scribe"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.0",
|
||||
|
||||
Generated
+771
@@ -0,0 +1,771 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fable-mcp"
|
||||
version = "0.2.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "mcp", extra = ["cli"] },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "respx" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0" },
|
||||
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.27.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
cli = [
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "respx"
|
||||
version = "0.23.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.24.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.44.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
|
||||
]
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Fabled">
|
||||
<title>Fabled Assistant</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Scribe">
|
||||
<title>Fabled Scribe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Fabled Assistant",
|
||||
"short_name": "Fabled",
|
||||
"name": "Fabled Scribe",
|
||||
"short_name": "Scribe",
|
||||
"description": "Your self-hosted second brain with AI assistance",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
|
||||
@@ -12,7 +12,7 @@ self.addEventListener('push', event => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return self.registration.showNotification(data.title || 'Fabled Assistant', {
|
||||
return self.registration.showNotification(data.title || 'Scribe', {
|
||||
body: data.body || '',
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
|
||||
+71
-88
@@ -300,141 +300,124 @@ export function apiSSEStream(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Briefing
|
||||
// Journal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BriefingLocation {
|
||||
export interface JournalLocation {
|
||||
label: string;
|
||||
address: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
export interface BriefingSlots {
|
||||
compilation: boolean;
|
||||
morning: boolean;
|
||||
midday: boolean;
|
||||
afternoon: boolean;
|
||||
export interface JournalConfig {
|
||||
prep_enabled: boolean;
|
||||
prep_hour: number;
|
||||
prep_minute: number;
|
||||
day_rollover_hour: number;
|
||||
morning_end_hour?: number;
|
||||
midday_end_hour?: number;
|
||||
// Ambient-context fields (carried forward from the briefing config schema)
|
||||
locations?: { home?: JournalLocation; work?: JournalLocation };
|
||||
temp_unit?: 'C' | 'F';
|
||||
use_caldav_event_locations?: boolean;
|
||||
enabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BriefingConfig {
|
||||
enabled: boolean;
|
||||
locations: {
|
||||
home?: BriefingLocation;
|
||||
work?: BriefingLocation;
|
||||
};
|
||||
use_caldav_event_locations: boolean;
|
||||
work_days: number[];
|
||||
slots: BriefingSlots;
|
||||
notifications: boolean;
|
||||
temp_unit: 'C' | 'F';
|
||||
}
|
||||
|
||||
export interface BriefingFeed {
|
||||
export interface JournalConversation {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
category: string | null;
|
||||
last_fetched_at: string | null;
|
||||
}
|
||||
|
||||
export interface BriefingConversation {
|
||||
id: number;
|
||||
title: string;
|
||||
briefing_date: string | null;
|
||||
model: string;
|
||||
conversation_type: string;
|
||||
day_date: string | null;
|
||||
rag_project_id: number | null;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BriefingMessage {
|
||||
export interface JournalMessage {
|
||||
id: number;
|
||||
conversation_id: number;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status: string;
|
||||
context_note_id: number | null;
|
||||
tool_calls: unknown[] | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
||||
enabled: false,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
temp_unit: 'C',
|
||||
};
|
||||
|
||||
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
||||
try {
|
||||
const data = await apiGet<BriefingConfig>('/api/briefing/config');
|
||||
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
|
||||
} catch {
|
||||
return { ...DEFAULT_BRIEFING_CONFIG };
|
||||
}
|
||||
export interface JournalDayPayload {
|
||||
day_date: string;
|
||||
conversation: JournalConversation | null;
|
||||
messages: JournalMessage[];
|
||||
}
|
||||
|
||||
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
|
||||
await apiPut('/api/briefing/config', config);
|
||||
export interface JournalMoment {
|
||||
id: number;
|
||||
user_id: number;
|
||||
conversation_id: number | null;
|
||||
source_message_id: number | null;
|
||||
day_date: string;
|
||||
occurred_at: string;
|
||||
recorded_at: string;
|
||||
content: string;
|
||||
raw_excerpt: string | null;
|
||||
tags: string[];
|
||||
pinned: boolean;
|
||||
people: { id: number; title: string }[];
|
||||
places: { id: number; title: string }[];
|
||||
task_ids: number[];
|
||||
note_ids: number[];
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
|
||||
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
|
||||
return data;
|
||||
export async function getJournalConfig(): Promise<JournalConfig> {
|
||||
return apiGet<JournalConfig>('/api/journal/config');
|
||||
}
|
||||
|
||||
export async function createBriefingFeed(url: string, category?: string): Promise<BriefingFeed> {
|
||||
const body: Record<string, string> = { url };
|
||||
if (category?.trim()) body.category = category.trim();
|
||||
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body);
|
||||
return { ...data, last_fetched_at: null };
|
||||
export async function saveJournalConfig(config: JournalConfig): Promise<void> {
|
||||
await apiPut('/api/journal/config', config);
|
||||
}
|
||||
|
||||
export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> {
|
||||
return apiPost('/api/briefing/feeds/refresh', {});
|
||||
export async function getJournalToday(): Promise<JournalDayPayload> {
|
||||
return apiGet<JournalDayPayload>('/api/journal/today');
|
||||
}
|
||||
|
||||
export async function deleteBriefingFeed(id: number): Promise<void> {
|
||||
await apiDelete(`/api/briefing/feeds/${id}`);
|
||||
export async function getJournalDay(isoDate: string): Promise<JournalDayPayload> {
|
||||
return apiGet<JournalDayPayload>(`/api/journal/day/${isoDate}`);
|
||||
}
|
||||
|
||||
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
|
||||
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
|
||||
return data.conversations;
|
||||
export async function getJournalDays(): Promise<string[]> {
|
||||
const data = await apiGet<{ days: string[] }>('/api/journal/days');
|
||||
return data.days;
|
||||
}
|
||||
|
||||
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
|
||||
return apiGet('/api/briefing/conversations/today');
|
||||
export async function triggerJournalPrep(date?: string): Promise<{ ok: boolean; message_id: number }> {
|
||||
return apiPost('/api/journal/trigger-prep', date ? { date } : {});
|
||||
}
|
||||
|
||||
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
|
||||
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
|
||||
return data.messages;
|
||||
export async function listJournalMoments(params: Record<string, string | number | boolean> = {}): Promise<JournalMoment[]> {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
|
||||
const data = await apiGet<{ moments: JournalMoment[] }>(`/api/journal/moments?${qs}`);
|
||||
return data.moments;
|
||||
}
|
||||
|
||||
export async function triggerBriefingSlot(slot: string): Promise<void> {
|
||||
await apiPost('/api/briefing/trigger', { slot });
|
||||
export async function updateJournalMoment(id: number, patch: Partial<JournalMoment>): Promise<JournalMoment> {
|
||||
return apiPatch<JournalMoment>(`/api/journal/moments/${id}`, patch);
|
||||
}
|
||||
|
||||
export async function postRssReaction(
|
||||
rssItemId: number,
|
||||
reaction: 'up' | 'down'
|
||||
): Promise<{ ok: boolean; action: string }> {
|
||||
return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction });
|
||||
}
|
||||
|
||||
export async function deleteRssReaction(rssItemId: number): Promise<void> {
|
||||
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
|
||||
}
|
||||
|
||||
export async function openArticleInChat(
|
||||
itemId: number,
|
||||
): Promise<{ conversation_id: number; assistant_message_id?: number; status?: string }> {
|
||||
return apiPost(`/api/chat/from-article/${itemId}`, {});
|
||||
export async function deleteJournalMoment(id: number): Promise<void> {
|
||||
await apiDelete(`/api/journal/moments/${id}`);
|
||||
}
|
||||
|
||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
|
||||
try {
|
||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address });
|
||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/journal/weather/geocode', { query: address });
|
||||
return { lat: r.lat, lon: r.lon, display_name: r.label };
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
|
||||
@@ -13,12 +12,11 @@ const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const isChatActive = computed(() => route.path.startsWith("/chat"))
|
||||
const isKnowledgeActive = computed(() => route.path === "/" || route.path === "/knowledge");
|
||||
const isKnowledgeActive = computed(() => route.path === "/knowledge");
|
||||
|
||||
const mobileMenuOpen = ref(false);
|
||||
|
||||
@@ -76,11 +74,10 @@ router.afterEach(() => {
|
||||
<!-- Center: primary navigation (desktop) -->
|
||||
<div class="nav-center">
|
||||
<div class="nav-pill-bar">
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/journal" class="nav-link">Journal</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link v-if="settingsStore.rssEnabled" to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,12 +122,11 @@ router.afterEach(() => {
|
||||
|
||||
<!-- Mobile dropdown -->
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/journal" class="nav-link">Journal</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link v-if="settingsStore.rssEnabled" to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import {
|
||||
saveBriefingConfig,
|
||||
geocodeAddress,
|
||||
createBriefingFeed,
|
||||
type BriefingConfig,
|
||||
} from '@/api/client'
|
||||
|
||||
const emit = defineEmits<{ done: [] }>()
|
||||
|
||||
const step = ref(1)
|
||||
const TOTAL_STEPS = 4
|
||||
|
||||
const config = reactive<BriefingConfig>({
|
||||
enabled: true,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
temp_unit: 'C',
|
||||
})
|
||||
|
||||
// Step 2 — locations
|
||||
const homeAddress = ref('')
|
||||
const workAddress = ref('')
|
||||
const geocodingHome = ref(false)
|
||||
const geocodingWork = ref(false)
|
||||
const homeConfirmed = ref<string | null>(null)
|
||||
const workConfirmed = ref<string | null>(null)
|
||||
const homeError = ref('')
|
||||
const workError = ref('')
|
||||
|
||||
async function lookupHome() {
|
||||
if (!homeAddress.value.trim()) return
|
||||
geocodingHome.value = true
|
||||
homeError.value = ''
|
||||
try {
|
||||
const r = await geocodeAddress(homeAddress.value.trim())
|
||||
if (r) {
|
||||
config.locations.home = { label: 'Home', address: homeAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||
homeConfirmed.value = r.display_name
|
||||
} else {
|
||||
homeError.value = 'Location not found'
|
||||
}
|
||||
} finally {
|
||||
geocodingHome.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupWork() {
|
||||
if (!workAddress.value.trim()) return
|
||||
geocodingWork.value = true
|
||||
workError.value = ''
|
||||
try {
|
||||
const r = await geocodeAddress(workAddress.value.trim())
|
||||
if (r) {
|
||||
config.locations.work = { label: 'Work', address: workAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||
workConfirmed.value = r.display_name
|
||||
} else {
|
||||
workError.value = 'Location not found'
|
||||
}
|
||||
} finally {
|
||||
geocodingWork.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — work days
|
||||
function toggleDay(d: number) {
|
||||
const idx = config.work_days.indexOf(d)
|
||||
if (idx === -1) config.work_days.push(d)
|
||||
else config.work_days.splice(idx, 1)
|
||||
config.work_days.sort()
|
||||
}
|
||||
|
||||
// Step 4 — RSS feeds
|
||||
const newFeedUrl = ref('')
|
||||
const pendingFeeds = ref<Array<{ url: string }>>([])
|
||||
|
||||
function addPendingFeed() {
|
||||
if (!newFeedUrl.value.trim()) return
|
||||
pendingFeeds.value.push({ url: newFeedUrl.value.trim() })
|
||||
newFeedUrl.value = ''
|
||||
}
|
||||
function removePendingFeed(i: number) { pendingFeeds.value.splice(i, 1) }
|
||||
|
||||
// Finish
|
||||
const finishing = ref(false)
|
||||
async function finish() {
|
||||
finishing.value = true
|
||||
try {
|
||||
await saveBriefingConfig(config)
|
||||
for (const f of pendingFeeds.value) {
|
||||
try { await createBriefingFeed(f.url) } catch { /* best-effort */ }
|
||||
}
|
||||
emit('done')
|
||||
} finally {
|
||||
finishing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="wizard-overlay">
|
||||
<div class="wizard-card" role="dialog" aria-label="Briefing Setup">
|
||||
<!-- Progress bar -->
|
||||
<div class="wizard-progress">
|
||||
<div class="wizard-progress-fill" :style="{ width: `${(step / TOTAL_STEPS) * 100}%` }"></div>
|
||||
</div>
|
||||
<div class="wizard-step-label">Step {{ step }} of {{ TOTAL_STEPS }}</div>
|
||||
|
||||
<!-- Step 1: Welcome -->
|
||||
<div v-if="step === 1" class="wizard-body">
|
||||
<h2 class="wizard-title">Good morning.</h2>
|
||||
<p class="wizard-text">
|
||||
The Briefing is a daily conversation that summarises your day — tasks, calendar,
|
||||
weather, and news — and checks in a few times throughout the day.
|
||||
</p>
|
||||
<p class="wizard-text">
|
||||
It learns from your responses over time and adapts to your schedule.
|
||||
Let's take a minute to set it up.
|
||||
</p>
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-primary" @click="step = 2">Get started</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Locations -->
|
||||
<div v-else-if="step === 2" class="wizard-body">
|
||||
<h2 class="wizard-title">Where are you?</h2>
|
||||
<p class="wizard-text">
|
||||
Enter your home and work addresses. The briefing uses these to fetch weather
|
||||
for the right places. You can skip either one.
|
||||
</p>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label class="wizard-label">Home address</label>
|
||||
<div class="wizard-input-row">
|
||||
<input v-model="homeAddress" class="wizard-input" placeholder="e.g. 123 Main St, Springfield" @keydown.enter="lookupHome" />
|
||||
<button class="btn-wizard-secondary" @click="lookupHome" :disabled="geocodingHome">
|
||||
{{ geocodingHome ? '…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="homeConfirmed" class="wizard-confirmed">✓ {{ homeConfirmed }}</div>
|
||||
<div v-if="homeError" class="wizard-error">{{ homeError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label class="wizard-label">Work address</label>
|
||||
<div class="wizard-input-row">
|
||||
<input v-model="workAddress" class="wizard-input" placeholder="e.g. 456 Office Ave, Portland" @keydown.enter="lookupWork" />
|
||||
<button class="btn-wizard-secondary" @click="lookupWork" :disabled="geocodingWork">
|
||||
{{ geocodingWork ? '…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="workConfirmed" class="wizard-confirmed">✓ {{ workConfirmed }}</div>
|
||||
<div v-if="workError" class="wizard-error">{{ workError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-ghost" @click="step = 1">Back</button>
|
||||
<button class="btn-wizard-primary" @click="step = 3">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Work schedule -->
|
||||
<div v-else-if="step === 3" class="wizard-body">
|
||||
<h2 class="wizard-title">When do you go to the office?</h2>
|
||||
<p class="wizard-text">
|
||||
The 8am slot is labelled "you're at the office" on days you have marked as office days.
|
||||
Toggle any days you typically commute.
|
||||
</p>
|
||||
|
||||
<div class="wizard-days">
|
||||
<button
|
||||
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||
:key="idx"
|
||||
:class="['wizard-day-btn', { active: config.work_days.includes(idx) }]"
|
||||
@click="toggleDay(idx)"
|
||||
type="button"
|
||||
>{{ day }}</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-ghost" @click="step = 2">Back</button>
|
||||
<button class="btn-wizard-primary" @click="step = 4">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: RSS feeds -->
|
||||
<div v-else-if="step === 4" class="wizard-body">
|
||||
<h2 class="wizard-title">What do you follow?</h2>
|
||||
<p class="wizard-text">
|
||||
Add RSS or Atom feeds and the briefing will summarise recent items each morning.
|
||||
You can add or remove feeds any time in Settings.
|
||||
</p>
|
||||
|
||||
<div v-if="pendingFeeds.length" class="wizard-feeds-list">
|
||||
<div v-for="(f, i) in pendingFeeds" :key="i" class="wizard-feed-row">
|
||||
<span class="wizard-feed-url">{{ f.url }}</span>
|
||||
<button class="btn-wizard-remove" @click="removePendingFeed(i)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-add-feed">
|
||||
<input v-model="newFeedUrl" class="wizard-input" placeholder="https://…/feed.xml" style="flex: 1" />
|
||||
<button class="btn-wizard-secondary" @click="addPendingFeed" :disabled="!newFeedUrl.trim()">Add</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions" style="margin-top: 1.5rem">
|
||||
<button class="btn-wizard-ghost" @click="step = 3">Back</button>
|
||||
<button class="btn-wizard-ghost" @click="finish" :disabled="finishing">Skip</button>
|
||||
<button class="btn-wizard-primary" @click="finish" :disabled="finishing">
|
||||
{{ finishing ? 'Setting up…' : 'Enable Briefing' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wizard-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
.wizard-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
width: 520px;
|
||||
max-width: 94vw;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
.wizard-progress {
|
||||
height: 3px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.wizard-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--gradient-cta);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.wizard-step-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
padding: 0.5rem 1.5rem 0;
|
||||
}
|
||||
.wizard-body {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
}
|
||||
.wizard-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.wizard-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.wizard-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.wizard-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.wizard-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wizard-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wizard-input:focus { border-color: var(--color-primary); }
|
||||
.wizard-confirmed {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-success, #22c55e);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.wizard-error {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-danger, #ef4444);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.wizard-days {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.wizard-day-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wizard-day-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.wizard-feeds-list {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.wizard-feed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wizard-feed-url {
|
||||
flex: 1;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-wizard-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-wizard-remove:hover { color: var(--color-danger, #ef4444); }
|
||||
.wizard-add-feed {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn-wizard-primary {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-wizard-secondary {
|
||||
padding: 0.5rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-wizard-ghost {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-ghost:hover { background: var(--color-bg-secondary); }
|
||||
.btn-wizard-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import type { ToolCallRecord } from "@/types/chat";
|
||||
|
||||
const props = defineProps<{
|
||||
toolCalls: ToolCallRecord[];
|
||||
}>();
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
function shortName(fn: string): string {
|
||||
return fn
|
||||
.replace(/^(list|get|search|fetch)_/, "")
|
||||
.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
interface Pill {
|
||||
key: string;
|
||||
name: string;
|
||||
count: string | null;
|
||||
state: "ok" | "empty" | "error";
|
||||
}
|
||||
|
||||
const pills = computed<Pill[]>(() =>
|
||||
props.toolCalls.map((tc, i) => {
|
||||
const ok = tc.result?.success !== false && tc.status !== "error";
|
||||
const data = tc.result?.data as Record<string, unknown> | undefined;
|
||||
let count: string | null = null;
|
||||
let state: Pill["state"] = ok ? "ok" : "error";
|
||||
if (ok && data) {
|
||||
const raw =
|
||||
(typeof data.count === "number" && data.count) ||
|
||||
(typeof data.total === "number" && data.total);
|
||||
if (typeof raw === "number") {
|
||||
count = String(raw);
|
||||
if (raw === 0) state = "empty";
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: `${tc.function}-${i}`,
|
||||
name: shortName(tc.function),
|
||||
count,
|
||||
state,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const errorCount = computed(() => pills.value.filter((p) => p.state === "error").length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="briefing-status" :class="{ expanded }">
|
||||
<button class="briefing-status-toggle" @click="expanded = !expanded">
|
||||
<span class="briefing-status-label">Gathered</span>
|
||||
<span
|
||||
v-for="p in pills"
|
||||
:key="p.key"
|
||||
class="briefing-status-pill"
|
||||
:class="`state-${p.state}`"
|
||||
>
|
||||
{{ p.name }}<span v-if="p.count != null" class="pill-count">{{ p.count }}</span>
|
||||
</span>
|
||||
<span v-if="errorCount" class="briefing-status-errcount">{{ errorCount }} failed</span>
|
||||
<span class="briefing-status-chevron" :class="{ open: expanded }">▶</span>
|
||||
</button>
|
||||
<div v-if="expanded" class="briefing-status-detail">
|
||||
<ToolCallCard
|
||||
v-for="(tc, i) in toolCalls"
|
||||
:key="i"
|
||||
:tool-call="tc"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.briefing-status {
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.briefing-status-toggle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--color-text-muted);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.briefing-status-toggle:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
|
||||
}
|
||||
|
||||
.briefing-status-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.85;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.briefing-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.08rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-card);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.briefing-status-pill.state-empty {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.briefing-status-pill.state-error {
|
||||
border-color: color-mix(in srgb, var(--color-danger, #ef4444) 60%, var(--color-border));
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.pill-count {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.state-error .pill-count {
|
||||
background: color-mix(in srgb, var(--color-danger, #ef4444) 18%, transparent);
|
||||
}
|
||||
|
||||
.briefing-status-errcount {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-danger, #ef4444);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.briefing-status-chevron {
|
||||
margin-left: auto;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.5;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.briefing-status-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.briefing-status-detail {
|
||||
margin-top: 0.4rem;
|
||||
padding-left: 0.55rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
@@ -3,16 +3,8 @@ import { computed } from "vue";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import BriefingToolStatusRow from "@/components/BriefingToolStatusRow.vue";
|
||||
import type { Message } from "@/types/chat";
|
||||
|
||||
const SLOT_LABELS: Record<string, string> = {
|
||||
compilation: "Full Briefing",
|
||||
morning: "Morning Update",
|
||||
midday: "Midday Update",
|
||||
afternoon: "Afternoon Update",
|
||||
};
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -51,26 +43,12 @@ function formatMs(ms: number | null | undefined): string {
|
||||
|
||||
const metadata = computed(() => (props.message.metadata ?? {}) as Record<string, unknown>);
|
||||
|
||||
const isBriefingIntermediate = computed(
|
||||
() => props.message.role === "assistant" && metadata.value.briefing_intermediate === true,
|
||||
);
|
||||
|
||||
const briefingSlot = computed(() => {
|
||||
const slot = metadata.value.briefing_slot;
|
||||
return typeof slot === "string" ? slot : null;
|
||||
});
|
||||
|
||||
const slotLabel = computed(() => {
|
||||
const slot = briefingSlot.value;
|
||||
if (!slot) return null;
|
||||
return SLOT_LABELS[slot] ?? slot;
|
||||
});
|
||||
|
||||
const isSlotUpdate = computed(
|
||||
() =>
|
||||
!isBriefingIntermediate.value &&
|
||||
briefingSlot.value != null &&
|
||||
briefingSlot.value !== "compilation",
|
||||
// Hide LEGACY system-role daily_prep messages from earlier versions. The
|
||||
// current journal prep is a normal assistant message (renders with proper
|
||||
// bubble styling). Old system-role rows lurking in existing conversations
|
||||
// would render as a flat unstyled block — filter them.
|
||||
const hideMessage = computed(() =>
|
||||
props.message.role === "system" && metadata.value.kind === "daily_prep"
|
||||
);
|
||||
|
||||
const timingParts = computed((): string[] => {
|
||||
@@ -89,16 +67,11 @@ const timingParts = computed((): string[] => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Briefing intermediate: compact status row, no bubble -->
|
||||
<div v-if="isBriefingIntermediate" class="chat-message role-assistant briefing-intermediate-row">
|
||||
<BriefingToolStatusRow :tool-calls="message.tool_calls ?? []" />
|
||||
</div>
|
||||
<div v-else class="chat-message" :class="[`role-${message.role}`, { 'slot-update': isSlotUpdate }]">
|
||||
<div v-if="!hideMessage" class="chat-message" :class="`role-${message.role}`">
|
||||
<div class="message-group">
|
||||
<div class="message-bubble" :class="{ 'bubble-slot': isSlotUpdate }">
|
||||
<div class="message-bubble">
|
||||
<div class="message-header">
|
||||
<span class="role-label">{{ roleLabel }}</span>
|
||||
<span v-if="slotLabel" class="slot-badge" :class="{ 'slot-badge-update': isSlotUpdate }">{{ slotLabel }}</span>
|
||||
<div class="message-actions" v-if="message.role === 'assistant' && !isStreaming">
|
||||
<button class="btn-save" @click="emit('saveAsNote', message.id)" title="Save as note">
|
||||
Save as Note
|
||||
@@ -319,38 +292,4 @@ details[open] .thinking-summary::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Briefing intermediate row (no bubble) ──────────────── */
|
||||
.briefing-intermediate-row {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Slot badge (morning/midday/afternoon/full) ─────────── */
|
||||
.slot-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.slot-badge-update {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* ── Slot update bubbles: softer treatment than compilation ── */
|
||||
.slot-update .message-bubble.bubble-slot {
|
||||
background: transparent;
|
||||
border-left: 2px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
.slot-update .message-bubble.bubble-slot .message-content {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,13 +5,16 @@ const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
// Root lands on the journal. To revert to Knowledge as home, change
|
||||
// the redirect target below to "/knowledge" (Knowledge stays a real
|
||||
// route at /knowledge, so the swap is a one-line edit).
|
||||
path: "/",
|
||||
name: "knowledge",
|
||||
component: () => import("@/views/KnowledgeView.vue"),
|
||||
redirect: "/journal",
|
||||
},
|
||||
{
|
||||
path: "/knowledge",
|
||||
redirect: "/",
|
||||
name: "knowledge",
|
||||
component: () => import("@/views/KnowledgeView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
@@ -45,7 +48,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: "/notes",
|
||||
redirect: "/",
|
||||
redirect: "/knowledge",
|
||||
},
|
||||
{
|
||||
path: "/notes/new",
|
||||
@@ -117,14 +120,9 @@ const router = createRouter({
|
||||
component: () => import("@/views/CalendarView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/briefing",
|
||||
name: "briefing",
|
||||
component: () => import("@/views/BriefingView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/news",
|
||||
name: "news",
|
||||
component: () => import("@/views/NewsView.vue"),
|
||||
path: "/journal",
|
||||
name: "journal",
|
||||
component: () => import("@/views/JournalView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
|
||||
@@ -16,10 +16,6 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
() => settings.value.default_model || ""
|
||||
);
|
||||
|
||||
const rssEnabled = computed(
|
||||
() => settings.value.rss_enabled === "true"
|
||||
);
|
||||
|
||||
// Voice status — checked once on login, refreshable from Settings
|
||||
const voiceEnabled = ref(false);
|
||||
const voiceSttReady = ref(false);
|
||||
@@ -66,7 +62,6 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
loading,
|
||||
assistantName,
|
||||
defaultModel,
|
||||
rssEnabled,
|
||||
voiceEnabled,
|
||||
voiceSttReady,
|
||||
voiceTtsReady,
|
||||
|
||||
@@ -1,828 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import ChatPanel from '@/components/ChatPanel.vue'
|
||||
import WeatherCard from '@/components/WeatherCard.vue'
|
||||
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
getBriefingConfig,
|
||||
getBriefingConversations,
|
||||
getBriefingToday,
|
||||
triggerBriefingSlot,
|
||||
postRssReaction,
|
||||
deleteRssReaction,
|
||||
getNewsItems,
|
||||
listEvents,
|
||||
type BriefingConversation,
|
||||
type EventEntry,
|
||||
} from '@/api/client'
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
interface WeatherData {
|
||||
location: string
|
||||
fetched_at: string
|
||||
current_temp: number
|
||||
condition: string
|
||||
today_high: number | null
|
||||
today_low: number | null
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
forecast: { day: string; condition: string; high: number; low: number; precip_probability: number | null; precip_mm: number | null; windspeed_max: number }[]
|
||||
}
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// Setup wizard
|
||||
const showWizard = ref(false)
|
||||
const wizardChecked = ref(false)
|
||||
|
||||
async function checkSetup() {
|
||||
const config = await getBriefingConfig()
|
||||
if (!config.enabled) showWizard.value = true
|
||||
wizardChecked.value = true
|
||||
}
|
||||
|
||||
async function onWizardDone() {
|
||||
showWizard.value = false
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
// Conversations list for the dropdown
|
||||
const conversations = ref<BriefingConversation[]>([])
|
||||
const selectedConvId = ref<number | null>(null)
|
||||
const todayConvId = ref<number | null>(null)
|
||||
|
||||
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
||||
|
||||
// Weather panel
|
||||
const weatherData = ref<WeatherData[]>([])
|
||||
const selectedWeatherIdx = ref(0)
|
||||
const tempUnit = ref<string>('C')
|
||||
|
||||
interface CurrentConditions {
|
||||
temperature: number | null;
|
||||
windspeed: number | null;
|
||||
description: string;
|
||||
precip_next_3h: number[];
|
||||
temp_unit: string;
|
||||
location: string;
|
||||
}
|
||||
const currentConditions = ref<CurrentConditions | null>(null)
|
||||
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadCurrentConditions() {
|
||||
try {
|
||||
currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current')
|
||||
// Patch the live temperature into the WeatherCard so it stays fresh
|
||||
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
|
||||
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
|
||||
}
|
||||
} catch { /* silent — endpoint may not have locations configured */ }
|
||||
}
|
||||
|
||||
async function loadWeather() {
|
||||
try {
|
||||
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
const refreshingWeather = ref(false)
|
||||
async function refreshWeather() {
|
||||
refreshingWeather.value = true
|
||||
try {
|
||||
const data = await apiPost<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather/refresh', {})
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
finally { refreshingWeather.value = false }
|
||||
}
|
||||
|
||||
// Upcoming events (right column, below weather)
|
||||
const upcomingEvents = ref<EventEntry[]>([])
|
||||
|
||||
interface GroupedDay {
|
||||
label: string
|
||||
dateKey: string
|
||||
events: EventEntry[]
|
||||
}
|
||||
|
||||
const groupedEvents = computed<GroupedDay[]>(() => {
|
||||
const groups = new Map<string, EventEntry[]>()
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
for (const ev of upcomingEvents.value) {
|
||||
const d = new Date(ev.start_dt)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(ev)
|
||||
}
|
||||
|
||||
const result: GroupedDay[] = []
|
||||
for (const [key, events] of groups) {
|
||||
const d = new Date(key + 'T00:00:00')
|
||||
const diff = Math.round((d.getTime() - today.getTime()) / 86_400_000)
|
||||
let label: string
|
||||
if (diff === 0) label = 'Today'
|
||||
else if (diff === 1) label = 'Tomorrow'
|
||||
else label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
result.push({ label, dateKey: key, events })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function formatEventTime(ev: EventEntry): string {
|
||||
if (ev.all_day) return 'All day'
|
||||
const d = new Date(ev.start_dt)
|
||||
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const now = new Date()
|
||||
const end = new Date(now)
|
||||
end.setDate(end.getDate() + 14)
|
||||
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// News panel (right column)
|
||||
const newsItems = ref<NewsItem[]>([])
|
||||
|
||||
async function loadNews() {
|
||||
try {
|
||||
const data = await getNewsItems({ days: 2, limit: 40 })
|
||||
newsItems.value = data.items
|
||||
// Seed reactions from API response
|
||||
for (const item of data.items) {
|
||||
if (reactions.value[item.id] === undefined) {
|
||||
reactions.value[item.id] = item.reaction
|
||||
}
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [convList, today] = await Promise.all([
|
||||
getBriefingConversations(),
|
||||
getBriefingToday().catch(() => null),
|
||||
loadWeather(),
|
||||
loadNews(),
|
||||
loadCurrentConditions(),
|
||||
loadEvents(),
|
||||
])
|
||||
conversations.value = convList
|
||||
if (today) {
|
||||
todayConvId.value = today.id
|
||||
if (!convList.find((c) => c.id === today.id)) {
|
||||
conversations.value = [
|
||||
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
|
||||
...convList,
|
||||
]
|
||||
}
|
||||
selectedConvId.value = today.id
|
||||
await chatStore.fetchConversation(today.id)
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedConvId, async (id) => {
|
||||
if (!id) return
|
||||
try {
|
||||
await chatStore.fetchConversation(id)
|
||||
} catch {
|
||||
// Historical conversation unavailable — do nothing
|
||||
}
|
||||
})
|
||||
|
||||
async function discussArticle(item: NewsItem) {
|
||||
if (!todayConvId.value || chatStore.streaming) return
|
||||
if (!isToday.value) selectedConvId.value = todayConvId.value
|
||||
await nextTick(() => {
|
||||
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
try {
|
||||
await apiPost<{ assistant_message_id: number }>(`/api/briefing/articles/${item.id}/discuss`, { conv_id: todayConvId.value })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await chatStore.fetchConversation(todayConvId.value)
|
||||
await chatStore.reconnectIfGenerating(todayConvId.value)
|
||||
}
|
||||
|
||||
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
|
||||
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
||||
|
||||
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
|
||||
const current = reactions.value[itemId]
|
||||
reactions.value[itemId] = current === reaction ? null : reaction
|
||||
try {
|
||||
if (current === reaction) {
|
||||
await deleteRssReaction(itemId)
|
||||
} else {
|
||||
await postRssReaction(itemId, reaction)
|
||||
}
|
||||
} catch {
|
||||
reactions.value[itemId] = current ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diffH = (now.getTime() - d.getTime()) / 3_600_000
|
||||
if (diffH < 24) return `${Math.round(diffH)}h ago`
|
||||
if (diffH < 48) return 'Yesterday'
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// Manual trigger
|
||||
const triggering = ref(false)
|
||||
async function triggerNow() {
|
||||
triggering.value = true
|
||||
try {
|
||||
await triggerBriefingSlot('compilation')
|
||||
// Guard: user may have navigated away during the long compilation
|
||||
if (_mounted) await loadAll()
|
||||
} finally {
|
||||
if (_mounted) triggering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown label
|
||||
function convLabel(c: BriefingConversation): string {
|
||||
if (c.id === todayConvId.value) return 'Today'
|
||||
if (c.briefing_date) {
|
||||
const d = new Date(c.briefing_date)
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
return c.title || 'Briefing'
|
||||
}
|
||||
|
||||
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
||||
async function _backgroundRefreshMessages() {
|
||||
try {
|
||||
const today = await getBriefingToday()
|
||||
if (!today) return
|
||||
if (_mounted && isToday.value && chatStore.currentConversation?.id === todayConvId.value) {
|
||||
await chatStore.fetchConversation(today.id)
|
||||
}
|
||||
await loadNews()
|
||||
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
||||
}
|
||||
|
||||
useBackgroundRefresh(
|
||||
_backgroundRefreshMessages,
|
||||
60_000,
|
||||
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
|
||||
)
|
||||
|
||||
let _mounted = true
|
||||
onUnmounted(() => {
|
||||
_mounted = false
|
||||
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await checkSetup()
|
||||
if (!showWizard.value) {
|
||||
await loadAll()
|
||||
// Poll current conditions every 30 minutes
|
||||
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="briefing-root">
|
||||
<!-- Setup wizard overlay -->
|
||||
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
|
||||
|
||||
<!-- Main view -->
|
||||
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
|
||||
<!-- Header spans all columns -->
|
||||
<header class="briefing-header">
|
||||
<div class="briefing-header-left">
|
||||
<h1 class="briefing-title">Briefing</h1>
|
||||
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
|
||||
</div>
|
||||
<div class="briefing-header-right">
|
||||
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
|
||||
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn-trigger"
|
||||
@click="triggerNow"
|
||||
:disabled="triggering"
|
||||
title="Manually trigger morning briefing now"
|
||||
>{{ triggering ? '…' : 'Refresh' }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Left column: Chat -->
|
||||
<div class="briefing-center">
|
||||
<ChatPanel
|
||||
variant="full"
|
||||
briefingMode
|
||||
:readOnly="!isToday"
|
||||
placeholder="Reply to your briefing…"
|
||||
class="briefing-chat-panel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Weather + News -->
|
||||
<div class="briefing-right">
|
||||
<!-- Weather section (sticky) -->
|
||||
<div class="weather-section" v-if="weatherData.length">
|
||||
<div class="weather-section-header">
|
||||
<div class="weather-tabs" v-if="weatherData.length > 1">
|
||||
<button
|
||||
v-for="(loc, i) in weatherData"
|
||||
:key="(loc as WeatherData).location"
|
||||
class="weather-tab"
|
||||
:class="{ active: selectedWeatherIdx === i }"
|
||||
@click="selectedWeatherIdx = i"
|
||||
>{{ (loc as WeatherData).location }}</button>
|
||||
</div>
|
||||
<button
|
||||
class="weather-refresh-btn"
|
||||
:class="{ spinning: refreshingWeather }"
|
||||
:disabled="refreshingWeather"
|
||||
@click="refreshWeather"
|
||||
title="Refresh weather"
|
||||
>↻</button>
|
||||
</div>
|
||||
<WeatherCard
|
||||
:weather="weatherData[selectedWeatherIdx]"
|
||||
:temp-unit="tempUnit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming events -->
|
||||
<div class="events-section" v-if="groupedEvents.length">
|
||||
<div class="panel-label-row">
|
||||
<div class="panel-label">Upcoming</div>
|
||||
<router-link to="/calendar" class="events-cal-link">Calendar →</router-link>
|
||||
</div>
|
||||
<div class="events-list">
|
||||
<div v-for="group in groupedEvents" :key="group.dateKey" class="events-day-group">
|
||||
<div class="events-day-label">{{ group.label }}</div>
|
||||
<div v-for="ev in group.events" :key="ev.id" class="event-row">
|
||||
<span class="event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
|
||||
<span class="event-body">
|
||||
<span class="event-title">{{ ev.title }}</span>
|
||||
<span class="event-time">{{ formatEventTime(ev) }}</span>
|
||||
<span v-if="ev.location" class="event-loc">{{ ev.location }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- News section (scrollable) -->
|
||||
<div v-if="settingsStore.rssEnabled" class="news-section">
|
||||
<div class="panel-label-row">
|
||||
<div class="panel-label">Today's News</div>
|
||||
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
|
||||
</div>
|
||||
<div v-if="!newsItems.length" class="panel-empty">No articles in the last 2 days</div>
|
||||
<div
|
||||
v-for="item in newsItems"
|
||||
:key="item.id"
|
||||
class="news-card"
|
||||
>
|
||||
<div class="news-card-meta">
|
||||
<span class="news-source">{{ item.source }}</span>
|
||||
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="item.url"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="news-title"
|
||||
>{{ item.title }}</a>
|
||||
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
|
||||
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
||||
<div class="news-reactions">
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'up' }"
|
||||
@click="handleReaction(item.id, 'up')"
|
||||
title="Interested"
|
||||
>👍</button>
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'down' }"
|
||||
@click="handleReaction(item.id, 'down')"
|
||||
title="Not interested"
|
||||
>👎</button>
|
||||
<button
|
||||
v-if="isToday && todayConvId"
|
||||
class="reaction-btn discuss-btn"
|
||||
@click="discussArticle(item)"
|
||||
title="Discuss in briefing chat"
|
||||
>💬</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.briefing-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(320px, 35%);
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-header {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.briefing-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.briefing-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.briefing-today-badge {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.briefing-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.briefing-conv-select {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-trigger {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-trigger:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ─── Left column (Chat) ─────────────────────────────────────────────────── */
|
||||
|
||||
.briefing-center {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-chat-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ─── Right column (Weather + News) ──────────────────────────────────────── */
|
||||
|
||||
.briefing-right {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
border-left: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.weather-section {
|
||||
flex-shrink: 0;
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.weather-section :deep(.weather-card) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.weather-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.45rem;
|
||||
line-height: 1;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.weather-refresh-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.weather-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.weather-refresh-btn.spinning {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.weather-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.weather-tab {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.weather-tab:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.weather-tab.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ─── Upcoming events ─────────────────────────────────────── */
|
||||
.events-section {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.events-cal-link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.events-cal-link:hover { color: var(--color-primary); }
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.events-day-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.events-day-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
.event-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.event-row:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
|
||||
}
|
||||
.event-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.event-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.05rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.event-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.event-time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.event-loc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.news-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-count {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Current conditions (live) ─────────────────────────── */
|
||||
|
||||
.panel-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.news-source {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.news-date {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.35;
|
||||
text-decoration: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
||||
|
||||
.news-snippet {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-reactions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.reaction-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.reaction-btn:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.reaction-btn.active {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.briefing-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
.briefing-center {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
.briefing-right {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,535 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import ChatPanel from '@/components/ChatPanel.vue'
|
||||
import WeatherCard from '@/components/WeatherCard.vue'
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
getJournalToday,
|
||||
getJournalDay,
|
||||
getJournalDays,
|
||||
triggerJournalPrep,
|
||||
listEvents,
|
||||
type EventEntry,
|
||||
} from '@/api/client'
|
||||
|
||||
interface WeatherDay {
|
||||
day: string
|
||||
condition: string
|
||||
high: number
|
||||
low: number
|
||||
precip_probability: number | null
|
||||
precip_mm: number | null
|
||||
windspeed_max: number
|
||||
}
|
||||
interface WeatherData {
|
||||
location: string
|
||||
fetched_at: string
|
||||
current_temp: number
|
||||
condition: string
|
||||
today_high: number | null
|
||||
today_low: number | null
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
forecast: WeatherDay[]
|
||||
}
|
||||
interface CurrentConditions {
|
||||
temperature: number | null
|
||||
windspeed: number | null
|
||||
description: string
|
||||
precip_next_3h: number[]
|
||||
temp_unit: string
|
||||
location: string
|
||||
}
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
// ── Day picker + conversation state ──────────────────────────────────────────
|
||||
const days = ref<string[]>([])
|
||||
const todayDate = ref<string | null>(null)
|
||||
const selectedDay = ref<string | null>(null)
|
||||
const dayConvId = ref<number | null>(null)
|
||||
const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value)
|
||||
|
||||
// ── Weather panel ────────────────────────────────────────────────────────────
|
||||
const weatherData = ref<WeatherData[]>([])
|
||||
const selectedWeatherIdx = ref(0)
|
||||
const tempUnit = ref<string>('C')
|
||||
const currentConditions = ref<CurrentConditions | null>(null)
|
||||
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadCurrentConditions() {
|
||||
try {
|
||||
currentConditions.value = await apiGet<CurrentConditions>('/api/journal/weather/current')
|
||||
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
|
||||
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadWeather() {
|
||||
try {
|
||||
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/journal/weather')
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
const refreshingWeather = ref(false)
|
||||
async function refreshWeather() {
|
||||
refreshingWeather.value = true
|
||||
try {
|
||||
const data = await apiPost<{ locations: WeatherData[]; temp_unit: string }>('/api/journal/weather/refresh', {})
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
finally { refreshingWeather.value = false }
|
||||
}
|
||||
|
||||
// ── Upcoming events ──────────────────────────────────────────────────────────
|
||||
const upcomingEvents = ref<EventEntry[]>([])
|
||||
|
||||
interface GroupedDay {
|
||||
label: string
|
||||
dateKey: string
|
||||
events: EventEntry[]
|
||||
}
|
||||
|
||||
const groupedEvents = computed<GroupedDay[]>(() => {
|
||||
const groups = new Map<string, EventEntry[]>()
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
for (const ev of upcomingEvents.value) {
|
||||
const d = new Date(ev.start_dt)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(ev)
|
||||
}
|
||||
const result: GroupedDay[] = []
|
||||
for (const [key, events] of groups) {
|
||||
const d = new Date(key + 'T00:00:00')
|
||||
const diff = Math.round((d.getTime() - today.getTime()) / 86_400_000)
|
||||
let label: string
|
||||
if (diff === 0) label = 'Today'
|
||||
else if (diff === 1) label = 'Tomorrow'
|
||||
else label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
result.push({ label, dateKey: key, events })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function formatEventTime(ev: EventEntry): string {
|
||||
if (ev.all_day) return 'All day'
|
||||
const d = new Date(ev.start_dt)
|
||||
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const now = new Date()
|
||||
const end = new Date(now)
|
||||
end.setDate(end.getDate() + 14)
|
||||
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Day load + switch ────────────────────────────────────────────────────────
|
||||
async function loadDay(iso: string) {
|
||||
const payload = iso === todayDate.value ? await getJournalToday() : await getJournalDay(iso)
|
||||
if (payload.conversation) {
|
||||
dayConvId.value = payload.conversation.id
|
||||
await chatStore.fetchConversation(payload.conversation.id)
|
||||
} else {
|
||||
dayConvId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const today = await getJournalToday()
|
||||
todayDate.value = today.day_date
|
||||
selectedDay.value = today.day_date
|
||||
if (today.conversation) {
|
||||
dayConvId.value = today.conversation.id
|
||||
await chatStore.fetchConversation(today.conversation.id)
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
|
||||
try {
|
||||
days.value = await getJournalDays()
|
||||
if (todayDate.value && !days.value.includes(todayDate.value)) {
|
||||
days.value = [todayDate.value, ...days.value]
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
|
||||
await Promise.all([loadWeather(), loadCurrentConditions(), loadEvents()])
|
||||
}
|
||||
|
||||
watch(selectedDay, async (iso) => {
|
||||
if (!iso || iso === todayDate.value) return
|
||||
try { await loadDay(iso) } catch { /* silent */ }
|
||||
})
|
||||
|
||||
// ── Manual prep regeneration ─────────────────────────────────────────────────
|
||||
const triggering = ref(false)
|
||||
async function triggerPrep() {
|
||||
if (triggering.value) return
|
||||
triggering.value = true
|
||||
try {
|
||||
await triggerJournalPrep()
|
||||
if (_mounted) await loadAll()
|
||||
} finally {
|
||||
if (_mounted) triggering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function dayLabel(iso: string): string {
|
||||
if (iso === todayDate.value) return 'Today'
|
||||
const d = new Date(iso + 'T00:00:00')
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const todayBadge = computed(() => {
|
||||
return new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
|
||||
})
|
||||
|
||||
// ── Background refresh ───────────────────────────────────────────────────────
|
||||
async function _backgroundRefreshMessages() {
|
||||
try {
|
||||
if (!_mounted || !isToday.value || !dayConvId.value) return
|
||||
await chatStore.fetchConversation(dayConvId.value)
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
useBackgroundRefresh(
|
||||
_backgroundRefreshMessages,
|
||||
60_000,
|
||||
() => !chatStore.streaming && isToday.value && !!dayConvId.value,
|
||||
)
|
||||
|
||||
let _mounted = true
|
||||
onUnmounted(() => {
|
||||
_mounted = false
|
||||
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll()
|
||||
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="journal-root">
|
||||
<div class="journal-shell">
|
||||
<header class="journal-header">
|
||||
<div class="journal-header-left">
|
||||
<h1 class="journal-title">Journal</h1>
|
||||
<span class="journal-today-badge">{{ todayBadge }}</span>
|
||||
</div>
|
||||
<div class="journal-header-right">
|
||||
<select v-if="days.length" v-model="selectedDay" class="journal-day-select">
|
||||
<option v-for="d in days" :key="d" :value="d">{{ dayLabel(d) }}</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn-trigger"
|
||||
@click="triggerPrep"
|
||||
:disabled="triggering || !isToday"
|
||||
title="Regenerate today's daily prep"
|
||||
>{{ triggering ? '…' : 'Refresh prep' }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Center: chat (prep is the first assistant message inside) -->
|
||||
<div class="journal-center">
|
||||
<ChatPanel
|
||||
variant="full"
|
||||
briefingMode
|
||||
:readOnly="!isToday"
|
||||
placeholder="Tell your journal…"
|
||||
class="journal-chat-panel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right: Weather + Events + News -->
|
||||
<div class="journal-right">
|
||||
<div class="weather-section" v-if="weatherData.length">
|
||||
<div class="weather-section-header">
|
||||
<div class="weather-tabs" v-if="weatherData.length > 1">
|
||||
<button
|
||||
v-for="(loc, i) in weatherData"
|
||||
:key="(loc as WeatherData).location"
|
||||
class="weather-tab"
|
||||
:class="{ active: selectedWeatherIdx === i }"
|
||||
@click="selectedWeatherIdx = i"
|
||||
>{{ (loc as WeatherData).location }}</button>
|
||||
</div>
|
||||
<button
|
||||
class="weather-refresh-btn"
|
||||
:class="{ spinning: refreshingWeather }"
|
||||
:disabled="refreshingWeather"
|
||||
@click="refreshWeather"
|
||||
title="Refresh weather"
|
||||
>↻</button>
|
||||
</div>
|
||||
<WeatherCard
|
||||
:weather="weatherData[selectedWeatherIdx]"
|
||||
:temp-unit="tempUnit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="events-section" v-if="groupedEvents.length">
|
||||
<div class="panel-label-row">
|
||||
<div class="panel-label">Upcoming</div>
|
||||
<router-link to="/calendar" class="events-cal-link">Calendar →</router-link>
|
||||
</div>
|
||||
<div class="events-list">
|
||||
<div v-for="group in groupedEvents" :key="group.dateKey" class="events-day-group">
|
||||
<div class="events-day-label">{{ group.label }}</div>
|
||||
<div v-for="ev in group.events" :key="ev.id" class="event-row">
|
||||
<span class="event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
|
||||
<span class="event-body">
|
||||
<span class="event-title">{{ ev.title }}</span>
|
||||
<span class="event-time">{{ formatEventTime(ev) }}</span>
|
||||
<span v-if="ev.location" class="event-loc">{{ ev.location }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.journal-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.journal-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(320px, 35%);
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.journal-header {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.journal-header-left { display: flex; align-items: baseline; gap: 0.75rem; }
|
||||
.journal-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.journal-today-badge { font-size: 0.82rem; color: var(--color-text-muted); }
|
||||
.journal-header-right { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
.journal-day-select {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-trigger {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-trigger:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Center column: prep card + chat ──────────────────────────────────────── */
|
||||
.journal-center {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.journal-chat-panel { flex: 1; min-height: 0; }
|
||||
|
||||
/* ── Right column ─────────────────────────────────────────────────────────── */
|
||||
.journal-right {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
border-left: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.weather-section { flex-shrink: 0; padding: 1rem 1rem 0.5rem; }
|
||||
.weather-section :deep(.weather-card) { margin-bottom: 0; }
|
||||
.weather-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.45rem;
|
||||
line-height: 1;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.weather-refresh-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.weather-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.weather-refresh-btn.spinning { animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.weather-tabs { display: flex; gap: 0.25rem; }
|
||||
.weather-tab {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.weather-tab:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.weather-tab.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.events-section { padding: 0.75rem 1rem; border-top: 1px solid var(--color-border); }
|
||||
.events-cal-link { font-size: 0.75rem; color: var(--color-text-muted); text-decoration: none; }
|
||||
.events-cal-link:hover { color: var(--color-primary); }
|
||||
.events-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.events-day-group { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.events-day-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.event-row { display: flex; align-items: flex-start; gap: 0.4rem; padding: 0.25rem 0.4rem; border-radius: 6px; }
|
||||
.event-row:hover { background: color-mix(in srgb, var(--color-primary) 6%, transparent); }
|
||||
.event-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0; margin-top: 5px;
|
||||
}
|
||||
.event-body { display: flex; flex-direction: column; gap: 0.05rem; min-width: 0; }
|
||||
.event-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.event-time { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.event-loc { font-size: 0.7rem; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.news-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel-label-row { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
|
||||
.news-count { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.panel-empty { font-size: 0.82rem; color: var(--color-text-muted); padding: 0.5rem 0; }
|
||||
|
||||
.news-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.news-card-meta { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.news-source { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-primary); }
|
||||
.news-date { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.news-title { font-size: 0.88rem; font-weight: 600; color: var(--color-text); line-height: 1.35; text-decoration: none; margin: 0; }
|
||||
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
||||
.news-snippet {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.news-reactions { display: flex; gap: 0.3rem; margin-top: 0.15rem; }
|
||||
.reaction-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
.reaction-btn:hover { opacity: 1; border-color: var(--color-primary); }
|
||||
.reaction-btn.active { opacity: 1; border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.journal-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
|
||||
.journal-center { grid-column: 1; grid-row: 2; }
|
||||
.journal-right {
|
||||
grid-column: 1; grid-row: 3;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -410,7 +410,6 @@ onUnmounted(() => {
|
||||
<router-link v-if="overdueCount > 0" to="/tasks" class="overdue-badge">
|
||||
{{ overdueCount }} overdue
|
||||
</router-link>
|
||||
<router-link to="/briefing" class="today-link">Briefing</router-link>
|
||||
<router-link to="/chat" class="today-link">Chat</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
getBriefingFeeds,
|
||||
postRssReaction,
|
||||
deleteRssReaction,
|
||||
getNewsItems,
|
||||
openArticleInChat,
|
||||
type BriefingFeed,
|
||||
} from '@/api/client'
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const LIMIT = 40
|
||||
|
||||
const items = ref<NewsItem[]>([])
|
||||
const offset = ref(0)
|
||||
const hasMore = ref(true)
|
||||
const loading = ref(false)
|
||||
const feeds = ref<BriefingFeed[]>([])
|
||||
const selectedFeedId = ref<number | null>(null)
|
||||
|
||||
// Reactions map: item id → current reaction
|
||||
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
||||
// Track which items are currently being opened in chat
|
||||
const openingChat = ref<Set<number>>(new Set())
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value || !hasMore.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getNewsItems({
|
||||
days: 90,
|
||||
limit: LIMIT,
|
||||
offset: offset.value,
|
||||
feed_id: selectedFeedId.value,
|
||||
})
|
||||
for (const item of data.items) {
|
||||
if (reactions.value[item.id] === undefined) {
|
||||
reactions.value[item.id] = item.reaction
|
||||
}
|
||||
}
|
||||
items.value = [...items.value, ...data.items]
|
||||
offset.value += data.items.length
|
||||
hasMore.value = data.items.length === LIMIT
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFeedChange() {
|
||||
items.value = []
|
||||
offset.value = 0
|
||||
hasMore.value = true
|
||||
reactions.value = {}
|
||||
loadMore()
|
||||
}
|
||||
|
||||
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
|
||||
const current = reactions.value[itemId]
|
||||
reactions.value[itemId] = current === reaction ? null : reaction
|
||||
try {
|
||||
if (current === reaction) {
|
||||
await deleteRssReaction(itemId)
|
||||
} else {
|
||||
await postRssReaction(itemId, reaction)
|
||||
}
|
||||
} catch {
|
||||
reactions.value[itemId] = current ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diffH = (now.getTime() - d.getTime()) / 3_600_000
|
||||
if (diffH < 1) return 'Just now'
|
||||
if (diffH < 24) return `${Math.round(diffH)}h ago`
|
||||
if (diffH < 48) return 'Yesterday'
|
||||
const days = Math.floor(diffH / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
async function openInChat(itemId: number) {
|
||||
if (openingChat.value.has(itemId)) return
|
||||
openingChat.value.add(itemId)
|
||||
try {
|
||||
const result = await openArticleInChat(itemId)
|
||||
router.push(`/chat/${result.conversation_id}`)
|
||||
} catch {
|
||||
// silently fail — button returns to enabled state
|
||||
} finally {
|
||||
openingChat.value.delete(itemId)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
feeds.value = await getBriefingFeeds().catch(() => [])
|
||||
await loadMore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="news-root">
|
||||
<div class="news-header">
|
||||
<div class="news-header-left">
|
||||
<h1 class="news-title">News</h1>
|
||||
<span class="news-subtitle">Last 90 days</span>
|
||||
</div>
|
||||
<div class="news-header-right">
|
||||
<select
|
||||
v-model="selectedFeedId"
|
||||
class="feed-select"
|
||||
@change="onFeedChange"
|
||||
>
|
||||
<option :value="null">All feeds</option>
|
||||
<option v-for="feed in feeds" :key="feed.id" :value="feed.id">
|
||||
{{ feed.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="news-list">
|
||||
<div v-if="!items.length && !loading" class="news-empty">
|
||||
No articles found for the selected feed.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="news-card"
|
||||
>
|
||||
<div class="news-card-meta">
|
||||
<span class="news-source">{{ item.source }}</span>
|
||||
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="item.url"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="news-card-title"
|
||||
>{{ item.title }}</a>
|
||||
<p v-else class="news-card-title news-card-title--plain">{{ item.title }}</p>
|
||||
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
||||
<div v-if="item.topics?.length" class="news-topics">
|
||||
<span v-for="topic in item.topics" :key="topic" class="news-topic">{{ topic }}</span>
|
||||
</div>
|
||||
<div class="news-reactions">
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'up' }"
|
||||
@click="handleReaction(item.id, 'up')"
|
||||
title="Interested"
|
||||
>👍</button>
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'down' }"
|
||||
@click="handleReaction(item.id, 'down')"
|
||||
title="Not interested"
|
||||
>👎</button>
|
||||
<button
|
||||
class="reaction-btn open-chat-btn"
|
||||
:class="{ busy: openingChat.has(item.id) }"
|
||||
:disabled="openingChat.has(item.id)"
|
||||
@click="openInChat(item.id)"
|
||||
title="Discuss in chat"
|
||||
>{{ openingChat.has(item.id) ? '…' : '💬' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="news-footer">
|
||||
<button
|
||||
v-if="hasMore"
|
||||
class="btn-load-more"
|
||||
@click="loadMore"
|
||||
:disabled="loading"
|
||||
>{{ loading ? 'Loading…' : 'Load more' }}</button>
|
||||
<div v-if="loading && !items.length" class="news-loading">Loading…</div>
|
||||
<p v-if="!hasMore && items.length" class="news-end">All articles loaded</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.news-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.news-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.news-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.news-subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.news-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.feed-select {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.news-list {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 860px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.news-empty,
|
||||
.news-loading {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.news-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.news-source {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.news-date {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.news-card-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
text-decoration: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a.news-card-title:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.news-snippet {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-topics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.news-topic {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
color: var(--color-primary);
|
||||
border-radius: 99px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.news-reactions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.reaction-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.reaction-btn:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.reaction-btn.active {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.open-chat-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.open-chat-btn.busy {
|
||||
opacity: 0.4;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.news-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.btn-load-more {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-load-more:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-load-more:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.news-end {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile } from "@/api/client";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile } from "@/api/client";
|
||||
import { usePushStore } from "@/stores/push";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
@@ -62,7 +62,7 @@ const appVersion = ref('dev');
|
||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "briefing", "voice", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "voice", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||
|
||||
@@ -72,7 +72,6 @@ function _loadTabContent(tab: string) {
|
||||
else if (tab === "logs") loadLogsPanel();
|
||||
else if (tab === "groups") loadGroupsPanel();
|
||||
}
|
||||
if (tab === "briefing") loadBriefingTab();
|
||||
if (tab === "voice") loadVoiceTab();
|
||||
if (tab === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
|
||||
}
|
||||
@@ -291,155 +290,6 @@ async function removeMemberFromGroup(groupId: number, userId: number) {
|
||||
await loadGroupsPanel();
|
||||
}
|
||||
|
||||
// Briefing settings
|
||||
const briefingConfig = ref<BriefingConfig>({
|
||||
enabled: false,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
temp_unit: 'C',
|
||||
});
|
||||
const briefingFeeds = ref<BriefingFeed[]>([]);
|
||||
const briefingSaving = ref(false);
|
||||
const briefingSaved = ref(false);
|
||||
const briefingGeocoding = ref<Record<string, boolean>>({});
|
||||
const briefingGeoError = ref<Record<string, string>>({});
|
||||
const newFeedUrl = ref('');
|
||||
const newFeedCategory = ref('');
|
||||
const addingFeed = ref(false);
|
||||
const refreshingFeeds = ref(false);
|
||||
const briefingIncludeTopics = ref<string[]>([]);
|
||||
const briefingExcludeTopics = ref<string[]>([]);
|
||||
|
||||
function _parseTopics(raw: unknown): string[] {
|
||||
try {
|
||||
const val = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(val) ? val.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBriefingTab() {
|
||||
briefingConfig.value = await getBriefingConfig();
|
||||
briefingFeeds.value = await getBriefingFeeds();
|
||||
const allSettings = await apiGet<Record<string, string>>('/api/settings').catch(() => ({} as Record<string, string>));
|
||||
briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]');
|
||||
briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]');
|
||||
}
|
||||
|
||||
async function saveIncludeTopics(topics: string[]) {
|
||||
briefingIncludeTopics.value = topics;
|
||||
await apiPut('/api/settings', { briefing_include_topics: JSON.stringify(topics) });
|
||||
}
|
||||
|
||||
async function saveExcludeTopics(topics: string[]) {
|
||||
briefingExcludeTopics.value = topics;
|
||||
await apiPut('/api/settings', { briefing_exclude_topics: JSON.stringify(topics) });
|
||||
}
|
||||
|
||||
const _STANDARD_TOPICS = ['technology', 'science', 'politics', 'business', 'health', 'environment', 'local', 'entertainment', 'sports', 'other'];
|
||||
async function fetchTopicSuggestions(q: string): Promise<string[]> {
|
||||
if (!q) return _STANDARD_TOPICS;
|
||||
return _STANDARD_TOPICS.filter((t) => t.startsWith(q.toLowerCase()));
|
||||
}
|
||||
|
||||
async function geocodeLocation(key: 'home' | 'work') {
|
||||
const loc = briefingConfig.value.locations[key];
|
||||
if (!loc?.address?.trim()) return;
|
||||
briefingGeocoding.value[key] = true;
|
||||
briefingGeoError.value[key] = '';
|
||||
try {
|
||||
const result = await geocodeAddress(loc.address.trim());
|
||||
if (result) {
|
||||
briefingConfig.value.locations[key] = {
|
||||
...loc,
|
||||
lat: result.lat,
|
||||
lon: result.lon,
|
||||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
};
|
||||
briefingGeoError.value[key] = '';
|
||||
} else {
|
||||
briefingGeoError.value[key] = 'Location not found — check the address';
|
||||
}
|
||||
} catch {
|
||||
briefingGeoError.value[key] = 'Geocoding failed';
|
||||
} finally {
|
||||
briefingGeocoding.value[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function saveBriefingSettings() {
|
||||
briefingSaving.value = true;
|
||||
briefingSaved.value = false;
|
||||
try {
|
||||
await saveBriefingConfig(briefingConfig.value);
|
||||
briefingSaved.value = true;
|
||||
setTimeout(() => (briefingSaved.value = false), 2000);
|
||||
} catch {
|
||||
toastStore.show('Failed to save briefing settings', 'error');
|
||||
} finally {
|
||||
briefingSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRss() {
|
||||
try {
|
||||
await store.updateSettings({ rss_enabled: store.rssEnabled ? "false" : "true" });
|
||||
} catch {
|
||||
toastStore.show("Failed to update RSS setting", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function addFeed() {
|
||||
if (!newFeedUrl.value.trim() || addingFeed.value) return;
|
||||
addingFeed.value = true;
|
||||
try {
|
||||
const feed = await createBriefingFeed(newFeedUrl.value.trim(), newFeedCategory.value.trim() || undefined);
|
||||
briefingFeeds.value.push(feed);
|
||||
newFeedUrl.value = '';
|
||||
newFeedCategory.value = '';
|
||||
} catch (err: any) {
|
||||
toastStore.show(err?.message === 'Feed already added' ? 'That feed is already in your list' : 'Failed to add feed', 'error');
|
||||
} finally {
|
||||
addingFeed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFeed(id: number) {
|
||||
await deleteBriefingFeed(id);
|
||||
briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id);
|
||||
}
|
||||
|
||||
async function refreshFeeds() {
|
||||
if (refreshingFeeds.value) return;
|
||||
refreshingFeeds.value = true;
|
||||
try {
|
||||
const result = await refreshBriefingFeeds();
|
||||
// Reload feed list so last_fetched_at updates
|
||||
briefingFeeds.value = await getBriefingFeeds();
|
||||
toastStore.show(`Refreshed ${result.feeds_refreshed} feed${result.feeds_refreshed !== 1 ? 's' : ''} — ${result.new_items} new item${result.new_items !== 1 ? 's' : ''}`);
|
||||
} catch {
|
||||
toastStore.show('Failed to refresh feeds', 'error');
|
||||
} finally {
|
||||
refreshingFeeds.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function feedAge(isoStr: string | null): string {
|
||||
if (!isoStr) return 'never fetched';
|
||||
const diff = Date.now() - new Date(isoStr).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
// Chat retention
|
||||
const chatRetentionDays = ref(90);
|
||||
@@ -501,7 +351,7 @@ const smtp = ref({
|
||||
smtp_username: "",
|
||||
smtp_password: "",
|
||||
smtp_from_address: "",
|
||||
smtp_from_name: "Fabled Assistant",
|
||||
smtp_from_name: "Fabled Scribe",
|
||||
smtp_use_tls: "true",
|
||||
});
|
||||
const savingSmtp = ref(false);
|
||||
@@ -1465,7 +1315,7 @@ function formatUserDate(iso: string): string {
|
||||
<div class="sidebar-group">
|
||||
<div class="sidebar-group-label">User</div>
|
||||
<button
|
||||
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'briefing', 'voice', 'apikeys']"
|
||||
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'voice', 'apikeys']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -1518,7 +1368,7 @@ function formatUserDate(iso: string): string {
|
||||
<option v-for="m in installedModels" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
<p class="field-hint">
|
||||
Model used for background tasks: title generation, tag suggestions, project summaries, and RSS classification.
|
||||
Model used for background tasks: title generation, tag suggestions, and project summaries.
|
||||
Using a small dedicated model (e.g. qwen2.5:0.5b) keeps the chat model's KV cache warm between messages, significantly reducing response time.
|
||||
<span v-if="backgroundModel && backgroundModel === (defaultModel || defaultChatModel)" class="field-hint-warn">
|
||||
⚠ Setting this to the same model as Chat Model will wipe the KV cache after every background task, increasing response latency.
|
||||
@@ -1981,7 +1831,7 @@ function formatUserDate(iso: string): string {
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>About</h2>
|
||||
<p class="version-line">Fabled Assistant <span class="version-badge">{{ appVersion }}</span></p>
|
||||
<p class="version-line">Fabled Scribe <span class="version-badge">{{ appVersion }}</span></p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@@ -2115,219 +1965,6 @@ function formatUserDate(iso: string): string {
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Briefing ── -->
|
||||
<div v-show="activeTab === 'briefing'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Daily Briefing</h2>
|
||||
<p class="section-desc">
|
||||
Configure your daily briefing — a conversation that summarises your day,
|
||||
weather, and RSS feeds on a schedule.
|
||||
</p>
|
||||
|
||||
<!-- Enable -->
|
||||
<div class="checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.enabled" />
|
||||
Enable Daily Briefing
|
||||
</label>
|
||||
<p class="field-hint">Start daily briefings at the configured slots.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Locations -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Locations</h2>
|
||||
<p class="section-desc">Enter addresses for weather lookup. Click "Look up" to geocode.</p>
|
||||
|
||||
<div v-for="key in (['home', 'work'] as const)" :key="key" class="briefing-location-row">
|
||||
<label class="field-label">{{ key.charAt(0).toUpperCase() + key.slice(1) }}</label>
|
||||
<div class="briefing-input-group">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="key === 'home' ? 'e.g. 123 Main St, Springfield' : 'e.g. 456 Office Ave, Portland'"
|
||||
:value="briefingConfig.locations[key]?.address ?? ''"
|
||||
@input="(e) => {
|
||||
if (!briefingConfig.locations[key]) briefingConfig.locations[key] = { label: key, address: '' };
|
||||
briefingConfig.locations[key]!.address = (e.target as HTMLInputElement).value;
|
||||
}"
|
||||
/>
|
||||
<button class="btn-secondary" @click="geocodeLocation(key)" :disabled="briefingGeocoding[key]">
|
||||
{{ briefingGeocoding[key] ? 'Looking up…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="briefingConfig.locations[key]?.lat" class="briefing-geo-confirmed">
|
||||
✓ {{ briefingConfig.locations[key]!.lat?.toFixed(4) }}, {{ briefingConfig.locations[key]!.lon?.toFixed(4) }}
|
||||
</div>
|
||||
<div v-if="briefingGeoError[key]" class="briefing-geo-error">{{ briefingGeoError[key] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-field" style="margin-top: 0.75rem">
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.use_caldav_event_locations" />
|
||||
Also check CalDAV event locations
|
||||
</label>
|
||||
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-top: 1rem">
|
||||
<label class="field-label">Temperature unit</label>
|
||||
<div class="briefing-unit-toggle">
|
||||
<button
|
||||
type="button"
|
||||
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'C' }]"
|
||||
@click="briefingConfig.temp_unit = 'C'"
|
||||
>°C</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'F' }]"
|
||||
@click="briefingConfig.temp_unit = 'F'"
|
||||
>°F</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Slots -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Scheduled Slots</h2>
|
||||
<p class="section-desc">Each active slot posts an update into your briefing conversation at the listed local time.</p>
|
||||
<div class="briefing-slot-list">
|
||||
<div
|
||||
v-for="[key, label, localTime] in ([
|
||||
['compilation', 'Morning briefing', '4:00 am'],
|
||||
['morning', 'Office check-in', '8:00 am'],
|
||||
['midday', 'Midday update', '12:00 pm'],
|
||||
['afternoon', 'End of day', '4:00 pm'],
|
||||
] as const)"
|
||||
:key="key"
|
||||
class="briefing-slot-row"
|
||||
>
|
||||
<div class="briefing-slot-info">
|
||||
<span class="briefing-slot-label">{{ label }}</span>
|
||||
<span class="briefing-slot-time">{{ localTime }}</span>
|
||||
</div>
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.slots[key]" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint" style="margin-top: 0.5rem">
|
||||
Firing in timezone: <strong>{{ userTimezone || 'UTC (not configured — set in General settings)' }}</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- RSS toggle -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>RSS / News</h2>
|
||||
<div class="checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" :checked="store.rssEnabled" @change="toggleRss" />
|
||||
Enable RSS feeds
|
||||
</label>
|
||||
<p class="field-hint">Subscribe to RSS/Atom feeds and include news in your briefings.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RSS Feeds -->
|
||||
<section v-if="store.rssEnabled" class="settings-section full-width">
|
||||
<div class="briefing-feeds-header">
|
||||
<div>
|
||||
<h2>RSS Feeds</h2>
|
||||
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="briefingFeeds.length"
|
||||
class="btn-secondary briefing-refresh-btn"
|
||||
@click="refreshFeeds"
|
||||
:disabled="refreshingFeeds"
|
||||
title="Fetch latest items from all feeds"
|
||||
>{{ refreshingFeeds ? 'Refreshing…' : 'Refresh all' }}</button>
|
||||
</div>
|
||||
|
||||
<div class="briefing-feeds-list" v-if="briefingFeeds.length">
|
||||
<div class="briefing-feed-row" v-for="feed in briefingFeeds" :key="feed.id">
|
||||
<div class="briefing-feed-info">
|
||||
<div class="briefing-feed-title-row">
|
||||
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
|
||||
<span v-if="feed.category" class="briefing-feed-cat">{{ feed.category }}</span>
|
||||
</div>
|
||||
<span v-if="feed.title" class="briefing-feed-url">{{ feed.url }}</span>
|
||||
<span class="briefing-feed-age">{{ feedAge(feed.last_fetched_at) }}</span>
|
||||
</div>
|
||||
<button class="briefing-btn-remove" @click="removeFeed(feed.id)" aria-label="Remove feed">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="field-hint">No feeds yet. Try adding Reuters, BBC World, or Hacker News.</p>
|
||||
|
||||
<div class="briefing-add-feed-form">
|
||||
<div class="briefing-add-feed-inputs">
|
||||
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" @keydown.enter="addFeed" />
|
||||
<input v-model="newFeedCategory" class="input briefing-cat-input" placeholder="Category (optional)" @keydown.enter="addFeed" />
|
||||
</div>
|
||||
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
|
||||
{{ addingFeed ? 'Adding…' : 'Add Feed' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- News Preferences -->
|
||||
<section v-if="store.rssEnabled" class="settings-section full-width">
|
||||
<h2>News Preferences</h2>
|
||||
<p class="section-desc">
|
||||
Tell the briefing what topics you care about. Topics are matched against
|
||||
classified RSS items before each briefing runs.
|
||||
</p>
|
||||
<div class="settings-field">
|
||||
<label class="field-label">Interested in</label>
|
||||
<TagInput
|
||||
:model-value="briefingIncludeTopics"
|
||||
placeholder="e.g. technology, science, local"
|
||||
:fetch-tags="fetchTopicSuggestions"
|
||||
@update:model-value="saveIncludeTopics"
|
||||
/>
|
||||
</div>
|
||||
<div class="settings-field" style="margin-top: 0.75rem">
|
||||
<label class="field-label">Not interested in</label>
|
||||
<TagInput
|
||||
:model-value="briefingExcludeTopics"
|
||||
placeholder="e.g. sports, celebrity"
|
||||
:fetch-tags="fetchTopicSuggestions"
|
||||
@update:model-value="saveExcludeTopics"
|
||||
/>
|
||||
</div>
|
||||
<details class="topic-vocab-hint" style="margin-top: 0.75rem">
|
||||
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.82rem">Standard topic vocabulary</summary>
|
||||
<p class="field-hint" style="margin-top: 0.35rem">
|
||||
technology · science · politics · business · health · environment ·
|
||||
local · entertainment · sports · other
|
||||
</p>
|
||||
<p class="field-hint">Custom terms are also accepted.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Notifications</h2>
|
||||
<div class="checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.notifications" />
|
||||
Push notification when each briefing slot is ready
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveBriefingSettings" :disabled="briefingSaving">
|
||||
{{ briefingSaved ? 'Saved ✓' : briefingSaving ? 'Saving…' : 'Save Briefing Settings' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Voice ── -->
|
||||
<div v-show="activeTab === 'voice'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
@@ -2741,7 +2378,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp-from-name">From Name</label>
|
||||
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Assistant" class="input" />
|
||||
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Scribe" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox-field">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="/home/bvandeusen/Nextcloud/Projects/fabledassistant"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
input=$(cat)
|
||||
command=$(echo "$input" | python3 -c "
|
||||
|
||||
@@ -11,8 +11,8 @@ from fabledassistant.config import Config
|
||||
from fabledassistant.routes.admin import admin_bp
|
||||
from fabledassistant.routes.api import api
|
||||
from fabledassistant.routes.auth import auth_bp
|
||||
from fabledassistant.routes.briefing import briefing_bp
|
||||
from fabledassistant.routes.chat import chat_bp
|
||||
from fabledassistant.routes.journal import journal_bp
|
||||
from fabledassistant.routes.export import export_bp
|
||||
from fabledassistant.routes.notes import notes_bp
|
||||
from fabledassistant.routes.images import images_bp
|
||||
@@ -75,8 +75,8 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(api)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(briefing_bp)
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(journal_bp)
|
||||
app.register_blueprint(export_bp)
|
||||
app.register_blueprint(images_bp)
|
||||
app.register_blueprint(milestones_bp)
|
||||
@@ -320,22 +320,12 @@ def create_app() -> Quart:
|
||||
await backfill_project_summaries()
|
||||
except Exception:
|
||||
logger.warning("Project summary backfill failed", exc_info=True)
|
||||
try:
|
||||
from fabledassistant.services.embeddings import backfill_rss_item_embeddings
|
||||
await backfill_rss_item_embeddings()
|
||||
except Exception:
|
||||
logger.warning("RSS embedding backfill failed", exc_info=True)
|
||||
try:
|
||||
from fabledassistant.services.embeddings import backfill_rss_article_content
|
||||
await backfill_rss_article_content()
|
||||
except Exception:
|
||||
logger.warning("RSS article content backfill failed", exc_info=True)
|
||||
|
||||
asyncio.create_task(_delayed_backfill())
|
||||
|
||||
# Start briefing scheduler
|
||||
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
|
||||
await start_briefing_scheduler(asyncio.get_running_loop())
|
||||
# Start journal scheduler (per-user daily prep generation)
|
||||
from fabledassistant.services.journal_scheduler import start_journal_scheduler
|
||||
start_journal_scheduler(asyncio.get_running_loop())
|
||||
|
||||
# Start event scheduler (reminders + CalDAV pull sync)
|
||||
from fabledassistant.services.event_scheduler import start_event_scheduler
|
||||
@@ -349,8 +339,8 @@ def create_app() -> Quart:
|
||||
|
||||
@app.after_serving
|
||||
async def shutdown():
|
||||
from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler
|
||||
stop_briefing_scheduler()
|
||||
from fabledassistant.services.journal_scheduler import stop_journal_scheduler
|
||||
stop_journal_scheduler()
|
||||
from fabledassistant.services.event_scheduler import stop_event_scheduler
|
||||
stop_event_scheduler()
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class Config:
|
||||
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3:latest")
|
||||
# Lightweight model for background tasks (title generation, tag suggestions,
|
||||
# project summaries, RSS classification). Using a separate model keeps the
|
||||
# project summaries). Using a separate model keeps the
|
||||
# main model's KV cache intact between user messages, enabling prefix cache hits.
|
||||
OLLAMA_BACKGROUND_MODEL: str = os.environ.get("OLLAMA_BACKGROUND_MODEL", "gemma3:4b")
|
||||
# Ollama keep_alive — how long a model stays resident in VRAM after its last
|
||||
@@ -52,7 +52,7 @@ class Config:
|
||||
SMTP_USERNAME: str = os.environ.get("SMTP_USERNAME", "")
|
||||
SMTP_PASSWORD: str = _read_secret("SMTP_PASSWORD", "SMTP_PASSWORD_FILE", "")
|
||||
SMTP_FROM_ADDRESS: str = os.environ.get("SMTP_FROM_ADDRESS", "")
|
||||
SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Assistant")
|
||||
SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Scribe")
|
||||
SMTP_USE_TLS: bool = os.environ.get("SMTP_USE_TLS", "true").lower() in ("1", "true", "yes")
|
||||
BASE_URL: str = os.environ.get("BASE_URL", "http://localhost:5000").rstrip("/")
|
||||
TRUST_PROXY_HEADERS: bool = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ("1", "true", "yes")
|
||||
|
||||
@@ -38,8 +38,14 @@ from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401
|
||||
from fabledassistant.models.group import Group, GroupMembership # noqa: E402, F401
|
||||
from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, F401
|
||||
from fabledassistant.models.notification import Notification # noqa: E402, F401
|
||||
from fabledassistant.models.rss_feed import RssFeed, RssItem # noqa: E402, F401
|
||||
from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401
|
||||
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
|
||||
from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401
|
||||
from fabledassistant.models.rss_item_embedding import RssItemEmbedding # noqa: E402, F401
|
||||
from fabledassistant.models.moment import ( # noqa: E402, F401
|
||||
Moment,
|
||||
MomentEmbedding,
|
||||
moment_people,
|
||||
moment_places,
|
||||
moment_tasks,
|
||||
moment_notes,
|
||||
)
|
||||
|
||||
@@ -18,11 +18,11 @@ class Conversation(Base, TimestampMixin):
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
model: Mapped[str] = mapped_column(Text, default="")
|
||||
# 'chat' (default) or 'briefing'. Briefing conversations are hidden from
|
||||
# the main chat view and managed by the briefing pipeline.
|
||||
# 'chat' (default) or 'journal'. Journal conversations are day-anchored
|
||||
# and rendered by the /journal view; they are hidden from the main chat list.
|
||||
conversation_type: Mapped[str] = mapped_column(Text, default="chat", server_default="chat")
|
||||
# For briefing conversations only: the calendar date this briefing covers.
|
||||
briefing_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True)
|
||||
# For journal conversations only: the calendar date this conversation covers.
|
||||
day_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True)
|
||||
# NULL = orphan notes only; -1 = all notes; positive int = specific project
|
||||
rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
|
||||
|
||||
@@ -48,7 +48,7 @@ class Conversation(Base, TimestampMixin):
|
||||
"title": self.title,
|
||||
"model": self.model,
|
||||
"conversation_type": self.conversation_type,
|
||||
"briefing_date": self.briefing_date.isoformat() if self.briefing_date else None,
|
||||
"day_date": self.day_date.isoformat() if self.day_date else None,
|
||||
"rag_project_id": self.rag_project_id,
|
||||
"message_count": msg_count,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, Table, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
# Junction tables. People, Places, Tasks, and (regular) Notes all live in
|
||||
# the `notes` table — the four junctions are kept separate (rather than one
|
||||
# merged table with a discriminator) so per-kind queries don't require a
|
||||
# filter, and so the schema is explicit about which links are which.
|
||||
moment_people = Table(
|
||||
"moment_people",
|
||||
Base.metadata,
|
||||
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("person_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
moment_places = Table(
|
||||
"moment_places",
|
||||
Base.metadata,
|
||||
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("place_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
moment_tasks = Table(
|
||||
"moment_tasks",
|
||||
Base.metadata,
|
||||
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("task_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
moment_notes = Table(
|
||||
"moment_notes",
|
||||
Base.metadata,
|
||||
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("note_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Moment(Base):
|
||||
"""A small structured extraction from a journal conversation.
|
||||
|
||||
Many per day. Emitted by the LLM via the `record_moment` tool when it
|
||||
notices a meaningful beat. Stored separately from Notes — they are
|
||||
different in kind: Notes are curated artifacts; Moments are ambient
|
||||
trace data with their own embedding index, so notes-RAG and journal-RAG
|
||||
cannot cross-contaminate.
|
||||
"""
|
||||
|
||||
__tablename__ = "moments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
conversation_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("conversations.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
source_message_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("messages.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
day_date: Mapped[datetime.date] = mapped_column(Date, nullable=False)
|
||||
occurred_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
recorded_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
people = relationship("Note", secondary=moment_people, lazy="selectin", viewonly=True)
|
||||
places = relationship("Note", secondary=moment_places, lazy="selectin", viewonly=True)
|
||||
tasks = relationship("Note", secondary=moment_tasks, lazy="selectin", viewonly=True)
|
||||
notes = relationship("Note", secondary=moment_notes, lazy="selectin", viewonly=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_moments_user_day", "user_id", "day_date"),
|
||||
Index("ix_moments_user_occurred", "user_id", "occurred_at"),
|
||||
)
|
||||
|
||||
def to_dict(self, *, include_links: bool = True) -> dict:
|
||||
result: dict = {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"conversation_id": self.conversation_id,
|
||||
"source_message_id": self.source_message_id,
|
||||
"day_date": self.day_date.isoformat(),
|
||||
"occurred_at": self.occurred_at.isoformat(),
|
||||
"recorded_at": self.recorded_at.isoformat(),
|
||||
"content": self.content,
|
||||
"raw_excerpt": self.raw_excerpt,
|
||||
"tags": list(self.tags or []),
|
||||
"pinned": self.pinned,
|
||||
}
|
||||
if include_links:
|
||||
result["people"] = [{"id": p.id, "title": p.title} for p in (self.people or [])]
|
||||
result["places"] = [{"id": p.id, "title": p.title} for p in (self.places or [])]
|
||||
result["task_ids"] = [t.id for t in (self.tasks or [])]
|
||||
result["note_ids"] = [n.id for n in (self.notes or [])]
|
||||
return result
|
||||
|
||||
|
||||
class MomentEmbedding(Base):
|
||||
"""Embedding vector for a Moment — used by `search_journal` semantic mode.
|
||||
|
||||
Stored separately from `note_embeddings` so notes-RAG and journal-RAG
|
||||
cannot cross-contaminate. This is a hard invariant of the journal design.
|
||||
"""
|
||||
|
||||
__tablename__ = "moment_embeddings"
|
||||
|
||||
moment_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("moments.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
embedding: Mapped[list] = mapped_column(JSONB, nullable=False)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
@@ -1,96 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import ARRAY, BigInteger, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class RssFeed(Base):
|
||||
__tablename__ = "rss_feeds"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
|
||||
url: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
category: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
items: Mapped[list["RssItem"]] = relationship(
|
||||
back_populates="feed", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "url", name="uq_rss_feeds_user_url"),
|
||||
Index("ix_rss_feeds_user_id", "user_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"category": self.category,
|
||||
"last_fetched_at": self.last_fetched_at.isoformat() if self.last_fetched_at else None,
|
||||
}
|
||||
|
||||
|
||||
class RssItem(Base):
|
||||
__tablename__ = "rss_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
feed_id: Mapped[int] = mapped_column(Integer, ForeignKey("rss_feeds.id", ondelete="CASCADE"))
|
||||
guid: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
url: Mapped[str] = mapped_column(Text, default="")
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Truncated to 2000 chars to keep DB size reasonable
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
# Full trafilatura-extracted article body, populated lazily on first
|
||||
# discuss-click / enrichment pass. Nullable — most items never get this
|
||||
# cached. Expires naturally with the item (90-day retention).
|
||||
content_full: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Map-reduced conversation-ready context derived from content_full. See
|
||||
# services/article_context.py — populated on first discuss click so
|
||||
# repeat clicks skip both the fetch and the LLM map step.
|
||||
context_prepared: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content_fetched_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
topics: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(Text), nullable=False, default=list, server_default="{}"
|
||||
)
|
||||
classified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Note persisting the first-click discussion summary. Set by the article
|
||||
# discussion pipeline once the seeded chat completes its first assistant
|
||||
# reply; links back into RAG so re-discussing the same article lands the
|
||||
# prior summary in context.
|
||||
discussion_note_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
feed: Mapped["RssFeed"] = relationship(back_populates="items")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("feed_id", "guid", name="uq_rss_items_feed_guid"),
|
||||
Index("ix_rss_items_feed_id", "feed_id"),
|
||||
Index("ix_rss_items_published_at", "published_at"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"feed_id": self.feed_id,
|
||||
"guid": self.guid,
|
||||
"title": self.title,
|
||||
"url": self.url,
|
||||
"published_at": self.published_at.isoformat() if self.published_at else None,
|
||||
"content": self.content,
|
||||
"topics": self.topics or [],
|
||||
"classified_at": self.classified_at.isoformat() if self.classified_at else None,
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class RssItemEmbedding(Base):
|
||||
"""Stores the embedding vector for an RSS item, used for semantic news search."""
|
||||
|
||||
__tablename__ = "rss_item_embeddings"
|
||||
|
||||
rss_item_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("rss_items.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
embedding: Mapped[list] = mapped_column(JSONB, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -1,705 +0,0 @@
|
||||
"""Briefing API: RSS feeds, weather, and briefing configuration."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.auth import login_required
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.models.rss_feed import RssFeed, RssItem
|
||||
from fabledassistant.services import rss as rss_svc
|
||||
from fabledassistant.services import weather as weather_svc
|
||||
from fabledassistant.services.briefing_conversations import (
|
||||
get_or_create_today_conversation,
|
||||
list_briefing_conversations,
|
||||
post_message,
|
||||
)
|
||||
from fabledassistant.services.chat import add_message, get_conversation
|
||||
from fabledassistant.services.generation_buffer import create_buffer, get_buffer
|
||||
from fabledassistant.services.generation_task import run_generation
|
||||
from fabledassistant.services.settings import get_setting, set_settings_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
briefing_bp = Blueprint("briefing", __name__, url_prefix="/api/briefing")
|
||||
|
||||
_REQUIRE = login_required
|
||||
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/config", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_config():
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
except Exception:
|
||||
config = {}
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@briefing_bp.route("/config", methods=["PUT"])
|
||||
@_REQUIRE
|
||||
async def put_config():
|
||||
data = await request.get_json()
|
||||
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
|
||||
# Live-patch the scheduler using the stored user_timezone.
|
||||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
||||
tz_override = await get_setting(g.user.id, "user_timezone") or None
|
||||
update_user_schedule(g.user.id, data, tz_override=tz_override)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
async def _rss_enabled() -> bool:
|
||||
return (await get_setting(g.user.id, "rss_enabled", "false")).lower() == "true"
|
||||
|
||||
|
||||
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/feeds", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def list_feeds():
|
||||
if not await _rss_enabled():
|
||||
return jsonify([])
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id)
|
||||
)
|
||||
feeds = list(result.scalars().all())
|
||||
return jsonify([f.to_dict() for f in feeds])
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def add_feed():
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"error": "RSS is disabled"}), 403
|
||||
data = await request.get_json()
|
||||
url = (data.get("url") or "").strip()
|
||||
if not url:
|
||||
return jsonify({"error": "url required"}), 400
|
||||
scheme = url.split("://")[0].lower() if "://" in url else ""
|
||||
if scheme not in ("http", "https"):
|
||||
return jsonify({"error": "Feed URL must use http or https"}), 400
|
||||
from fabledassistant.services.llm import _is_private_url
|
||||
if _is_private_url(url):
|
||||
return jsonify({"error": "Feed URL must not point to an internal address"}), 400
|
||||
category = data.get("category") or None
|
||||
|
||||
async with async_session() as session:
|
||||
# Prevent duplicates
|
||||
existing = await session.execute(
|
||||
select(RssFeed).where(RssFeed.user_id == g.user.id, RssFeed.url == url)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
return jsonify({"error": "Feed already added"}), 409
|
||||
|
||||
feed = RssFeed(user_id=g.user.id, url=url, title="", category=category)
|
||||
session.add(feed)
|
||||
await session.commit()
|
||||
await session.refresh(feed)
|
||||
feed_id = feed.id
|
||||
|
||||
# Fetch in background to auto-populate title and get initial items
|
||||
asyncio.create_task(rss_svc.fetch_and_cache_feed(feed_id, url))
|
||||
|
||||
return jsonify({"id": feed_id, "url": url, "title": "", "category": category}), 201
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds/<int:feed_id>", methods=["DELETE"])
|
||||
@_REQUIRE
|
||||
async def delete_feed(feed_id: int):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssFeed).where(RssFeed.id == feed_id, RssFeed.user_id == g.user.id)
|
||||
)
|
||||
feed = result.scalars().first()
|
||||
if not feed:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
await session.delete(feed)
|
||||
await session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds/refresh", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def refresh_feeds():
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"feeds_refreshed": 0, "new_items": 0})
|
||||
results = await rss_svc.refresh_all_feeds(g.user.id)
|
||||
total_new = sum(results.values())
|
||||
return jsonify({"feeds_refreshed": len(results), "new_items": total_new})
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds/recent", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def recent_items():
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"items": []})
|
||||
limit = min(int(request.args.get("limit", 20)), 100)
|
||||
items = await rss_svc.get_recent_items(g.user.id, limit=limit)
|
||||
return jsonify({"items": items})
|
||||
|
||||
|
||||
# ── Weather ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/weather", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_weather():
|
||||
rows = await weather_svc.get_cached_weather_rows(g.user.id)
|
||||
import json as _json
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
_cfg = _json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
temp_unit = _cfg.get("temp_unit", "C")
|
||||
if temp_unit not in ("C", "F"):
|
||||
temp_unit = "C"
|
||||
except Exception:
|
||||
temp_unit = "C"
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
|
||||
@briefing_bp.route("/weather/current", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_current_weather():
|
||||
"""Return current temperature, conditions, and precipitation for the user's primary location.
|
||||
|
||||
Lightweight — fetches live from Open-Meteo, no caching. Intended for periodic frontend polling.
|
||||
"""
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
temp_unit = config.get("temp_unit", "C")
|
||||
if temp_unit not in ("C", "F"):
|
||||
temp_unit = "C"
|
||||
locations = config.get("locations", {})
|
||||
except Exception:
|
||||
return jsonify({"error": "No briefing config"}), 404
|
||||
|
||||
# Use home location, fall back to work
|
||||
loc = locations.get("home") or locations.get("work")
|
||||
if not loc or not loc.get("lat") or not loc.get("lon"):
|
||||
return jsonify({"error": "No location configured"}), 404
|
||||
|
||||
from fabledassistant.services.weather import fetch_current_conditions
|
||||
current = await fetch_current_conditions(loc["lat"], loc["lon"])
|
||||
if current is None:
|
||||
return jsonify({"error": "Failed to fetch current conditions"}), 502
|
||||
|
||||
# Convert temperature if needed
|
||||
temp = current["temperature"]
|
||||
if temp is not None and temp_unit == "F":
|
||||
temp = temp * 9 / 5 + 32
|
||||
current["temperature"] = round(temp) if temp is not None else None
|
||||
current["temp_unit"] = temp_unit
|
||||
current["location"] = loc.get("label") or "Home"
|
||||
|
||||
return jsonify(current)
|
||||
|
||||
|
||||
@briefing_bp.route("/weather/geocode", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def geocode_location():
|
||||
data = await request.get_json()
|
||||
query = (data.get("query") or "").strip()
|
||||
if not query:
|
||||
return jsonify({"error": "query required"}), 400
|
||||
try:
|
||||
lat, lon, label = await weather_svc.geocode(query)
|
||||
return jsonify({"lat": lat, "lon": lon, "label": label})
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
@briefing_bp.route("/weather/refresh", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def refresh_weather():
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else {}
|
||||
temp_unit = config.get("temp_unit", "C")
|
||||
if temp_unit not in ("C", "F"):
|
||||
temp_unit = "C"
|
||||
except Exception:
|
||||
config = {}
|
||||
temp_unit = "C"
|
||||
|
||||
locations = config.get("locations", {})
|
||||
for key, loc in locations.items():
|
||||
if not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=g.user.id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
||||
|
||||
rows = await weather_svc.get_cached_weather_rows(g.user.id)
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
# ── Briefing Conversations ─────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/conversations", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_conversations():
|
||||
convs = await list_briefing_conversations(g.user.id)
|
||||
return jsonify({"conversations": convs})
|
||||
|
||||
|
||||
@briefing_bp.route("/conversations/today", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_today_conversation():
|
||||
model = await get_setting(g.user.id, "default_model", "")
|
||||
conv = await get_or_create_today_conversation(g.user.id, model)
|
||||
# Load messages
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv.id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = [m.to_dict() for m in result.scalars().all()]
|
||||
data = conv.to_dict()
|
||||
data["messages"] = messages
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@briefing_bp.route("/conversations/<int:conv_id>/messages", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_conversation_messages(conv_id: int):
|
||||
# Verify ownership
|
||||
async with async_session() as session:
|
||||
conv = await session.get(Conversation, conv_id)
|
||||
if not conv or conv.user_id != g.user.id or conv.conversation_type != "briefing":
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
result = await session.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv_id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = [m.to_dict() for m in result.scalars().all()]
|
||||
return jsonify({"messages": messages})
|
||||
|
||||
|
||||
@briefing_bp.route("/trigger", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def manual_trigger():
|
||||
"""Manually trigger a briefing compilation, including a fresh data refresh."""
|
||||
data = await request.get_json() or {}
|
||||
slot = data.get("slot", "compilation")
|
||||
if slot not in ("compilation", "morning", "midday", "afternoon"):
|
||||
return jsonify({"error": "invalid slot"}), 400
|
||||
|
||||
# Refresh external data first (mirrors what the scheduler does)
|
||||
try:
|
||||
from fabledassistant.services.rss import refresh_all_feeds
|
||||
config_raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
await refresh_all_feeds(g.user.id)
|
||||
for key, loc in config.get("locations", {}).items():
|
||||
if loc.get("lat") and loc.get("lon"):
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=g.user.id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Pre-trigger refresh failed for user %d", g.user.id, exc_info=True)
|
||||
|
||||
from fabledassistant.services.briefing_pipeline import run_compilation
|
||||
from fabledassistant.services.briefing_scheduler import _persist_agentic_messages
|
||||
|
||||
model = await get_setting(g.user.id, "default_model", "")
|
||||
conv = await get_or_create_today_conversation(g.user.id, model)
|
||||
text, metadata = await run_compilation(g.user.id, slot, model)
|
||||
await _persist_agentic_messages(conv.id, slot, metadata)
|
||||
final_meta = {k: v for k, v in metadata.items() if k != "agentic_messages"}
|
||||
final_meta["briefing_slot"] = slot
|
||||
msg = await post_message(conv.id, "assistant", text, metadata=final_meta)
|
||||
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
|
||||
|
||||
|
||||
@briefing_bp.route("/reset-today", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def reset_today_briefing():
|
||||
"""Delete all messages in today's briefing conversation.
|
||||
|
||||
The conversation row itself is kept so its id stays stable for any
|
||||
open UI sessions. Intended for "wipe and start fresh" workflows
|
||||
driven from the MCP when iterating on prompts. Pair with
|
||||
``POST /api/briefing/trigger`` to immediately regenerate.
|
||||
"""
|
||||
from sqlalchemy import delete as _delete
|
||||
|
||||
from fabledassistant.services.tz import user_briefing_date
|
||||
|
||||
# User-local briefing day (flips at 4am local), not ``date.today()`` —
|
||||
# see services/tz.py for rationale.
|
||||
today = await user_briefing_date(g.user.id)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == g.user.id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == today,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv is None:
|
||||
return jsonify({"deleted": 0, "conversation_id": None})
|
||||
deleted_result = await session.execute(
|
||||
_delete(Message).where(Message.conversation_id == conv.id)
|
||||
)
|
||||
await session.commit()
|
||||
deleted = deleted_result.rowcount or 0
|
||||
|
||||
return jsonify({"deleted": deleted, "conversation_id": conv.id})
|
||||
|
||||
|
||||
# ── RSS Reactions ──────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/rss-reactions", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def upsert_rss_reaction():
|
||||
"""Upsert a 👍/👎 reaction on an RSS item. Same reaction toggles off; opposite flips."""
|
||||
data = await request.get_json()
|
||||
rss_item_id = data.get("rss_item_id")
|
||||
reaction = data.get("reaction")
|
||||
|
||||
if not rss_item_id or reaction not in ("up", "down"):
|
||||
return jsonify({"error": "rss_item_id and reaction ('up'|'down') required"}), 400
|
||||
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
async with async_session() as session:
|
||||
# Ownership check: verify item belongs to a feed owned by this user
|
||||
result = await session.execute(
|
||||
_text("""
|
||||
SELECT i.id FROM rss_items i
|
||||
JOIN rss_feeds f ON f.id = i.feed_id
|
||||
WHERE i.id = :item_id AND f.user_id = :uid
|
||||
""").bindparams(item_id=rss_item_id, uid=g.user.id)
|
||||
)
|
||||
if result.first() is None:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
# Check existing reaction
|
||||
existing = await session.execute(
|
||||
_text("""
|
||||
SELECT id, reaction FROM rss_item_reactions
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(uid=g.user.id, item_id=rss_item_id)
|
||||
)
|
||||
row = existing.first()
|
||||
|
||||
if row is None:
|
||||
await session.execute(
|
||||
_text("""
|
||||
INSERT INTO rss_item_reactions (user_id, rss_item_id, reaction)
|
||||
VALUES (:uid, :item_id, :reaction)
|
||||
""").bindparams(uid=g.user.id, item_id=rss_item_id, reaction=reaction)
|
||||
)
|
||||
action = "created"
|
||||
elif row.reaction == reaction:
|
||||
# Toggle off (same reaction clicked again)
|
||||
await session.execute(
|
||||
_text("""
|
||||
DELETE FROM rss_item_reactions
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(uid=g.user.id, item_id=rss_item_id)
|
||||
)
|
||||
action = "removed"
|
||||
else:
|
||||
# Flip to opposite reaction
|
||||
await session.execute(
|
||||
_text("""
|
||||
UPDATE rss_item_reactions SET reaction = :reaction
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(reaction=reaction, uid=g.user.id, item_id=rss_item_id)
|
||||
)
|
||||
action = "updated"
|
||||
|
||||
await session.commit()
|
||||
|
||||
return jsonify({"ok": True, "action": action})
|
||||
|
||||
|
||||
@briefing_bp.route("/rss-reactions/<int:item_id>", methods=["DELETE"])
|
||||
@_REQUIRE
|
||||
async def delete_rss_reaction(item_id: int):
|
||||
"""Explicitly remove a reaction (useful for MCP/external API callers)."""
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
_text("""
|
||||
DELETE FROM rss_item_reactions
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(uid=g.user.id, item_id=item_id)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@briefing_bp.route("/news", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def list_news():
|
||||
"""Return recent RSS articles with optional feed filter and pagination.
|
||||
|
||||
Query params:
|
||||
days — lookback window (default 2, max 90)
|
||||
limit — items per page (default 40, max 100)
|
||||
offset — pagination offset (default 0)
|
||||
feed_id — optional integer filter by feed
|
||||
"""
|
||||
if not await _rss_enabled():
|
||||
return jsonify({"items": [], "total": 0})
|
||||
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
days = min(int(request.args.get("days", 2)), 90)
|
||||
limit = min(int(request.args.get("limit", 40)), 100)
|
||||
offset = max(int(request.args.get("offset", 0)), 0)
|
||||
feed_id = request.args.get("feed_id", type=int)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
_text("""
|
||||
SELECT
|
||||
i.id, i.title, i.url, i.content, i.published_at,
|
||||
i.topics, f.title AS feed_title,
|
||||
r.reaction
|
||||
FROM rss_items i
|
||||
JOIN rss_feeds f ON f.id = i.feed_id
|
||||
LEFT JOIN rss_item_reactions r
|
||||
ON r.rss_item_id = i.id AND r.user_id = :uid
|
||||
WHERE f.user_id = :uid
|
||||
AND (CAST(:feed_id AS integer) IS NULL OR f.id = CAST(:feed_id AS integer))
|
||||
AND COALESCE(i.published_at, i.fetched_at) >= NOW() - make_interval(days => :days)
|
||||
ORDER BY COALESCE(i.published_at, i.fetched_at) DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""").bindparams(uid=g.user.id, days=days, limit=limit,
|
||||
offset=offset, feed_id=feed_id)
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"url": r["url"],
|
||||
"snippet": (r["content"] or "")[:300],
|
||||
"content": r["content"] or "",
|
||||
"published_at": r["published_at"].isoformat() if r["published_at"] else None,
|
||||
"topics": r["topics"] or [],
|
||||
"source": r["feed_title"],
|
||||
"reaction": r["reaction"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return jsonify({"items": items, "offset": offset, "limit": limit})
|
||||
|
||||
|
||||
# ── Article Discuss ────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def discuss_article(item_id: int):
|
||||
"""Inject article content as a synthetic tool exchange and trigger generation."""
|
||||
data = await request.get_json() or {}
|
||||
conv_id = data.get("conv_id")
|
||||
if not conv_id:
|
||||
return jsonify({"error": "conv_id required"}), 400
|
||||
|
||||
uid = g.user.id
|
||||
|
||||
# Verify item belongs to user via feed ownership
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssItem)
|
||||
.join(RssFeed, RssFeed.id == RssItem.feed_id)
|
||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||||
)
|
||||
item = result.scalars().first()
|
||||
if item is None:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
# Verify conversation belongs to user
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
# Reject if generation already running
|
||||
if get_buffer(conv_id) is not None:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
# Shared helper handles the three-layer cache (context_prepared →
|
||||
# content_full → fresh fetch), writes the synthetic read_article tool
|
||||
# exchange and the conversational seed user prompt into the conversation.
|
||||
# The /news from-article route calls the same helper so behavior stays
|
||||
# byte-identical across entry points.
|
||||
from fabledassistant.services.article_context import (
|
||||
EmptyArticleError,
|
||||
seed_article_discussion,
|
||||
)
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
try:
|
||||
discuss_prompt = await seed_article_discussion(conv_id, item, model)
|
||||
except EmptyArticleError as e:
|
||||
return jsonify({"error": str(e)}), 422
|
||||
|
||||
# Reload conversation with fresh messages to build history
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
assert conv is not None
|
||||
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
||||
buf = create_buffer(conv_id, assistant_msg.id)
|
||||
|
||||
asyncio.create_task(run_generation(
|
||||
buf, history, model,
|
||||
uid, conv_id, conv.title or "",
|
||||
discuss_prompt,
|
||||
))
|
||||
|
||||
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
|
||||
|
||||
|
||||
@briefing_bp.route("/topics/<topic>/discuss", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def discuss_topic(topic: str):
|
||||
"""Discuss all recent articles in a topic cluster — multi-article deep analysis."""
|
||||
data = await request.get_json() or {}
|
||||
conv_id = data.get("conv_id")
|
||||
if not conv_id:
|
||||
return jsonify({"error": "conv_id required"}), 400
|
||||
|
||||
uid = g.user.id
|
||||
|
||||
# Verify conversation belongs to user
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
if get_buffer(conv_id) is not None:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
# Find recent articles with this topic (last 2 days)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssItem)
|
||||
.join(RssFeed, RssFeed.id == RssItem.feed_id)
|
||||
.where(RssFeed.user_id == uid)
|
||||
.where(RssItem.topics.contains([topic]))
|
||||
.order_by(RssItem.published_at.desc().nullslast())
|
||||
.limit(5)
|
||||
)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
if not items:
|
||||
return jsonify({"error": f"No articles found for topic '{topic}'"}), 404
|
||||
|
||||
# Fetch full content for each article
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
|
||||
synthetic_tool_calls = []
|
||||
for item in items:
|
||||
content = await _fetch_full_article(item.url) if item.url else None
|
||||
content = content or item.content or ""
|
||||
synthetic_tool_calls.append({
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url or ""},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url or "",
|
||||
"title": item.title or "",
|
||||
"source": "",
|
||||
"content": content[:8000], # cap per article to stay within context
|
||||
"truncated": len(content) > 8000,
|
||||
},
|
||||
})
|
||||
|
||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
||||
|
||||
user_prompt = (
|
||||
f"I'd like to discuss the {len(items)} articles about '{topic}'. "
|
||||
"Don't just summarize each one — draw connections between the sources, "
|
||||
"highlight where they agree or disagree, and share your analysis of what "
|
||||
"this means. Let's have a real discussion about this topic."
|
||||
)
|
||||
await add_message(conv_id, "user", user_prompt)
|
||||
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
assert conv is not None
|
||||
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
|
||||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
||||
buf = create_buffer(conv_id, assistant_msg.id)
|
||||
|
||||
asyncio.create_task(run_generation(
|
||||
buf, history, model,
|
||||
uid, conv_id, conv.title or "",
|
||||
user_prompt,
|
||||
))
|
||||
|
||||
return jsonify({
|
||||
"assistant_message_id": assistant_msg.id,
|
||||
"status": "generating",
|
||||
"article_count": len(items),
|
||||
}), 202
|
||||
@@ -507,81 +507,3 @@ async def delete_model_route():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@chat_bp.route("/from-article/<int:item_id>", methods=["POST"])
|
||||
@login_required
|
||||
async def create_conversation_from_article(item_id: int):
|
||||
"""Create a chat conversation seeded for article discussion and auto-run.
|
||||
|
||||
Mirrors the briefing ``discuss_article`` route: creates a fresh
|
||||
conversation, stages the shared synthetic read_article exchange + seed
|
||||
prompt, then kicks off generation so the client lands on an in-flight
|
||||
stream. The Flutter and web chat screens reconnect to the running buffer
|
||||
on mount.
|
||||
"""
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
||||
from fabledassistant.services.article_context import (
|
||||
EmptyArticleError,
|
||||
seed_article_discussion,
|
||||
)
|
||||
|
||||
uid = get_current_user_id()
|
||||
|
||||
async with _async_session() as session:
|
||||
result = await session.execute(
|
||||
_select(RssItem)
|
||||
.join(RssFeed, RssItem.feed_id == RssFeed.id)
|
||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||||
)
|
||||
item = result.scalars().first()
|
||||
|
||||
if item is None:
|
||||
return jsonify({"error": "Article not found"}), 404
|
||||
|
||||
conv_title = (item.title or "Article discussion")[:80]
|
||||
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL
|
||||
try:
|
||||
discuss_prompt = await seed_article_discussion(conv.id, item, model)
|
||||
except EmptyArticleError as e:
|
||||
# Roll back the empty conversation so the user doesn't end up with a
|
||||
# phantom entry in their chat list.
|
||||
try:
|
||||
await delete_conversation(uid, conv.id)
|
||||
except Exception:
|
||||
logger.warning("Failed to clean up empty article conversation %s", conv.id)
|
||||
return jsonify({"error": str(e)}), 422
|
||||
|
||||
# Reload conversation so we see the two messages the helper just added.
|
||||
conv = await get_conversation(uid, conv.id)
|
||||
assert conv is not None
|
||||
|
||||
history: list[dict] = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict: dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
assistant_msg = await add_message(conv.id, "assistant", "", status="generating")
|
||||
buf = create_buffer(conv.id, assistant_msg.id)
|
||||
asyncio.create_task(run_generation(
|
||||
buf, history, model, uid, conv.id, conv.title or "", discuss_prompt,
|
||||
))
|
||||
|
||||
return jsonify({
|
||||
"conversation_id": conv.id,
|
||||
"assistant_message_id": assistant_msg.id,
|
||||
"status": "generating",
|
||||
}), 202
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""HTTP endpoints for the Journal feature.
|
||||
|
||||
Includes the conversational journal endpoints (config / today / day / days /
|
||||
trigger-prep / moments) plus the ambient-context surface lifted from the
|
||||
old Briefing routes (RSS feeds, weather, news, RSS reactions, article-discuss).
|
||||
The ambient endpoints read locations + temp_unit + topic preferences from the
|
||||
``journal_config`` user setting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.models import Conversation, Message, async_session
|
||||
from fabledassistant.services import weather as weather_svc
|
||||
from fabledassistant.services.journal_prep import ensure_daily_prep_message
|
||||
from fabledassistant.services.journal_scheduler import (
|
||||
DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG,
|
||||
update_user_schedule,
|
||||
)
|
||||
from fabledassistant.services.journal_search import search_journal
|
||||
from fabledassistant.services.moments import delete_moment, update_moment
|
||||
from fabledassistant.services.settings import get_setting, set_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
journal_bp = Blueprint("journal", __name__, url_prefix="/api/journal")
|
||||
|
||||
|
||||
def _resolve_tz(tz_str: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _today_in_tz(tz_str: str, *, day_rollover_hour: int) -> datetime.date:
|
||||
tz = _resolve_tz(tz_str)
|
||||
now = datetime.datetime.now(tz)
|
||||
if now.hour < day_rollover_hour:
|
||||
return (now - datetime.timedelta(days=1)).date()
|
||||
return now.date()
|
||||
|
||||
|
||||
async def _user_timezone(user_id: int) -> str:
|
||||
return await get_setting(user_id, "user_timezone", "UTC") or "UTC"
|
||||
|
||||
|
||||
async def _resolve_config(user_id: int) -> dict:
|
||||
raw = await get_setting(user_id, "journal_config", "")
|
||||
config: dict = {}
|
||||
if raw:
|
||||
try:
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
||||
if isinstance(parsed, dict):
|
||||
config = parsed
|
||||
except Exception:
|
||||
logger.warning("Invalid journal_config for user %d", user_id)
|
||||
return {**DEFAULT_JOURNAL_CONFIG, **config}
|
||||
|
||||
|
||||
@journal_bp.get("/config")
|
||||
@login_required
|
||||
async def get_config():
|
||||
user_id = get_current_user_id()
|
||||
return jsonify(await _resolve_config(user_id))
|
||||
|
||||
|
||||
@journal_bp.put("/config")
|
||||
@login_required
|
||||
async def put_config():
|
||||
user_id = get_current_user_id()
|
||||
body = await request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"error": "config must be an object"}), 400
|
||||
await set_setting(user_id, "journal_config", json.dumps(body))
|
||||
await update_user_schedule(user_id)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@journal_bp.get("/today")
|
||||
@login_required
|
||||
async def get_today():
|
||||
user_id = get_current_user_id()
|
||||
config = await _resolve_config(user_id)
|
||||
tz_str = await _user_timezone(user_id)
|
||||
today = _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
|
||||
|
||||
await ensure_daily_prep_message(
|
||||
user_id=user_id, day_date=today, user_timezone=tz_str
|
||||
)
|
||||
return await _day_payload(user_id=user_id, day_date=today)
|
||||
|
||||
|
||||
@journal_bp.get("/day/<iso_date>")
|
||||
@login_required
|
||||
async def get_day(iso_date: str):
|
||||
user_id = get_current_user_id()
|
||||
try:
|
||||
day = datetime.date.fromisoformat(iso_date)
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid date"}), 400
|
||||
return await _day_payload(user_id=user_id, day_date=day)
|
||||
|
||||
|
||||
@journal_bp.get("/days")
|
||||
@login_required
|
||||
async def list_days():
|
||||
user_id = get_current_user_id()
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(Conversation.day_date)
|
||||
.where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Conversation.day_date.is_not(None),
|
||||
)
|
||||
.order_by(Conversation.day_date.desc())
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return jsonify({"days": [d.isoformat() for d in rows]})
|
||||
|
||||
|
||||
@journal_bp.post("/trigger-prep")
|
||||
@login_required
|
||||
async def trigger_prep():
|
||||
user_id = get_current_user_id()
|
||||
body = await request.get_json(silent=True) or {}
|
||||
iso_date = body.get("date")
|
||||
config = await _resolve_config(user_id)
|
||||
tz_str = await _user_timezone(user_id)
|
||||
day = (
|
||||
datetime.date.fromisoformat(iso_date)
|
||||
if iso_date
|
||||
else _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
|
||||
)
|
||||
msg = await ensure_daily_prep_message(
|
||||
user_id=user_id, day_date=day, user_timezone=tz_str, force=True
|
||||
)
|
||||
return jsonify({"ok": True, "message_id": msg.id})
|
||||
|
||||
|
||||
@journal_bp.get("/moments")
|
||||
@login_required
|
||||
async def list_moments():
|
||||
user_id = get_current_user_id()
|
||||
args = request.args
|
||||
df = args.get("date_from")
|
||||
dt = args.get("date_to")
|
||||
person_id = args.get("person_id", type=int)
|
||||
place_id = args.get("place_id", type=int)
|
||||
tag = args.get("tag")
|
||||
query = args.get("query")
|
||||
pinned_only = args.get("pinned_only", "false").lower() == "true"
|
||||
limit = args.get("limit", default=50, type=int)
|
||||
|
||||
results = await search_journal(
|
||||
user_id=user_id,
|
||||
query=query,
|
||||
person_id=person_id,
|
||||
place_id=place_id,
|
||||
tag=tag,
|
||||
date_from=datetime.date.fromisoformat(df) if df else None,
|
||||
date_to=datetime.date.fromisoformat(dt) if dt else None,
|
||||
limit=limit,
|
||||
)
|
||||
if pinned_only:
|
||||
results = [r for r in results if r.get("pinned")]
|
||||
return jsonify({"moments": results})
|
||||
|
||||
|
||||
@journal_bp.patch("/moments/<int:moment_id>")
|
||||
@login_required
|
||||
async def patch_moment(moment_id: int):
|
||||
user_id = get_current_user_id()
|
||||
body = await request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"error": "body must be an object"}), 400
|
||||
moment = await update_moment(
|
||||
user_id=user_id,
|
||||
moment_id=moment_id,
|
||||
content=body.get("content"),
|
||||
tags=body.get("tags"),
|
||||
pinned=body.get("pinned"),
|
||||
person_ids=body.get("person_ids"),
|
||||
place_ids=body.get("place_ids"),
|
||||
task_ids=body.get("task_ids"),
|
||||
note_ids=body.get("note_ids"),
|
||||
)
|
||||
if moment is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(moment.to_dict())
|
||||
|
||||
|
||||
@journal_bp.delete("/moments/<int:moment_id>")
|
||||
@login_required
|
||||
async def remove_moment(moment_id: int):
|
||||
user_id = get_current_user_id()
|
||||
deleted = await delete_moment(user_id=user_id, moment_id=moment_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────────
|
||||
# Ambient endpoints (lifted from the old briefing surface).
|
||||
# ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _journal_temp_unit(user_id: int) -> str:
|
||||
cfg = await _resolve_config(user_id)
|
||||
unit = cfg.get("temp_unit", "C")
|
||||
return unit if unit in ("C", "F") else "C"
|
||||
|
||||
|
||||
# ── Weather ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@journal_bp.get("/weather")
|
||||
@login_required
|
||||
async def get_weather():
|
||||
user_id = get_current_user_id()
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
@journal_bp.get("/weather/current")
|
||||
@login_required
|
||||
async def get_current_weather():
|
||||
"""Live current temperature + conditions for the user's primary location."""
|
||||
user_id = get_current_user_id()
|
||||
cfg = await _resolve_config(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
locations = cfg.get("locations") or {}
|
||||
loc = locations.get("home") or locations.get("work")
|
||||
if not loc or not loc.get("lat") or not loc.get("lon"):
|
||||
return jsonify({"error": "No location configured"}), 404
|
||||
|
||||
current = await weather_svc.fetch_current_conditions(loc["lat"], loc["lon"])
|
||||
if current is None:
|
||||
return jsonify({"error": "Failed to fetch current conditions"}), 502
|
||||
|
||||
temp = current["temperature"]
|
||||
if temp is not None and temp_unit == "F":
|
||||
temp = temp * 9 / 5 + 32
|
||||
current["temperature"] = round(temp) if temp is not None else None
|
||||
current["temp_unit"] = temp_unit
|
||||
current["location"] = loc.get("label") or "Home"
|
||||
return jsonify(current)
|
||||
|
||||
|
||||
@journal_bp.post("/weather/refresh")
|
||||
@login_required
|
||||
async def refresh_weather():
|
||||
user_id = get_current_user_id()
|
||||
cfg = await _resolve_config(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
for key, loc in (cfg.get("locations") or {}).items():
|
||||
if not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id)
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
@journal_bp.post("/weather/geocode")
|
||||
@login_required
|
||||
async def geocode_location():
|
||||
data = await request.get_json()
|
||||
query = (data.get("query") or "").strip()
|
||||
if not query:
|
||||
return jsonify({"error": "query required"}), 400
|
||||
try:
|
||||
lat, lon, label = await weather_svc.geocode(query)
|
||||
return jsonify({"lat": lat, "lon": lon, "label": label})
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
async def _day_payload(*, user_id: int, day_date: datetime.date):
|
||||
async with async_session() as session:
|
||||
conv_stmt = select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Conversation.day_date == day_date,
|
||||
)
|
||||
conv = (await session.execute(conv_stmt)).scalar_one_or_none()
|
||||
if conv is None:
|
||||
return jsonify({
|
||||
"day_date": day_date.isoformat(),
|
||||
"conversation": None,
|
||||
"messages": [],
|
||||
})
|
||||
|
||||
msgs_stmt = (
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv.id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = (await session.execute(msgs_stmt)).scalars().all()
|
||||
return jsonify({
|
||||
"day_date": day_date.isoformat(),
|
||||
"conversation": conv.to_dict(),
|
||||
"messages": [m.to_dict() for m in messages],
|
||||
})
|
||||
@@ -94,17 +94,10 @@ async def update_settings_route():
|
||||
if to_save:
|
||||
await set_settings_batch(uid, to_save)
|
||||
|
||||
# When timezone changes, live-patch the briefing scheduler immediately
|
||||
# Live-reschedule the journal daily-prep job when the timezone changes.
|
||||
if "user_timezone" in to_save:
|
||||
import json as _json
|
||||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
||||
config_raw = await get_setting(uid, "briefing_config", "{}")
|
||||
try:
|
||||
config = _json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
except Exception:
|
||||
config = {}
|
||||
if config.get("enabled"):
|
||||
update_user_schedule(uid, config, tz_override=to_save["user_timezone"] or None)
|
||||
from fabledassistant.services.journal_scheduler import update_user_schedule as _update_journal_schedule
|
||||
await _update_journal_schedule(uid)
|
||||
|
||||
if "default_model" in to_save and to_save["default_model"]:
|
||||
asyncio.create_task(_prime_kv_cache_bg(uid, to_save["default_model"]))
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
"""Prepare article bodies as conversation-ready context.
|
||||
|
||||
Used by the briefing ``discuss-article`` flow and the ``/news`` discuss button.
|
||||
A raw trafilatura extraction is often too large to drop whole into a chat
|
||||
history without eating the context window, so this module runs a map-reduce
|
||||
step over oversized articles and returns a compact, structured context that
|
||||
still preserves the article's meaning across sections.
|
||||
|
||||
Small articles pass through unchanged — map-reduce only fires when the raw
|
||||
body exceeds CHAR_BUDGET. The output is cached on ``rss_items.context_prepared``
|
||||
by the caller, so repeat discuss-clicks on the same article skip this work
|
||||
entirely.
|
||||
|
||||
The module also owns ``seed_article_discussion``, the shared routine that
|
||||
stages a synthetic ``read_article`` tool exchange plus a conversational seed
|
||||
prompt into a conversation. Both the briefing and ``/news`` entry points call
|
||||
it so the two flows stay byte-identical — the only thing that differs between
|
||||
them is whether the conversation already existed or was freshly created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.rss_feed import RssItem
|
||||
from fabledassistant.services.chat import add_message
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ~12k tokens at 4 chars/token. Comfortably under OLLAMA_NUM_CTX=16384
|
||||
# with room left for system prompt, chat history, and the assistant reply.
|
||||
CHAR_BUDGET = 48_000
|
||||
|
||||
# Chunk size for the map step on oversized articles. Overlap preserves
|
||||
# context across paragraph boundaries that happen to land mid-sentence.
|
||||
CHUNK_CHARS = 8_000
|
||||
CHUNK_OVERLAP = 400
|
||||
|
||||
_PARA_SPLIT = re.compile(r"\n\s*\n")
|
||||
|
||||
|
||||
def _chunk_by_paragraph(body: str) -> list[str]:
|
||||
"""Split ``body`` into chunks of up to CHUNK_CHARS, respecting paragraphs.
|
||||
|
||||
Paragraphs longer than CHUNK_CHARS are split mid-paragraph as a last
|
||||
resort. Adjacent chunks share CHUNK_OVERLAP chars of trailing text so
|
||||
a sentence straddling the boundary stays readable on both sides.
|
||||
"""
|
||||
paragraphs = [p.strip() for p in _PARA_SPLIT.split(body) if p.strip()]
|
||||
chunks: list[str] = []
|
||||
current: list[str] = []
|
||||
current_len = 0
|
||||
for para in paragraphs:
|
||||
para_len = len(para)
|
||||
if para_len > CHUNK_CHARS:
|
||||
if current:
|
||||
chunks.append("\n\n".join(current))
|
||||
current, current_len = [], 0
|
||||
for i in range(0, para_len, CHUNK_CHARS - CHUNK_OVERLAP):
|
||||
chunks.append(para[i : i + CHUNK_CHARS])
|
||||
continue
|
||||
if current_len + para_len + 2 > CHUNK_CHARS and current:
|
||||
chunks.append("\n\n".join(current))
|
||||
tail = current[-1][-CHUNK_OVERLAP:] if current else ""
|
||||
current = [tail, para] if tail else [para]
|
||||
current_len = len(tail) + para_len + (2 if tail else 0)
|
||||
else:
|
||||
current.append(para)
|
||||
current_len += para_len + 2
|
||||
if current:
|
||||
chunks.append("\n\n".join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
async def _summarize_chunk(title: str, chunk: str, index: int, total: int, model: str) -> str:
|
||||
"""Map-step summary of one article chunk.
|
||||
|
||||
Aims for ~300 words of dense, factual prose — not bullet points — so the
|
||||
downstream chat model can quote from it naturally.
|
||||
"""
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are summarizing one section of a larger article so a downstream "
|
||||
"conversation model can discuss the full article without having to read "
|
||||
"every word.\n\n"
|
||||
"Requirements:\n"
|
||||
"- 250–350 words of dense factual prose\n"
|
||||
"- Preserve specific claims, numbers, names, and quotes\n"
|
||||
"- Do NOT editorialize or add analysis\n"
|
||||
"- Do NOT use bullet points or headings\n"
|
||||
"- Do NOT say 'this section' or 'this article' — write content, not meta"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Article: {title}\n"
|
||||
f"Section {index + 1} of {total}:\n\n{chunk}"
|
||||
),
|
||||
},
|
||||
]
|
||||
try:
|
||||
# Pin num_ctx — same rationale as services/research.py:66. A large
|
||||
# chunk plus system prompt can push well past the default window;
|
||||
# silent truncation here would drop the tail of the chunk without
|
||||
# any error, producing a misleading summary.
|
||||
raw = await generate_completion(
|
||||
messages, model, max_tokens=600, num_ctx=16384
|
||||
)
|
||||
return raw.strip()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Article chunk summary failed for section %d/%d of '%s'",
|
||||
index + 1, total, title, exc_info=True,
|
||||
)
|
||||
# Fall back to the raw chunk truncated to ~1500 chars so the overall
|
||||
# pipeline still delivers something rather than dropping the section.
|
||||
return chunk[:1500]
|
||||
|
||||
|
||||
async def prepare_article_context(
|
||||
title: str,
|
||||
url: str,
|
||||
body: str,
|
||||
model: str,
|
||||
) -> str:
|
||||
"""Return a conversation-ready context block for ``body``.
|
||||
|
||||
- Small article (≤ CHAR_BUDGET): returns ``body`` unchanged.
|
||||
- Oversized article: runs a parallel map step over paragraph-aware
|
||||
chunks and concatenates the summaries under section headers.
|
||||
|
||||
The returned string is what should go into the ``read_article`` synthetic
|
||||
tool-result in chat history. Callers are responsible for caching it to
|
||||
``rss_items.context_prepared``.
|
||||
"""
|
||||
body = body or ""
|
||||
if len(body) <= CHAR_BUDGET:
|
||||
return body
|
||||
|
||||
chunks = _chunk_by_paragraph(body)
|
||||
logger.info(
|
||||
"Article '%s' is %d chars, map-reducing into %d chunks",
|
||||
title, len(body), len(chunks),
|
||||
)
|
||||
|
||||
summaries = await asyncio.gather(
|
||||
*[
|
||||
_summarize_chunk(title, chunk, i, len(chunks), model)
|
||||
for i, chunk in enumerate(chunks)
|
||||
]
|
||||
)
|
||||
|
||||
header = (
|
||||
f"(This article was longer than the chat window could hold verbatim, "
|
||||
f"so the full text was split into {len(chunks)} sections and each was "
|
||||
"summarized below. Each section preserves specific claims, numbers, "
|
||||
"and quotes from the original.)\n\n"
|
||||
)
|
||||
parts = [
|
||||
f"## Section {i + 1}\n\n{summary}"
|
||||
for i, summary in enumerate(summaries)
|
||||
]
|
||||
return header + "\n\n".join(parts)
|
||||
|
||||
|
||||
# Conversational seed prompt for article discussions. Kept here so both the
|
||||
# briefing and /news entry points use the exact same wording. See
|
||||
# feedback_discuss_prompt_style memory: numbered checklists produce
|
||||
# assignment-completion responses; this conversational seed opens a dialogue.
|
||||
ARTICLE_DISCUSS_SEED = (
|
||||
"I want to talk about this article. Start with a substantive summary "
|
||||
"of what it's arguing and the key evidence it uses, then tell me what "
|
||||
"stood out to you or seems worth pushing back on. I'll ask follow-ups "
|
||||
"from there."
|
||||
)
|
||||
|
||||
|
||||
class EmptyArticleError(Exception):
|
||||
"""Raised when an article has no extractable body text.
|
||||
|
||||
Callers (the briefing and /news discuss routes) map this to a 422 so the
|
||||
user sees a clear error instead of a hallucinated summary built from an
|
||||
empty synthetic tool result.
|
||||
"""
|
||||
|
||||
|
||||
async def seed_article_discussion(
|
||||
conv_id: int,
|
||||
item: RssItem,
|
||||
model: str,
|
||||
) -> str:
|
||||
"""Stage the synthetic read_article tool exchange + conversational seed.
|
||||
|
||||
Used by both the briefing ``discuss_article`` route and the ``/news``
|
||||
``from-article`` conversation creator. Handles the three-layer cache
|
||||
(``context_prepared`` → ``content_full`` → fresh fetch) and inserts two
|
||||
messages into ``conv_id``:
|
||||
|
||||
1. An assistant message with a synthetic ``read_article`` tool_call whose
|
||||
``result.content`` carries the prepared article context. The message
|
||||
also carries ``msg_metadata={"rss_item_id": ...}`` so the post-generation
|
||||
hook in ``generation_task.py`` can locate it and persist the first
|
||||
reply as a discussion-summary Note.
|
||||
2. A user message with the shared conversational seed prompt.
|
||||
|
||||
Returns the seed prompt string so callers can pass it to ``run_generation``
|
||||
as ``user_content``.
|
||||
"""
|
||||
# Avoid circulars: rss helper imports article_context indirectly nowhere,
|
||||
# but keep this local for symmetry with the route-level imports it
|
||||
# replaces.
|
||||
from fabledassistant.services.rss import get_or_fetch_full_article
|
||||
|
||||
if item.context_prepared:
|
||||
article_content = item.context_prepared
|
||||
else:
|
||||
raw_body = await get_or_fetch_full_article(item) or item.content or ""
|
||||
if not raw_body.strip():
|
||||
# Hard-fail rather than stage an empty synthetic tool result.
|
||||
# An empty `content` field silently tells the model "the article
|
||||
# has nothing in it" and it confabulates from RAG/history. Better
|
||||
# to surface a clean error to the user.
|
||||
logger.warning(
|
||||
"Article discussion aborted: empty body for rss_item %s (%s)",
|
||||
item.id, item.url,
|
||||
)
|
||||
raise EmptyArticleError(
|
||||
"Couldn't extract any readable text from this article."
|
||||
)
|
||||
article_content = await prepare_article_context(
|
||||
item.title or "", item.url, raw_body, model,
|
||||
)
|
||||
if not article_content.strip():
|
||||
raise EmptyArticleError(
|
||||
"Couldn't extract any readable text from this article."
|
||||
)
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(RssItem, item.id)
|
||||
if fresh is not None:
|
||||
fresh.context_prepared = article_content
|
||||
await session.commit()
|
||||
|
||||
synthetic_tool_calls = [{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url,
|
||||
"content": article_content,
|
||||
"truncated": False,
|
||||
},
|
||||
}]
|
||||
await add_message(
|
||||
conv_id,
|
||||
"assistant",
|
||||
"",
|
||||
status="complete",
|
||||
tool_calls=synthetic_tool_calls,
|
||||
msg_metadata={"rss_item_id": item.id, "article_seed": True},
|
||||
)
|
||||
await add_message(conv_id, "user", ARTICLE_DISCUSS_SEED)
|
||||
return ARTICLE_DISCUSS_SEED
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Generic article-text fetcher.
|
||||
|
||||
Fetches a URL and extracts its main body via trafilatura. The single source
|
||||
of truth for article-content extraction across the codebase — used by the
|
||||
``read_article`` LLM tool and the ``lookup`` tool's web-result enrichment.
|
||||
|
||||
Trafilatura/lxml is NOT safe to call concurrently — running it via
|
||||
``run_in_executor`` from multiple coroutines can trip a libxml2 double-free.
|
||||
Callers must serialize their fetches (await one before starting the next).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def fetch_article_text(url: str) -> str | None:
|
||||
"""Return the clean article body for *url*, or None on failure.
|
||||
|
||||
Returns None when the HTTP fetch fails or trafilatura yields nothing
|
||||
useful. Callers should treat None as "no article content available."
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, headers={
|
||||
"User-Agent": "Mozilla/5.0 (compatible; FabledScribe/1.0; +https://fabledsword.com)",
|
||||
}) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
raw_html = resp.text
|
||||
except Exception:
|
||||
logger.debug("Failed to fetch article URL %s", url)
|
||||
return None
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
import trafilatura
|
||||
text = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: trafilatura.extract(
|
||||
raw_html,
|
||||
include_comments=False,
|
||||
include_tables=True,
|
||||
favor_recall=True,
|
||||
),
|
||||
)
|
||||
return text or None
|
||||
except Exception:
|
||||
logger.debug("trafilatura extraction failed for %s", url, exc_info=True)
|
||||
return None
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Create and manage briefing conversations."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.services.tz import user_briefing_date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_or_create_today_conversation(user_id: int, model: str) -> Conversation:
|
||||
"""
|
||||
Return today's briefing conversation, creating it if it doesn't exist.
|
||||
"""
|
||||
# "Today" is the user-local briefing day (flips at 4am local), not
|
||||
# ``date.today()`` — in a UTC container the latter rolls over at
|
||||
# 19:00 NY local and makes the in-progress briefing disappear.
|
||||
today = await user_briefing_date(user_id)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == today,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv is not None:
|
||||
return conv
|
||||
|
||||
conv = Conversation(
|
||||
user_id=user_id,
|
||||
title=f"Briefing — {today.isoformat()}",
|
||||
model=model,
|
||||
conversation_type="briefing",
|
||||
briefing_date=today,
|
||||
)
|
||||
session.add(conv)
|
||||
await session.commit()
|
||||
await session.refresh(conv)
|
||||
return conv
|
||||
|
||||
|
||||
async def post_message(
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
content: str,
|
||||
metadata: dict | None = None,
|
||||
tool_calls: list | None = None,
|
||||
) -> Message:
|
||||
"""Append a message to a briefing conversation.
|
||||
|
||||
``tool_calls`` is accepted on assistant-role messages so the full
|
||||
agentic briefing sequence (assistant tool-call turns and tool-role
|
||||
results) can be persisted as real conversation rows, keeping the
|
||||
receipts in context on chat follow-ups.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
role=role,
|
||||
content=content,
|
||||
status="complete",
|
||||
msg_metadata=metadata,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
session.add(msg)
|
||||
# Bump conversation updated_at
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
if conv:
|
||||
conv.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
|
||||
|
||||
async def list_briefing_conversations(user_id: int) -> list[dict]:
|
||||
"""Return briefing conversations newest first."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
).order_by(Conversation.briefing_date.desc().nullslast())
|
||||
.limit(30)
|
||||
)
|
||||
convs = list(result.scalars().all())
|
||||
return [c.to_dict() for c in convs]
|
||||
@@ -1,429 +0,0 @@
|
||||
"""
|
||||
Briefing pipeline: agentic tool-use loop + UI metadata gather.
|
||||
|
||||
Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (4pm)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
|
||||
|
||||
|
||||
# ── External data gather ──────────────────────────────────────────────────────
|
||||
|
||||
async def _gather_external(user_id: int) -> dict:
|
||||
"""Collect RSS items (when enabled) and weather."""
|
||||
from fabledassistant.services.weather import get_cached_weather
|
||||
|
||||
rss_on = (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
|
||||
rss_items: list = []
|
||||
if rss_on:
|
||||
from fabledassistant.services.rss import get_recent_items
|
||||
try:
|
||||
rss_items = await get_recent_items(user_id, limit=20)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
weather = await get_cached_weather(user_id)
|
||||
except Exception:
|
||||
weather = []
|
||||
|
||||
return {
|
||||
"rss_items": rss_items,
|
||||
"weather": weather,
|
||||
}
|
||||
|
||||
|
||||
# ── Agentic briefing (tool-use loop) ──────────────────────────────────────────
|
||||
|
||||
_BRIEFING_AGENT_MAX_ROUNDS = 8
|
||||
_BRIEFING_AGENT_NUM_CTX = 8192
|
||||
|
||||
|
||||
def _agentic_system_prompt(
|
||||
profile_body: str,
|
||||
slot: str,
|
||||
today_iso: str,
|
||||
tz_name: str,
|
||||
day_from_iso: str,
|
||||
day_to_iso: str,
|
||||
) -> str:
|
||||
"""System prompt for the agentic briefing path.
|
||||
|
||||
Pushes the model to ground every factual claim in a tool result and
|
||||
to be honest when tools return nothing, rather than fabricating
|
||||
content to fill the narrative.
|
||||
|
||||
Pre-computes today's window in the user's local timezone so the
|
||||
model can call date-sensitive tools (list_events, list_tasks filters)
|
||||
without having to do any timezone math itself — eliminating a whole
|
||||
class of "wrong day" bugs.
|
||||
"""
|
||||
tz_block = (
|
||||
f"Today is {today_iso} ({tz_name}). "
|
||||
f"When calling list_events for today, use:\n"
|
||||
f" date_from = {day_from_iso}\n"
|
||||
f" date_to = {day_to_iso}\n"
|
||||
f"These are already the correct local-day boundaries — do not convert "
|
||||
f"them to UTC or any other timezone. For other date ranges, compute in "
|
||||
f"the same timezone.\n\n"
|
||||
)
|
||||
|
||||
if slot == "compilation":
|
||||
base = (
|
||||
"You are the user's personal assistant giving their full morning briefing. "
|
||||
"Weave real data from tool calls into a warm, natural-sounding summary.\n\n"
|
||||
"Tools to call every compilation (skip only if you already know a category is empty):\n"
|
||||
"- list_tasks — what's due today, overdue, or in progress\n"
|
||||
"- list_events — what's on the calendar today\n"
|
||||
"- get_weather — today's forecast\n"
|
||||
"- get_rss_items — recent news/blog items from the user's feeds\n"
|
||||
"- list_projects (optional) — active project context for narrative continuity\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
|
||||
"- If a tool returns nothing (no events today, no overdue tasks, no news items), "
|
||||
"say so honestly. Don't fabricate items to fill space.\n"
|
||||
"- For news, pick one or two items worth mentioning — surface the theme, not a laundry list.\n"
|
||||
"- Write flowing prose. No markdown, no headers, no bullet points.\n"
|
||||
"- Aim for 6 to 10 sentences. Skip topics that have nothing interesting.\n"
|
||||
"- Close on one or two concrete, actionable suggestions.\n\n"
|
||||
)
|
||||
elif slot == "weekly_review":
|
||||
base = (
|
||||
"You are the user's personal assistant delivering a weekly review. "
|
||||
"Use the tools available to see what was accomplished this week, what's still "
|
||||
"overdue, how many notes were captured, and what's coming up in the next seven days. "
|
||||
"Write a reflective recap that celebrates real progress and gently flags what's stuck.\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
|
||||
"- If a category is empty, say so honestly rather than inventing items.\n"
|
||||
"- Write flowing prose. No markdown, no bullet points.\n"
|
||||
"- Aim for 5 to 8 sentences. Reflective and encouraging tone.\n\n"
|
||||
)
|
||||
else: # morning, midday, afternoon check-ins
|
||||
base = (
|
||||
f"You are the user's personal assistant giving a brief {slot} check-in. "
|
||||
"Use tools to see what's changed since this morning. Focus on progress and "
|
||||
"what's still unaddressed.\n\n"
|
||||
"When checking tasks, call list_tasks at least twice:\n"
|
||||
"- once with status=\"in_progress\" to see anything already being worked on "
|
||||
"(regardless of due date — these can quietly drag past their due dates)\n"
|
||||
"- once filtered by due date for what's coming up or overdue today\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see current state. Never assert facts without tool results.\n"
|
||||
"- If nothing meaningful has changed, say so briefly — don't invent progress.\n"
|
||||
"- 3 to 5 sentences, natural prose, no markdown.\n\n"
|
||||
)
|
||||
|
||||
base = tz_block + base
|
||||
if profile_body:
|
||||
base += f"User profile (tone and preferences):\n{profile_body}\n"
|
||||
return base
|
||||
|
||||
|
||||
def _agentic_user_trigger(slot: str, date_str: str) -> str:
|
||||
"""Seed user-role message that kicks off the agentic run."""
|
||||
labels = {
|
||||
"compilation": "morning briefing",
|
||||
"morning": "morning check-in",
|
||||
"midday": "midday check-in",
|
||||
"afternoon": "afternoon wrap-up",
|
||||
"weekly_review": "weekly review",
|
||||
}
|
||||
label = labels.get(slot, f"{slot} briefing")
|
||||
return f"Generate my {label} for {date_str}."
|
||||
|
||||
|
||||
async def run_agentic_briefing(
|
||||
user_id: int,
|
||||
slot: str,
|
||||
model: str,
|
||||
conv_id: int | None = None,
|
||||
rss_override: list[dict] | None = None,
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""
|
||||
Run the agentic briefing loop for a user and slot.
|
||||
|
||||
Uses the chat pipeline's tool-use loop with a curated read-only tool
|
||||
subset and a slot-specific system prompt. Every fact the model states
|
||||
is either derived from a tool result visible in the returned message
|
||||
list or it's the model hallucinating — so follow-up chat in the same
|
||||
conversation can hold the model to what the tool results actually show.
|
||||
|
||||
Returns ``(final_prose, message_list)`` where ``message_list`` is the
|
||||
full sequence including system, user trigger, tool calls, and tool
|
||||
results. Callers are expected to persist those intermediate turns
|
||||
alongside the final prose so the receipts remain in conversation
|
||||
history on follow-up.
|
||||
|
||||
If the loop fails or the model returns empty prose, returns
|
||||
``("", [])`` and the caller should fall back to the legacy path.
|
||||
"""
|
||||
from fabledassistant.services.llm import stream_chat_with_tools, ChatChunk # noqa: F401
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
from fabledassistant.services.briefing_tools import get_briefing_tools
|
||||
from fabledassistant.services.user_profile import build_profile_context
|
||||
|
||||
profile_context = await build_profile_context(user_id)
|
||||
tools = await get_briefing_tools(user_id)
|
||||
|
||||
if not tools:
|
||||
logger.warning(
|
||||
"Agentic briefing for user %d slot %s: no tools available — aborting",
|
||||
user_id, slot,
|
||||
)
|
||||
return "", []
|
||||
|
||||
# Compute today's window in the user's local timezone so the model
|
||||
# receives ready-to-use ISO 8601 boundaries and never has to do its
|
||||
# own tz math when calling date-sensitive tools like list_events.
|
||||
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
user_tz = ZoneInfo(tz_name)
|
||||
except ZoneInfoNotFoundError:
|
||||
user_tz = ZoneInfo("UTC")
|
||||
tz_name = "UTC"
|
||||
now_local = datetime.now(user_tz)
|
||||
today_iso = now_local.date().isoformat()
|
||||
day_start = datetime(now_local.year, now_local.month, now_local.day, 0, 0, 0, tzinfo=user_tz)
|
||||
day_end = datetime(now_local.year, now_local.month, now_local.day, 23, 59, 59, tzinfo=user_tz)
|
||||
day_from_iso = day_start.isoformat()
|
||||
day_to_iso = day_end.isoformat()
|
||||
|
||||
system_prompt = _agentic_system_prompt(
|
||||
profile_context, slot, today_iso, tz_name, day_from_iso, day_to_iso,
|
||||
)
|
||||
messages: list[dict] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": _agentic_user_trigger(slot, today_iso)},
|
||||
]
|
||||
|
||||
final_text = ""
|
||||
for round_idx in range(_BRIEFING_AGENT_MAX_ROUNDS):
|
||||
accumulated_content = ""
|
||||
accumulated_tool_calls: list[dict] = []
|
||||
|
||||
try:
|
||||
async for chunk in stream_chat_with_tools(
|
||||
messages, model, tools=tools, think=False,
|
||||
num_ctx=_BRIEFING_AGENT_NUM_CTX,
|
||||
):
|
||||
if chunk.type == "content" and chunk.content:
|
||||
accumulated_content += chunk.content
|
||||
elif chunk.type == "tool_calls" and chunk.tool_calls:
|
||||
accumulated_tool_calls.extend(chunk.tool_calls)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Agentic briefing stream failed (user %d, slot %s, round %d)",
|
||||
user_id, slot, round_idx, exc_info=True,
|
||||
)
|
||||
return "", []
|
||||
|
||||
# Append the assistant turn (content + any tool calls) to history
|
||||
assistant_msg: dict = {"role": "assistant", "content": accumulated_content}
|
||||
if accumulated_tool_calls:
|
||||
assistant_msg["tool_calls"] = accumulated_tool_calls
|
||||
messages.append(assistant_msg)
|
||||
|
||||
# No tool calls → the model is done
|
||||
if not accumulated_tool_calls:
|
||||
final_text = accumulated_content.strip()
|
||||
break
|
||||
|
||||
# Execute each tool call and append results as tool-role messages
|
||||
for tc in accumulated_tool_calls:
|
||||
fn = tc.get("function") or {}
|
||||
tool_name = fn.get("name", "")
|
||||
arguments = fn.get("arguments") or {}
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
import json as _json
|
||||
arguments = _json.loads(arguments)
|
||||
except Exception:
|
||||
arguments = {}
|
||||
|
||||
# Default list_tasks to active statuses only so cancelled/done
|
||||
# items don't slip into briefing prose. The model can still
|
||||
# pass an explicit status filter when it wants something else.
|
||||
if tool_name == "list_tasks" and not arguments.get("status"):
|
||||
arguments["status"] = ["todo", "in_progress"]
|
||||
|
||||
try:
|
||||
if tool_name == "get_rss_items" and rss_override is not None:
|
||||
# Use topic-scored/filtered items already computed by
|
||||
# the briefing pipeline rather than the raw feed dump
|
||||
# that execute_tool would return. Keeps the model's
|
||||
# view of news aligned with the user's topic prefs
|
||||
# and the sidebar's rss_item_ids metadata.
|
||||
slim = [
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"title": item.get("title", ""),
|
||||
"url": item.get("url", ""),
|
||||
"source": item.get("feed_title", ""),
|
||||
"summary": (item.get("content") or "")[:400],
|
||||
"published_at": item.get("published_at"),
|
||||
"topics": item.get("topics") or [],
|
||||
}
|
||||
for item in rss_override
|
||||
]
|
||||
result = {"success": True, "data": {"items": slim, "count": len(slim)}}
|
||||
else:
|
||||
result = await execute_tool(user_id, tool_name, arguments, conv_id=conv_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Tool %s failed during agentic briefing: %s", tool_name, exc,
|
||||
)
|
||||
result = {"success": False, "error": str(exc)}
|
||||
|
||||
# Serialize the result compactly for the model's context
|
||||
import json as _json
|
||||
try:
|
||||
result_str = _json.dumps(result, default=str)[:4000]
|
||||
except Exception:
|
||||
result_str = str(result)[:4000]
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"content": result_str,
|
||||
"tool_name": tool_name,
|
||||
})
|
||||
else:
|
||||
logger.warning(
|
||||
"Agentic briefing hit max rounds (%d) for user %d slot %s — using last content",
|
||||
_BRIEFING_AGENT_MAX_ROUNDS, user_id, slot,
|
||||
)
|
||||
# Walk back to find the last assistant message with non-empty content
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "assistant" and m.get("content"):
|
||||
final_text = m["content"].strip()
|
||||
break
|
||||
|
||||
return final_text, messages
|
||||
|
||||
|
||||
# ── Main entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_temp_unit(user_id: int) -> str:
|
||||
"""Read the user's preferred temperature unit from briefing_config ('C' or 'F')."""
|
||||
import json
|
||||
raw = await get_setting(user_id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
unit = config.get("temp_unit", "C")
|
||||
return unit if unit in ("C", "F") else "C"
|
||||
except Exception:
|
||||
return "C"
|
||||
|
||||
|
||||
async def run_compilation(
|
||||
user_id: int,
|
||||
slot: str,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Run the agentic briefing loop and gather UI metadata (RSS + weather).
|
||||
|
||||
Returns ``(briefing_text, metadata)`` where metadata contains
|
||||
``rss_item_ids``, ``rss_items``, ``weather`` for frontend rendering,
|
||||
and ``agentic_messages`` (the full tool-call sequence) for the
|
||||
scheduler to persist as separate conversation rows.
|
||||
"""
|
||||
if model is None:
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
from fabledassistant.services.briefing_preferences import (
|
||||
load_topic_preferences,
|
||||
load_topic_reaction_scores,
|
||||
score_and_filter_items,
|
||||
)
|
||||
from fabledassistant.services.weather import parse_weather_card_data, get_cached_weather_rows
|
||||
|
||||
include_topics, exclude_topics = await load_topic_preferences(user_id)
|
||||
topic_scores = await load_topic_reaction_scores(user_id)
|
||||
|
||||
external_data, weather_rows, temp_unit = await asyncio.gather(
|
||||
_gather_external(user_id),
|
||||
get_cached_weather_rows(user_id),
|
||||
_get_temp_unit(user_id),
|
||||
)
|
||||
|
||||
raw_rss = external_data.get("rss_items") or []
|
||||
filtered_rss = score_and_filter_items(
|
||||
raw_rss,
|
||||
include_topics=include_topics,
|
||||
exclude_topics=exclude_topics,
|
||||
topic_scores=topic_scores,
|
||||
max_items=10,
|
||||
)
|
||||
rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")]
|
||||
rss_items_meta = [
|
||||
{
|
||||
"id": item["id"],
|
||||
"title": item.get("title", ""),
|
||||
"url": item.get("url", ""),
|
||||
"source": item.get("feed_title", ""),
|
||||
"snippet": (item.get("content") or "")[:300],
|
||||
"published_at": item.get("published_at"),
|
||||
}
|
||||
for item in filtered_rss
|
||||
if item.get("id")
|
||||
]
|
||||
|
||||
weather_cards = [
|
||||
card for row in weather_rows
|
||||
if (card := parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
weather_card = weather_cards[0] if weather_cards else None
|
||||
|
||||
briefing_text, agentic_messages = await run_agentic_briefing(
|
||||
user_id, slot, model, conv_id=None, rss_override=filtered_rss,
|
||||
)
|
||||
|
||||
metadata: dict = {
|
||||
"rss_item_ids": rss_item_ids,
|
||||
"rss_items": rss_items_meta,
|
||||
"weather": weather_card,
|
||||
}
|
||||
if agentic_messages:
|
||||
metadata["agentic_messages"] = agentic_messages
|
||||
|
||||
if not briefing_text:
|
||||
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
|
||||
return "", metadata
|
||||
|
||||
return briefing_text, metadata
|
||||
|
||||
|
||||
async def run_slot_injection(
|
||||
user_id: int,
|
||||
slot: str,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Lighter check-in update for 8am/12pm/4pm slots.
|
||||
|
||||
Runs the agentic loop with the slot-specific prompt. Returns
|
||||
``(text, metadata)`` where metadata contains ``agentic_messages``
|
||||
for the scheduler to persist.
|
||||
"""
|
||||
if model is None:
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
text, agentic_messages = await run_agentic_briefing(
|
||||
user_id, slot, model, conv_id=None,
|
||||
)
|
||||
|
||||
metadata: dict = {}
|
||||
if agentic_messages:
|
||||
metadata["agentic_messages"] = agentic_messages
|
||||
return text, metadata
|
||||
@@ -1,110 +0,0 @@
|
||||
"""
|
||||
Briefing preferences: load topic settings, aggregate reaction scores,
|
||||
filter and rank RSS items for briefing inclusion.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def load_topic_preferences(user_id: int) -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
Return (include_topics, exclude_topics) from user settings.
|
||||
"""
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
raw_include = await get_setting(user_id, "briefing_include_topics", "[]")
|
||||
raw_exclude = await get_setting(user_id, "briefing_exclude_topics", "[]")
|
||||
|
||||
def _parse(raw) -> list[str]:
|
||||
try:
|
||||
val = json.loads(raw) if isinstance(raw, str) else raw
|
||||
return [str(t) for t in val] if isinstance(val, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
return _parse(raw_include), _parse(raw_exclude)
|
||||
|
||||
|
||||
async def load_topic_reaction_scores(user_id: int) -> dict[str, float]:
|
||||
"""
|
||||
Aggregate per-topic reaction scores from the last 30 days.
|
||||
Returns a dict of topic -> net_score (positive = liked, negative = disliked).
|
||||
Uses rss_item_reactions joined to rss_items.topics.
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
_text("""
|
||||
SELECT unnest(i.topics) AS topic,
|
||||
SUM(CASE r.reaction WHEN 'up' THEN 1 ELSE -1 END) AS score
|
||||
FROM rss_item_reactions r
|
||||
JOIN rss_items i ON i.id = r.rss_item_id
|
||||
WHERE r.user_id = :uid
|
||||
AND r.created_at > NOW() - INTERVAL '30 days'
|
||||
GROUP BY topic
|
||||
""").bindparams(uid=user_id)
|
||||
)
|
||||
return {row.topic: float(row.score) for row in result}
|
||||
except Exception:
|
||||
logger.warning("Failed to load topic reaction scores", exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
def score_and_filter_items(
|
||||
items: list[dict],
|
||||
include_topics: list[str],
|
||||
exclude_topics: list[str],
|
||||
topic_scores: dict[str, float],
|
||||
max_items: int = 10,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Score, filter, and rank RSS items for briefing inclusion.
|
||||
|
||||
Scoring:
|
||||
- Hard-exclude: any item tagged with an excluded topic is removed.
|
||||
- Base score: 0.0
|
||||
- +2.0 per topic that appears in include_topics
|
||||
- +1.0 / -1.0 per topic based on reaction score (clamped per topic)
|
||||
- Tiebreak: newer published_at wins
|
||||
|
||||
Returns up to max_items items, highest score first.
|
||||
Items with classified_at=None (unclassified) pass through with score=0.
|
||||
"""
|
||||
include_set = set(include_topics)
|
||||
exclude_set = set(exclude_topics)
|
||||
scored = []
|
||||
|
||||
for item in items:
|
||||
item_topics = item.get("topics") or []
|
||||
|
||||
# Hard exclude
|
||||
if exclude_set and any(t in exclude_set for t in item_topics):
|
||||
continue
|
||||
|
||||
score = 0.0
|
||||
for topic in item_topics:
|
||||
if topic in include_set:
|
||||
score += 2.0
|
||||
if topic in topic_scores:
|
||||
score += max(-1.0, min(1.0, topic_scores[topic]))
|
||||
|
||||
# Parse published_at for tiebreak
|
||||
pub_str = item.get("published_at") or ""
|
||||
try:
|
||||
pub_ts = datetime.fromisoformat(pub_str).timestamp() if pub_str else 0.0
|
||||
except ValueError:
|
||||
pub_ts = 0.0
|
||||
|
||||
scored.append((score, pub_ts, item))
|
||||
|
||||
# Sort: highest score first, then newest first
|
||||
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||
return [item for _, _, item in scored[:max_items]]
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Briefing profile note: stores learned user preferences for the briefing assistant."""
|
||||
|
||||
import logging
|
||||
|
||||
from fabledassistant.services.notes import create_note, list_notes, update_note
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROFILE_TAG = "briefing-profile"
|
||||
PROFILE_TITLE = "Briefing Profile"
|
||||
|
||||
|
||||
async def _find_profile_note(user_id: int) -> dict | None:
|
||||
"""Find the user's briefing profile note by tag."""
|
||||
notes, _total = await list_notes(user_id, tags=[PROFILE_TAG], limit=1)
|
||||
if not notes:
|
||||
return None
|
||||
note = notes[0]
|
||||
return {
|
||||
"id": note.id,
|
||||
"body": note.body or "",
|
||||
"title": note.title,
|
||||
}
|
||||
|
||||
|
||||
async def get_profile_body(user_id: int) -> str:
|
||||
"""Return the body of the briefing profile note, or '' if none exists."""
|
||||
note = await _find_profile_note(user_id)
|
||||
return note["body"] if note else ""
|
||||
|
||||
|
||||
async def get_profile_note_id(user_id: int) -> int | None:
|
||||
note = await _find_profile_note(user_id)
|
||||
return note["id"] if note else None
|
||||
|
||||
|
||||
async def ensure_profile_note(user_id: int) -> int:
|
||||
"""
|
||||
Get or create the briefing profile note.
|
||||
Returns the note id.
|
||||
"""
|
||||
note = await _find_profile_note(user_id)
|
||||
if note:
|
||||
return note["id"]
|
||||
created = await create_note(
|
||||
user_id=user_id,
|
||||
title=PROFILE_TITLE,
|
||||
body=(
|
||||
"# Briefing Profile\n\n"
|
||||
"This note is maintained by the briefing assistant. "
|
||||
"It stores your preferences, patterns, and work schedule.\n\n"
|
||||
"## Work Schedule\n\n"
|
||||
"Office days: (not yet configured)\n\n"
|
||||
"## Locations\n\n"
|
||||
"(configured via Settings → Briefing)\n\n"
|
||||
"## Preferences\n\n"
|
||||
"(the assistant will add observations here over time)\n"
|
||||
),
|
||||
tags=[PROFILE_TAG],
|
||||
)
|
||||
return created.id
|
||||
|
||||
|
||||
async def append_observations(user_id: int, observations: str) -> None:
|
||||
"""
|
||||
Append the assistant's end-of-day observations to the profile note.
|
||||
Creates the note if it doesn't exist.
|
||||
"""
|
||||
if not observations.strip():
|
||||
return
|
||||
note_id = await ensure_profile_note(user_id)
|
||||
note = await _find_profile_note(user_id)
|
||||
if not note:
|
||||
return
|
||||
current_body = note.get("body", "")
|
||||
from datetime import date
|
||||
date_str = date.today().isoformat()
|
||||
new_body = current_body.rstrip() + f"\n\n## Observations — {date_str}\n\n{observations.strip()}\n"
|
||||
await update_note(user_id, note_id, body=new_body)
|
||||
logger.info("Briefing profile updated for user %d", user_id)
|
||||
@@ -1,625 +0,0 @@
|
||||
"""
|
||||
APScheduler-based briefing scheduler — per-user, timezone-aware.
|
||||
|
||||
Each enabled user gets 4 individual CronTrigger jobs keyed to their IANA
|
||||
timezone (stored in briefing_config.timezone). Changing the config via the
|
||||
settings UI calls update_user_schedule() which live-patches the scheduler
|
||||
without a restart.
|
||||
|
||||
Uses a background thread scheduler (not async) because APScheduler 3.x's
|
||||
AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async
|
||||
functions wrapped with asyncio.run_coroutine_threadsafe().
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
# Slot definitions: (name, hour, minute) — local time in the user's timezone
|
||||
SLOTS = [
|
||||
("compilation", 4, 0),
|
||||
("morning", 8, 0),
|
||||
("midday", 12, 0),
|
||||
("afternoon", 16, 0),
|
||||
]
|
||||
|
||||
# Weekly review runs Sunday at 6pm by default
|
||||
WEEKLY_REVIEW_DAY = "sun" # APScheduler day_of_week format
|
||||
WEEKLY_REVIEW_HOUR = 18
|
||||
WEEKLY_REVIEW_MINUTE = 0
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_timezone(tz_str: str) -> str:
|
||||
"""Validate and return an IANA timezone string, falling back to UTC."""
|
||||
if not tz_str:
|
||||
return "UTC"
|
||||
try:
|
||||
ZoneInfo(tz_str)
|
||||
return tz_str
|
||||
except (ZoneInfoNotFoundError, KeyError):
|
||||
logger.warning("Invalid timezone %r in briefing config, falling back to UTC", tz_str)
|
||||
return "UTC"
|
||||
|
||||
|
||||
async def _get_briefing_enabled_users() -> list[tuple[int, str, dict]]:
|
||||
"""Return [(user_id, iana_timezone, config)] for all users with briefing enabled."""
|
||||
import json
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.key.in_(["briefing_config", "user_timezone"]))
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
by_user: dict[int, dict[str, str]] = {}
|
||||
for row in rows:
|
||||
by_user.setdefault(row.user_id, {})[row.key] = row.value or ""
|
||||
|
||||
enabled = []
|
||||
for user_id, settings in by_user.items():
|
||||
try:
|
||||
config = json.loads(settings.get("briefing_config", "{}") or "{}")
|
||||
if config.get("enabled"):
|
||||
tz_str = settings.get("user_timezone") or config.get("timezone", "UTC")
|
||||
tz = _resolve_timezone(tz_str)
|
||||
enabled.append((user_id, tz, config))
|
||||
except Exception:
|
||||
pass
|
||||
return enabled
|
||||
|
||||
|
||||
def _job_id(user_id: int, slot: str) -> str:
|
||||
return f"briefing_{slot}_user_{user_id}"
|
||||
|
||||
|
||||
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
||||
"""Add (or replace) slot jobs for a user, skipping disabled slots."""
|
||||
if _scheduler is None or _loop is None:
|
||||
return
|
||||
enabled_slots = (config or {}).get("slots", {})
|
||||
for slot_name, hour, minute in SLOTS:
|
||||
jid = _job_id(user_id, slot_name)
|
||||
# compilation always runs; other slots default to True if not in config
|
||||
slot_on = enabled_slots.get(slot_name, True)
|
||||
if not slot_on:
|
||||
if _scheduler.get_job(jid):
|
||||
_scheduler.remove_job(jid)
|
||||
continue
|
||||
_scheduler.add_job(
|
||||
_run_user_slot_sync,
|
||||
CronTrigger(hour=hour, minute=minute, timezone=tz),
|
||||
args=[user_id, slot_name],
|
||||
id=jid,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
# Weekly review job — runs once per week
|
||||
weekly_jid = _job_id(user_id, "weekly_review")
|
||||
weekly_on = enabled_slots.get("weekly_review", True)
|
||||
if weekly_on:
|
||||
_scheduler.add_job(
|
||||
_run_user_slot_sync,
|
||||
CronTrigger(
|
||||
day_of_week=WEEKLY_REVIEW_DAY,
|
||||
hour=WEEKLY_REVIEW_HOUR,
|
||||
minute=WEEKLY_REVIEW_MINUTE,
|
||||
timezone=tz,
|
||||
),
|
||||
args=[user_id, "weekly_review"],
|
||||
id=weekly_jid,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=7200,
|
||||
)
|
||||
elif _scheduler.get_job(weekly_jid):
|
||||
_scheduler.remove_job(weekly_jid)
|
||||
|
||||
logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz)
|
||||
|
||||
|
||||
def _remove_user_jobs(user_id: int) -> None:
|
||||
"""Remove all slot jobs for a user."""
|
||||
if _scheduler is None:
|
||||
return
|
||||
for slot_name, _, _ in SLOTS:
|
||||
jid = _job_id(user_id, slot_name)
|
||||
if _scheduler.get_job(jid):
|
||||
_scheduler.remove_job(jid)
|
||||
weekly_jid = _job_id(user_id, "weekly_review")
|
||||
if _scheduler.get_job(weekly_jid):
|
||||
_scheduler.remove_job(weekly_jid)
|
||||
logger.info("Removed briefing jobs for user %d", user_id)
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def update_user_schedule(user_id: int, config: dict, tz_override: str | None = None) -> None:
|
||||
"""
|
||||
Called when a user saves their briefing config via the settings UI.
|
||||
Live-patches the scheduler — no restart required.
|
||||
tz_override takes priority over any timezone in config.
|
||||
"""
|
||||
if config.get("enabled"):
|
||||
tz_str = tz_override or config.get("timezone", "UTC")
|
||||
tz = _resolve_timezone(tz_str)
|
||||
_add_user_jobs(user_id, tz, config)
|
||||
else:
|
||||
_remove_user_jobs(user_id)
|
||||
|
||||
|
||||
# ── Job execution ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _auto_pause_stale_projects(user_id: int) -> list[str]:
|
||||
"""Pause active projects with no note/task activity in 14+ days. Returns paused project titles."""
|
||||
from sqlalchemy import select as _sel, func as _func
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models import async_session as _session
|
||||
|
||||
paused: list[str] = []
|
||||
threshold = datetime.now(timezone.utc) - timedelta(days=14)
|
||||
try:
|
||||
async with _session() as session:
|
||||
projects = (await session.execute(
|
||||
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
|
||||
)).scalars().all()
|
||||
for p in projects:
|
||||
latest = (await session.execute(
|
||||
_sel(_func.max(Note.updated_at)).where(Note.project_id == p.id)
|
||||
)).scalar()
|
||||
if latest is None or latest < threshold:
|
||||
p.status = "paused"
|
||||
paused.append(p.title or "Untitled")
|
||||
if paused:
|
||||
await session.commit()
|
||||
logger.info("Auto-paused %d stale projects for user %d: %s", len(paused), user_id, paused)
|
||||
except Exception:
|
||||
logger.debug("Auto-pause check failed for user %d", user_id, exc_info=True)
|
||||
return paused
|
||||
|
||||
|
||||
async def _persist_agentic_messages(
|
||||
conv_id: int,
|
||||
slot: str,
|
||||
metadata: dict | None,
|
||||
) -> None:
|
||||
"""Persist the intermediate turns from an agentic briefing run.
|
||||
|
||||
``metadata["agentic_messages"]`` is the full message list the agent
|
||||
generated — system prompt, user trigger, assistant tool-call turns,
|
||||
tool-role results, and the final assistant prose.
|
||||
|
||||
To stay compatible with the existing chat loader in ``routes/chat.py``,
|
||||
tool results are folded back into the parent assistant message's
|
||||
``tool_calls[i]["result"]`` field rather than being persisted as
|
||||
separate ``role="tool"`` rows. This matches how regular chat
|
||||
persists agentic turns, so the follow-up chat endpoint can rehydrate
|
||||
the tool sequence using its existing logic.
|
||||
|
||||
Persists everything except the system prompt (implicit in the chat
|
||||
pipeline) and the final assistant prose (the caller posts that
|
||||
separately with the user-facing metadata block). The synthetic user
|
||||
trigger message is persisted so Ollama sees a user→assistant→user
|
||||
sequence rather than an orphaned assistant reply — it's tagged as
|
||||
intermediate so the UI can hide it.
|
||||
|
||||
Legacy (non-agentic) briefings have no ``agentic_messages`` and this
|
||||
function is a no-op.
|
||||
"""
|
||||
from fabledassistant.services.briefing_conversations import post_message
|
||||
|
||||
if not metadata:
|
||||
return
|
||||
agentic_messages = metadata.get("agentic_messages") or []
|
||||
if not agentic_messages:
|
||||
return
|
||||
|
||||
# Drop the system prompt (index 0) and the final assistant prose
|
||||
# (last item). The caller posts the final prose as its own message.
|
||||
middle = agentic_messages[1:-1]
|
||||
|
||||
# Walk the middle sequence, pairing each assistant tool-call turn
|
||||
# with the tool-role results that immediately follow it.
|
||||
i = 0
|
||||
n = len(middle)
|
||||
while i < n:
|
||||
m = middle[i]
|
||||
role = m.get("role", "")
|
||||
content = m.get("content", "") or ""
|
||||
tag = {"briefing_slot": slot, "briefing_intermediate": True}
|
||||
|
||||
if role == "assistant" and m.get("tool_calls"):
|
||||
# Collect subsequent tool-role results, matching them
|
||||
# positionally onto this assistant's tool_calls. Normalise
|
||||
# each entry to the flat storage format the chat loader
|
||||
# expects: {"function": <name>, "arguments": <args>,
|
||||
# "result": <result>, "status": "success"|"error"}.
|
||||
raw_tool_calls = list(m["tool_calls"])
|
||||
flat_tool_calls: list[dict] = []
|
||||
result_idx = 0
|
||||
j = i + 1
|
||||
|
||||
import json as _json
|
||||
for raw_tc in raw_tool_calls:
|
||||
fn = raw_tc.get("function") or {}
|
||||
name = fn.get("name") if isinstance(fn, dict) else str(fn)
|
||||
arguments = fn.get("arguments") if isinstance(fn, dict) else {}
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = _json.loads(arguments)
|
||||
except Exception:
|
||||
arguments = {}
|
||||
|
||||
# Pair up with the next available tool-role message
|
||||
parsed_result: object = {}
|
||||
status = "success"
|
||||
if j < n and middle[j].get("role") == "tool":
|
||||
tool_content = middle[j].get("content", "") or ""
|
||||
try:
|
||||
parsed_result = _json.loads(tool_content)
|
||||
except Exception:
|
||||
parsed_result = tool_content
|
||||
if isinstance(parsed_result, dict) and parsed_result.get("success") is False:
|
||||
status = "error"
|
||||
j += 1
|
||||
result_idx += 1
|
||||
|
||||
flat_tool_calls.append({
|
||||
"function": name,
|
||||
"arguments": arguments,
|
||||
"result": parsed_result,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
try:
|
||||
await post_message(
|
||||
conv_id, "assistant", content,
|
||||
metadata=tag,
|
||||
tool_calls=flat_tool_calls,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist agentic assistant turn for conv %d slot %s",
|
||||
conv_id, slot, exc_info=True,
|
||||
)
|
||||
i = j # skip the tool results we just folded in
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
# Unpaired tool result — shouldn't normally happen, but be
|
||||
# defensive and persist it as an assistant-visible note so we
|
||||
# don't lose the receipt entirely.
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
# Skip the synthetic user trigger ("Generate my morning briefing…").
|
||||
# Persisting it would recreate the exact "[Midday briefing update]"
|
||||
# problem PR 2 is designed to eliminate: fake user messages
|
||||
# cluttering chat history. The LLM can follow an all-assistant
|
||||
# sequence just fine since the chat endpoint injects the real
|
||||
# system prompt on follow-up.
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# assistant without tool_calls — persist as-is (rare intermediate)
|
||||
try:
|
||||
await post_message(conv_id, role, content, metadata=tag)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist agentic %s message for conv %d slot %s",
|
||||
role, conv_id, slot, exc_info=True,
|
||||
)
|
||||
i += 1
|
||||
|
||||
|
||||
async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
||||
"""Execute one slot job for one user."""
|
||||
from fabledassistant.services.briefing_conversations import (
|
||||
get_or_create_today_conversation, post_message
|
||||
)
|
||||
from fabledassistant.services.briefing_pipeline import run_compilation, run_slot_injection
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.config import Config
|
||||
|
||||
# Morning slot: skip if today is not a configured work day
|
||||
if slot == "morning":
|
||||
from fabledassistant.services.user_profile import get_profile
|
||||
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
user_tz = ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
user_tz = ZoneInfo("UTC")
|
||||
today_abbr = datetime.now(user_tz).strftime("%a") # 'Mon', 'Tue', …
|
||||
profile = await get_profile(user_id)
|
||||
work_days = (profile.work_schedule or {}).get("days", ["Mon", "Tue", "Wed", "Thu", "Fri"])
|
||||
if today_abbr not in work_days:
|
||||
logger.info(
|
||||
"Skipping morning slot for user %d — %s not a configured work day",
|
||||
user_id, today_abbr,
|
||||
)
|
||||
return
|
||||
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
if slot == "compilation":
|
||||
# Auto-pause stale projects before compiling the briefing
|
||||
await _auto_pause_stale_projects(user_id)
|
||||
|
||||
# Refresh external data first
|
||||
try:
|
||||
import json
|
||||
config_raw = await get_setting(user_id, "briefing_config", "{}")
|
||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
rss_on = (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
|
||||
if rss_on:
|
||||
from fabledassistant.services.rss import refresh_all_feeds
|
||||
await refresh_all_feeds(user_id)
|
||||
from fabledassistant.services import weather as wx
|
||||
for key, loc in config.get("locations", {}).items():
|
||||
if loc.get("lat") and loc.get("lon"):
|
||||
await wx.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Pre-compilation refresh failed for user %d", user_id, exc_info=True)
|
||||
|
||||
await _run_profile_closeout(user_id, model)
|
||||
conv = await get_or_create_today_conversation(user_id, model)
|
||||
text, metadata = await run_compilation(user_id, slot, model)
|
||||
if text:
|
||||
# Persist the agentic tool-call sequence as its own messages
|
||||
# so follow-up chat can see the receipts. Each intermediate
|
||||
# message is tagged with briefing_slot so the chat context
|
||||
# loader can decide whether to include them in history.
|
||||
await _persist_agentic_messages(conv.id, slot, metadata)
|
||||
final_meta = {k: v for k, v in metadata.items() if k != "agentic_messages"}
|
||||
final_meta["briefing_slot"] = slot
|
||||
await post_message(conv.id, "assistant", text, metadata=final_meta)
|
||||
|
||||
else:
|
||||
conv = await get_or_create_today_conversation(user_id, model)
|
||||
text, slot_metadata = await run_slot_injection(user_id, slot, model)
|
||||
if text:
|
||||
# No more synthetic "[Midday briefing update]" user-role
|
||||
# messages. Slot updates are plain assistant messages tagged
|
||||
# with briefing_slot so the chat endpoint can filter them
|
||||
# from the LLM's view of history on follow-ups (they remain
|
||||
# visible in the UI).
|
||||
await _persist_agentic_messages(conv.id, slot, slot_metadata)
|
||||
final_meta = {k: v for k, v in slot_metadata.items() if k != "agentic_messages"}
|
||||
final_meta["briefing_slot"] = slot
|
||||
await post_message(conv.id, "assistant", text, metadata=final_meta)
|
||||
|
||||
try:
|
||||
from fabledassistant.services.push import send_push_notification
|
||||
slot_labels = {
|
||||
"compilation": "Morning briefing ready",
|
||||
"morning": "Office briefing ready",
|
||||
"midday": "Midday check-in",
|
||||
"afternoon": "End of day wrap-up",
|
||||
}
|
||||
await send_push_notification(
|
||||
user_id=user_id,
|
||||
title="Briefing",
|
||||
body=slot_labels.get(slot, "Briefing update"),
|
||||
url="/briefing",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Push notification failed for briefing slot %s", slot, exc_info=True)
|
||||
|
||||
logger.info("Briefing slot '%s' completed for user %d", slot, user_id)
|
||||
|
||||
|
||||
def _run_user_slot_sync(user_id: int, slot: str) -> None:
|
||||
"""Synchronous wrapper called by APScheduler's background thread."""
|
||||
if _loop is None:
|
||||
logger.error("No event loop available for briefing slot %s user %d", slot, user_id)
|
||||
return
|
||||
future = asyncio.run_coroutine_threadsafe(_run_slot_for_user(user_id, slot), _loop)
|
||||
try:
|
||||
future.result(timeout=600)
|
||||
except Exception:
|
||||
logger.exception("Briefing slot '%s' failed for user %d", slot, user_id)
|
||||
|
||||
|
||||
async def _run_profile_closeout(user_id: int, model: str) -> None:
|
||||
"""
|
||||
Read yesterday's briefing conversation, extract preference observations,
|
||||
and append them to the briefing profile note.
|
||||
"""
|
||||
from fabledassistant.services.user_profile import append_observations
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.services.tz import user_today
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
|
||||
# User-local "yesterday" so closeout always targets the day that just
|
||||
# ended in the user's timezone, regardless of container TZ.
|
||||
yesterday = (await user_today(user_id)) - timedelta(days=1)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == yesterday,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if not conv:
|
||||
return
|
||||
msgs_result = await session.execute(
|
||||
select(Message).where(Message.conversation_id == conv.id).order_by(Message.created_at)
|
||||
)
|
||||
messages = list(msgs_result.scalars().all())
|
||||
|
||||
if len(messages) < 2:
|
||||
return
|
||||
|
||||
transcript = "\n".join(
|
||||
f"{m.role.upper()}: {m.content[:500]}" for m in messages[-20:]
|
||||
)
|
||||
system = (
|
||||
"You are reviewing a day's briefing conversation to extract preference observations. "
|
||||
"Identify any patterns, preferences, or schedule facts the user revealed. "
|
||||
"Write 2-5 short bullet points. Be specific and factual. "
|
||||
"Example: '- User skipped news about finance', '- Prefers weather for home location first'. "
|
||||
"If nothing notable, output only: (nothing to note)"
|
||||
)
|
||||
try:
|
||||
observations = (await generate_completion(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": transcript},
|
||||
],
|
||||
model,
|
||||
)).strip()
|
||||
except Exception:
|
||||
logger.warning("Profile closeout synthesis failed for user %d", user_id, exc_info=True)
|
||||
observations = ""
|
||||
if observations and "(nothing to note)" not in observations.lower():
|
||||
await append_observations(user_id, observations)
|
||||
|
||||
|
||||
# ── Startup / catchup ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""
|
||||
On startup, fire any slot that was missed in the last 24 hours
|
||||
(one catch-up per slot per user, evaluated in the user's local timezone).
|
||||
"""
|
||||
users = await _get_briefing_enabled_users()
|
||||
for user_id, tz, _config in users:
|
||||
user_tz = ZoneInfo(tz)
|
||||
now_local = datetime.now(user_tz)
|
||||
today_local = now_local.date()
|
||||
|
||||
for slot_name, hour, minute in SLOTS:
|
||||
slot_local = datetime.combine(today_local, time(hour, minute), tzinfo=user_tz)
|
||||
if slot_local > now_local:
|
||||
continue # Not yet due
|
||||
age = (now_local - slot_local).total_seconds()
|
||||
if age > 86400:
|
||||
continue # More than 24h ago — skip
|
||||
|
||||
# Check if today's conversation already has a message from after slot time
|
||||
async with async_session() as session:
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == today_local,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv:
|
||||
# Convert slot_local to UTC for DB comparison (stored as UTC)
|
||||
slot_utc = slot_local.astimezone(ZoneInfo("UTC"))
|
||||
msgs = await session.execute(
|
||||
select(Message).where(
|
||||
Message.conversation_id == conv.id,
|
||||
Message.created_at >= slot_utc,
|
||||
).limit(1)
|
||||
)
|
||||
if msgs.scalars().first():
|
||||
continue # Already covered
|
||||
|
||||
logger.info(
|
||||
"Catching up missed briefing slot '%s' for user %d (tz: %s)",
|
||||
slot_name, user_id, tz,
|
||||
)
|
||||
try:
|
||||
await _run_slot_for_user(user_id, slot_name)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Catch-up for slot '%s' user %d failed", slot_name, user_id
|
||||
)
|
||||
|
||||
|
||||
async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""
|
||||
Start the APScheduler background scheduler with per-user timezone-aware jobs.
|
||||
Must be awaited from the app's before_serving hook (async context).
|
||||
"""
|
||||
global _scheduler, _loop
|
||||
if _scheduler is not None:
|
||||
return
|
||||
|
||||
_loop = loop
|
||||
_scheduler = BackgroundScheduler(timezone="UTC")
|
||||
|
||||
# Await directly — we're already on the event loop, so run_coroutine_threadsafe
|
||||
# would deadlock (it blocks the calling thread, which IS the event loop thread).
|
||||
try:
|
||||
users = await _get_briefing_enabled_users()
|
||||
except Exception:
|
||||
logger.exception("Failed to load briefing users at startup")
|
||||
users = []
|
||||
|
||||
for user_id, tz, config in users:
|
||||
_add_user_jobs(user_id, tz, config)
|
||||
|
||||
from fabledassistant.services.recurrence import spawn_recurring_tasks as _spawn_recurring
|
||||
|
||||
def _run_recurrence_spawn() -> None:
|
||||
future = asyncio.run_coroutine_threadsafe(_spawn_recurring(), _loop)
|
||||
try:
|
||||
count = future.result(timeout=300)
|
||||
logger.info("Recurrence spawn: %d task(s) created", count)
|
||||
except Exception as exc:
|
||||
logger.error("Recurrence spawn failed: %s", exc)
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_recurrence_spawn,
|
||||
CronTrigger(hour=0, minute=0, timezone="UTC"),
|
||||
id="recurrence_daily",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
def _run_kokoro_update_check() -> None:
|
||||
from fabledassistant.services.tts import check_for_kokoro_updates
|
||||
future = asyncio.run_coroutine_threadsafe(check_for_kokoro_updates(), _loop)
|
||||
try:
|
||||
future.result(timeout=300)
|
||||
except Exception as exc:
|
||||
logger.error("Kokoro update check failed: %s", exc)
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_kokoro_update_check,
|
||||
CronTrigger(hour=3, minute=0, timezone="UTC"),
|
||||
id="kokoro_update_check_daily",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info(
|
||||
"Briefing scheduler started with %d user(s) across %d job(s)",
|
||||
len(users), len(users) * len(SLOTS),
|
||||
)
|
||||
|
||||
asyncio.create_task(_catchup_missed_slots(loop))
|
||||
|
||||
|
||||
def stop_briefing_scheduler() -> None:
|
||||
global _scheduler, _loop
|
||||
if _scheduler:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
_loop = None
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Briefing tool subset — delegates to the registry's ``briefing=True`` filter.
|
||||
|
||||
Tools are opted-in to briefings via ``@tool(briefing=True)`` in their
|
||||
respective module, so there is no separate allowlist to maintain here.
|
||||
"""
|
||||
|
||||
from fabledassistant.services.tools import get_briefing_tools
|
||||
|
||||
__all__ = ["get_briefing_tools"]
|
||||
@@ -66,7 +66,7 @@ async def setup_user_calendar(user_id: int) -> bool:
|
||||
<set>
|
||||
<prop>
|
||||
<resourcetype><collection/><C:calendar/></resourcetype>
|
||||
<displayname>Fabled Assistant</displayname>
|
||||
<displayname>Fabled Scribe</displayname>
|
||||
</prop>
|
||||
</set>
|
||||
</mkcol>""",
|
||||
|
||||
@@ -80,7 +80,7 @@ async def list_conversations(
|
||||
"title": conv.title,
|
||||
"model": conv.model,
|
||||
"conversation_type": conv.conversation_type,
|
||||
"briefing_date": conv.briefing_date.isoformat() if conv.briefing_date else None,
|
||||
"day_date": conv.day_date.isoformat() if conv.day_date else None,
|
||||
"rag_project_id": conv.rag_project_id,
|
||||
"message_count": row[1],
|
||||
"created_at": conv.created_at.isoformat(),
|
||||
|
||||
@@ -38,7 +38,7 @@ _EMAIL_LOGO_SVG = (
|
||||
|
||||
|
||||
def _email_html(title: str, body: str) -> str:
|
||||
"""Wrap email body content in the standard Fabled Assistant template."""
|
||||
"""Wrap email body content in the standard Fabled Scribe template."""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -56,7 +56,7 @@ def _email_html(title: str, body: str) -> str:
|
||||
<!-- Header -->
|
||||
<div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
|
||||
<div style="margin-bottom:8px;">
|
||||
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Assistant</span>
|
||||
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Scribe</span>
|
||||
</div>
|
||||
<p style="margin:0;color:#ede9fe;font-size:13px;letter-spacing:0.03em;">{title}</p>
|
||||
</div>
|
||||
@@ -68,7 +68,7 @@ def _email_html(title: str, body: str) -> str:
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
|
||||
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Assistant instance.</p>
|
||||
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Scribe instance.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -146,7 +146,7 @@ async def send_email(to: str, subject: str, html_body: str) -> None:
|
||||
username = config.get("smtp_username", "")
|
||||
password = config.get("smtp_password", "")
|
||||
from_address = config.get("smtp_from_address", "")
|
||||
from_name = config.get("smtp_from_name", "Fabled Assistant")
|
||||
from_name = config.get("smtp_from_name", "Fabled Scribe")
|
||||
use_tls = config.get("smtp_use_tls", "true") == "true"
|
||||
|
||||
msg = EmailMessage()
|
||||
@@ -176,6 +176,6 @@ async def send_test_email(to: str) -> None:
|
||||
"""Send a branded test email."""
|
||||
body = """
|
||||
<p style="margin:0 0 12px;color:#1e1b4b;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
|
||||
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p>
|
||||
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Scribe instance can send email notifications.</p>
|
||||
"""
|
||||
await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))
|
||||
await send_email(to, "Fabled Scribe - Test Email", _email_html("Test Email", body))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Semantic note search via Ollama embedding model (nomic-embed-text).
|
||||
|
||||
Embeddings are stored in the note_embeddings table (one row per note).
|
||||
RSS item embeddings are stored in rss_item_embeddings (one row per item).
|
||||
All search operations degrade gracefully — if the embedding model is
|
||||
unavailable the callers fall back to keyword search.
|
||||
"""
|
||||
@@ -9,7 +8,6 @@ unavailable the callers fall back to keyword search.
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import delete, select
|
||||
@@ -18,8 +16,6 @@ from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.embedding import NoteEmbedding
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.rss_feed import RssItem
|
||||
from fabledassistant.models.rss_item_embedding import RssItemEmbedding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,10 +24,6 @@ logger = logging.getLogger(__name__)
|
||||
# 0.45 keeps only genuinely relevant notes; lower values like 0.30 let in
|
||||
# loosely-related results that pad the sidebar without adding real value.
|
||||
_SIMILARITY_THRESHOLD = 0.45
|
||||
_RSS_SIMILARITY_THRESHOLD = 0.55
|
||||
_RSS_SEARCH_LIMIT = 3
|
||||
_RSS_SEARCH_DAYS = 30
|
||||
_RSS_SNIPPET_CHARS = 500
|
||||
|
||||
|
||||
async def get_embedding(text: str, model: str | None = None) -> list[float]:
|
||||
@@ -186,174 +178,3 @@ async def backfill_note_embeddings() -> None:
|
||||
logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed))
|
||||
|
||||
|
||||
# ── RSS item embeddings ───────────────────────────────────────────────────────
|
||||
|
||||
async def upsert_rss_item_embedding(item_id: int, user_id: int, title: str, content: str) -> None:
|
||||
"""Generate and persist an embedding for an RSS item. Safe to fire-and-forget."""
|
||||
text = f"{title}\n{content}".strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
embedding = await get_embedding(text)
|
||||
except Exception:
|
||||
logger.debug("Skipping embedding for RSS item %d — model unavailable", item_id)
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
delete(RssItemEmbedding).where(RssItemEmbedding.rss_item_id == item_id)
|
||||
)
|
||||
session.add(RssItemEmbedding(rss_item_id=item_id, user_id=user_id, embedding=embedding))
|
||||
await session.commit()
|
||||
logger.debug("Upserted embedding for RSS item %d", item_id)
|
||||
except Exception:
|
||||
logger.warning("Failed to persist embedding for RSS item %d", item_id, exc_info=True)
|
||||
|
||||
|
||||
async def semantic_search_rss_items(
|
||||
user_id: int,
|
||||
query_vector: list[float],
|
||||
limit: int = _RSS_SEARCH_LIMIT,
|
||||
days: int = _RSS_SEARCH_DAYS,
|
||||
) -> list[tuple[float, RssItem]]:
|
||||
"""Return up to *limit* (score, RssItem) pairs most relevant to *query_vector*.
|
||||
|
||||
Only considers items fetched within the last *days* days.
|
||||
Returns an empty list on any error.
|
||||
"""
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
try:
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(RssItemEmbedding, RssItem)
|
||||
.join(RssItem, RssItemEmbedding.rss_item_id == RssItem.id)
|
||||
.where(
|
||||
RssItemEmbedding.user_id == user_id,
|
||||
RssItem.fetched_at >= since,
|
||||
)
|
||||
)
|
||||
rows = list((await session.execute(stmt)).all())
|
||||
except Exception:
|
||||
logger.warning("Failed to query RSS item embeddings", exc_info=True)
|
||||
return []
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
scored: list[tuple[float, RssItem]] = []
|
||||
for rie, item in rows:
|
||||
try:
|
||||
sim = _cosine_similarity(query_vector, rie.embedding)
|
||||
except Exception:
|
||||
continue
|
||||
if sim >= _RSS_SIMILARITY_THRESHOLD:
|
||||
scored.append((sim, item))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
|
||||
async def backfill_rss_item_embeddings() -> None:
|
||||
"""Generate embeddings for all RSS items that don't have one yet.
|
||||
|
||||
Runs as a background task at startup. Adds a small sleep between items
|
||||
to avoid overwhelming Ollama.
|
||||
"""
|
||||
try:
|
||||
async with async_session() as session:
|
||||
existing = {
|
||||
row[0]
|
||||
for row in (
|
||||
await session.execute(select(RssItemEmbedding.rss_item_id))
|
||||
).fetchall()
|
||||
}
|
||||
result = await session.execute(
|
||||
select(RssItem.id, RssItem.feed_id, RssItem.title, RssItem.content)
|
||||
)
|
||||
items_to_embed = [row for row in result.fetchall() if row[0] not in existing]
|
||||
except Exception:
|
||||
logger.warning("RSS embedding backfill: failed to query items", exc_info=True)
|
||||
return
|
||||
|
||||
if not items_to_embed:
|
||||
logger.info("RSS embedding backfill: all items already have embeddings")
|
||||
return
|
||||
|
||||
# Resolve user_id per feed_id
|
||||
try:
|
||||
from fabledassistant.models.rss_feed import RssFeed
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(RssFeed.id, RssFeed.user_id))
|
||||
feed_user_map = {fid: uid for fid, uid in result.fetchall()}
|
||||
except Exception:
|
||||
logger.warning("RSS embedding backfill: failed to load feed user map", exc_info=True)
|
||||
return
|
||||
|
||||
logger.info("RSS embedding backfill: generating embeddings for %d items", len(items_to_embed))
|
||||
success = 0
|
||||
for item_id, feed_id, title, content in items_to_embed:
|
||||
user_id = feed_user_map.get(feed_id)
|
||||
if user_id is None:
|
||||
continue
|
||||
await upsert_rss_item_embedding(item_id, user_id, title or "", content or "")
|
||||
success += 1
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
logger.info("RSS embedding backfill complete: %d/%d items embedded", success, len(items_to_embed))
|
||||
|
||||
|
||||
async def backfill_rss_article_content() -> None:
|
||||
"""Fetch full article text for RSS items that only have short feed-provided content.
|
||||
|
||||
An item is considered unenriched if its content is shorter than 1000 chars —
|
||||
typical of feed summaries/teasers rather than full articles.
|
||||
Runs at startup after the embedding backfill.
|
||||
"""
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
from fabledassistant.models.rss_feed import RssFeed
|
||||
|
||||
SHORT_THRESHOLD = 1000
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
feed_result = await session.execute(select(RssFeed.id, RssFeed.user_id))
|
||||
feed_user_map = {fid: uid for fid, uid in feed_result.fetchall()}
|
||||
|
||||
item_result = await session.execute(
|
||||
select(RssItem.id, RssItem.feed_id, RssItem.url, RssItem.title, RssItem.content)
|
||||
.where(RssItem.url != "")
|
||||
)
|
||||
candidates = [
|
||||
row for row in item_result.fetchall()
|
||||
if len(row[4] or "") < SHORT_THRESHOLD
|
||||
]
|
||||
except Exception:
|
||||
logger.warning("Article content backfill: failed to query items", exc_info=True)
|
||||
return
|
||||
|
||||
if not candidates:
|
||||
logger.info("Article content backfill: no unenriched items found")
|
||||
return
|
||||
|
||||
logger.info("Article content backfill: enriching %d items", len(candidates))
|
||||
enriched = 0
|
||||
for item_id, feed_id, url, title, _ in candidates:
|
||||
user_id = feed_user_map.get(feed_id)
|
||||
if user_id is None:
|
||||
continue
|
||||
full_text = await _fetch_full_article(url)
|
||||
if full_text and len(full_text) > SHORT_THRESHOLD:
|
||||
try:
|
||||
async with async_session() as session:
|
||||
item = await session.get(RssItem, item_id)
|
||||
if item:
|
||||
item.content = full_text
|
||||
await session.commit()
|
||||
await upsert_rss_item_embedding(item_id, user_id, title or "", full_text)
|
||||
enriched += 1
|
||||
except Exception:
|
||||
logger.debug("Failed to store enriched content for item %d", item_id, exc_info=True)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
logger.info("Article content backfill complete: %d/%d items enriched", enriched, len(candidates))
|
||||
|
||||
@@ -37,84 +37,6 @@ _TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE)
|
||||
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
|
||||
|
||||
|
||||
async def _maybe_save_article_discussion_note(
|
||||
user_id: int, conv_id: int, reply_content: str,
|
||||
) -> None:
|
||||
"""Persist a seeded article-discussion's first reply as a Note.
|
||||
|
||||
Fires after ``run_generation`` completes. Looks for a synthetic
|
||||
read_article seed message on the conversation; if found AND the linked
|
||||
``rss_items`` row has no ``discussion_note_id`` yet, saves ``reply_content``
|
||||
as a Note, tags it, and writes the backlink. Subsequent discuss clicks on
|
||||
the same article are a no-op (already linked).
|
||||
|
||||
Failures are logged and swallowed — the chat UI should never break because
|
||||
Note persistence hit a snag.
|
||||
"""
|
||||
try:
|
||||
if not reply_content or not reply_content.strip():
|
||||
return
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models.conversation import Message as _Message
|
||||
from fabledassistant.models.rss_feed import RssItem as _RssItem
|
||||
from fabledassistant.services.notes import create_note
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
_select(_Message)
|
||||
.where(_Message.conversation_id == conv_id)
|
||||
.order_by(_Message.id.asc())
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
seed_meta = None
|
||||
for m in messages:
|
||||
meta = m.msg_metadata or {}
|
||||
if meta.get("article_seed") and meta.get("rss_item_id"):
|
||||
seed_meta = meta
|
||||
break
|
||||
if seed_meta is None:
|
||||
return
|
||||
item_id = int(seed_meta["rss_item_id"])
|
||||
item = await session.get(_RssItem, item_id)
|
||||
if item is None or item.discussion_note_id is not None:
|
||||
return
|
||||
article_title = (item.title or "Untitled article").strip()
|
||||
article_url = item.url
|
||||
article_topics = list(item.topics or [])
|
||||
|
||||
note_title = f"Article: {article_title}"[:200]
|
||||
body_parts = [f"**Source:** {article_url}"] if article_url else []
|
||||
body_parts.append(reply_content.strip())
|
||||
note_body = "\n\n".join(body_parts)
|
||||
tags = ["article-summary"] + [t for t in article_topics if t]
|
||||
note = await create_note(
|
||||
user_id=user_id,
|
||||
title=note_title,
|
||||
body=note_body,
|
||||
tags=tags,
|
||||
entity_meta={
|
||||
"source": "article_discussion",
|
||||
"rss_item_id": item_id,
|
||||
"url": article_url,
|
||||
"conversation_id": conv_id,
|
||||
},
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(_RssItem, item_id)
|
||||
if fresh is not None and fresh.discussion_note_id is None:
|
||||
fresh.discussion_note_id = note.id
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Saved article-discussion summary as note %d for rss_item %d (conv %d)",
|
||||
note.id, item_id, conv_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist article-discussion note for conv %d",
|
||||
conv_id, exc_info=True,
|
||||
)
|
||||
|
||||
# Human-readable labels for each tool, shown in the status indicator
|
||||
_TOOL_LABELS: dict[str, str] = {
|
||||
"create_note": "Creating note/task",
|
||||
@@ -254,8 +176,15 @@ async def run_generation(
|
||||
|
||||
buf.append_event("status", {"status": "Building context..."})
|
||||
|
||||
# Phase 1: Resolve the tools list for this user.
|
||||
tools = await get_tools_for_user(user_id)
|
||||
# Phase 1: Resolve the tools list for this user, scoped to conversation type.
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Conversation as _Conversation
|
||||
async with _async_session() as _sess:
|
||||
_conv = await _sess.get(_Conversation, conv_id)
|
||||
_conversation_type = (
|
||||
_conv.conversation_type if _conv and _conv.conversation_type else "chat"
|
||||
)
|
||||
tools = await get_tools_for_user(user_id, conversation_type=_conversation_type)
|
||||
|
||||
logger.info(
|
||||
"Starting generation for conv %d: model=%s, tools=%d",
|
||||
@@ -586,28 +515,16 @@ async def run_generation(
|
||||
msg_count = len(non_system)
|
||||
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
|
||||
|
||||
# Persist article-discussion seed conversations as a Note on their
|
||||
# first assistant reply. This makes "Discuss" summaries part of RAG
|
||||
# so the knowledge base stops being amnesiac about articles the user
|
||||
# has already engaged with. The hook detects a seeded conversation by
|
||||
# finding a synthetic read_article assistant message whose
|
||||
# msg_metadata carries ``article_seed: True`` and whose rss_items row
|
||||
# has no discussion_note_id yet. Fire-and-forget so the done event
|
||||
# lands immediately.
|
||||
asyncio.create_task(_maybe_save_article_discussion_note(
|
||||
user_id, conv_id, buf.content_so_far,
|
||||
))
|
||||
|
||||
if should_gen_title:
|
||||
# Feed the title model the *raw* conversation turns only — never
|
||||
# the post-build_context ``messages`` list. ``build_context``
|
||||
# prepends RAG snippets, RSS excerpts, URL content, and briefing
|
||||
# article dumps INTO the user message string itself, so filtering
|
||||
# by role="user" downstream still surfaces that noise as the
|
||||
# "user's message". That pollution caused wildly-wrong titles
|
||||
# (bug #109) — the small background model was staring at article
|
||||
# excerpts instead of what the user actually typed. Pass the
|
||||
# original history + the raw user_content + the assistant reply.
|
||||
# prepends RAG snippets and URL content INTO the user message
|
||||
# string itself, so filtering by role="user" downstream still
|
||||
# surfaces that noise as the "user's message". That pollution
|
||||
# caused wildly-wrong titles (bug #109) — the small background
|
||||
# model was staring at article excerpts instead of what the user
|
||||
# actually typed. Pass the original history + the raw user_content
|
||||
# + the assistant reply.
|
||||
title_messages: list[dict] = [
|
||||
{"role": m["role"], "content": m.get("content") or ""}
|
||||
for m in history
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""System prompt + ambient context injection for journal conversations.
|
||||
|
||||
Distinct from the chat pipeline (services/llm.py) in three ways:
|
||||
1. New persona: warm, curious listener; not a task manager.
|
||||
2. Calibration rules: ask before structural changes; record_moment freely.
|
||||
3. Auto-injects last ~48h of moments for ambient continuity (no notes-RAG).
|
||||
|
||||
Notes-RAG auto-injection MUST be disabled for journal sessions — the
|
||||
journal's ambient context replaces it. Failing to disable would let notes
|
||||
leak into journal sessions, violating the design's isolation invariant.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from fabledassistant.services.journal_search import search_journal
|
||||
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. "
|
||||
"The day's prep message at the top of the conversation is your context — "
|
||||
"build on it, don't restate it."
|
||||
)
|
||||
|
||||
JOURNAL_CALIBRATION = """\
|
||||
JOURNAL-SPECIFIC TOOL GUIDANCE:
|
||||
|
||||
PEOPLE / PLACES — ask before creating new entries.
|
||||
- If the user mentions a name you don't already know about, ASK them in plain
|
||||
language ("Who's Sarah to you?") and WAIT for the reply before calling
|
||||
save_person or save_place.
|
||||
- For ambiguous references (multiple matches in their existing people/places),
|
||||
ask which one. Never guess.
|
||||
- For unambiguous references to people they've already established, no need
|
||||
to ask — proceed normally.
|
||||
|
||||
MOMENTS — recording them is your primary job, not a "nice to have."
|
||||
|
||||
After every substantive user message, BEFORE you compose your reply,
|
||||
check: did the user describe ANY of these?
|
||||
- An event that happened ("I went grocery shopping")
|
||||
- An encounter with a person ("had coffee with Sarah")
|
||||
- A decision ("I'm going to switch jobs")
|
||||
- An observation about themselves or the world ("the new place is loud")
|
||||
- A plan or commitment ("watching a show with Victoria tonight")
|
||||
- A feeling or state ("I'm tired", "feeling decompressed")
|
||||
- A small accomplishment or change they made ("installed the new AP")
|
||||
|
||||
If the answer is YES to ANY of those — CALL record_moment FIRST, before
|
||||
composing your reply. This is not optional. The journal exists to capture
|
||||
these beats; if you skip the call, the beat is lost.
|
||||
|
||||
Multiple distinct beats in one message → multiple record_moment calls,
|
||||
one per beat.
|
||||
|
||||
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
|
||||
another tool call in this same turn. Never invent IDs.
|
||||
|
||||
The ONLY messages where you skip record_moment are purely meta-conversational
|
||||
ones — about the journal itself or about a prior tool result ("thanks",
|
||||
"no priority needed", "can you also add X to that one", "I meant tasks not
|
||||
notes"). Those aren't journal beats; they're chat about the chat.
|
||||
|
||||
STATE-CHANGING TOOLS — use the confirmation flow.
|
||||
- update_task / update_note that change state (status, completion, deletion)
|
||||
follow the standard confirmation pattern: pass `confirmed=false` first; the
|
||||
frontend shows a confirm UI; call again with `confirmed=true` after the
|
||||
user confirms.
|
||||
- Pure-read tools (list_tasks, search_notes, search_journal, get_weather, etc.)
|
||||
don't need confirmation.
|
||||
|
||||
OTHER:
|
||||
- Do NOT call set_rag_scope. The journal scope is implicit.
|
||||
- Notes are not auto-retrieved here. If you need to reference a note, call
|
||||
search_notes explicitly.
|
||||
|
||||
RESPONSE STYLE:
|
||||
- Don't apologize for the user's feelings ("I'm sorry you're feeling…"). Engage
|
||||
with what they said directly.
|
||||
- Don't produce multi-option menus ("1. Show your calendar 2. List your tasks
|
||||
3. ..."). They feel like a help-desk bot. Ask one specific follow-up or take
|
||||
one specific action.
|
||||
- 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.
|
||||
"""
|
||||
|
||||
PHASE_GREETINGS = {
|
||||
"morning": "Morning. What's the day looking like for you?",
|
||||
"midday": "How's it going so far?",
|
||||
"evening": "Wrapping up — how'd the day shake out?",
|
||||
}
|
||||
|
||||
|
||||
def determine_phase(
|
||||
*,
|
||||
now_local: datetime.datetime,
|
||||
day_rollover_hour: int = 4,
|
||||
morning_end_hour: int = 12,
|
||||
midday_end_hour: int = 18,
|
||||
) -> str:
|
||||
"""Return 'morning' | 'midday' | 'evening' for a local datetime."""
|
||||
h = now_local.hour
|
||||
if h < day_rollover_hour:
|
||||
return "evening"
|
||||
if h < morning_end_hour:
|
||||
return "morning"
|
||||
if h < midday_end_hour:
|
||||
return "midday"
|
||||
return "evening"
|
||||
|
||||
|
||||
def phase_greeting(phase: str) -> str:
|
||||
return PHASE_GREETINGS.get(phase, PHASE_GREETINGS["morning"])
|
||||
|
||||
|
||||
async def build_journal_system_prompt(
|
||||
*,
|
||||
user_id: int,
|
||||
day_date: datetime.date,
|
||||
user_timezone: str,
|
||||
) -> str:
|
||||
"""Static-then-dynamic system prompt.
|
||||
|
||||
Static prefix (persona + calibration) is identical on every request,
|
||||
preserving Ollama KV cache. Dynamic suffix changes per-day.
|
||||
"""
|
||||
static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}"
|
||||
|
||||
today_iso = day_date.isoformat()
|
||||
tz_block = f"Today is {today_iso} ({user_timezone})."
|
||||
|
||||
profile_context = await build_profile_context(user_id)
|
||||
profile_section = f"\n\n{profile_context}" if profile_context else ""
|
||||
|
||||
ambient = await _ambient_moments_block(user_id=user_id, day_date=day_date)
|
||||
ambient_section = (
|
||||
f"\n\nRECENT JOURNAL CONTEXT (last 48h):\n{ambient}" if ambient else ""
|
||||
)
|
||||
|
||||
return f"{static_block}\n\n{tz_block}{profile_section}{ambient_section}"
|
||||
|
||||
|
||||
async def _ambient_moments_block(
|
||||
*, user_id: int, day_date: datetime.date
|
||||
) -> str:
|
||||
"""Render last 48h of moments as a compact text block.
|
||||
|
||||
Capped at 20 moments / 1500 chars total. Distinct from RAG retrieval.
|
||||
"""
|
||||
moments = await search_journal(
|
||||
user_id=user_id,
|
||||
date_from=day_date - datetime.timedelta(days=2),
|
||||
date_to=day_date,
|
||||
limit=20,
|
||||
)
|
||||
if not moments:
|
||||
return ""
|
||||
|
||||
lines: list[str] = []
|
||||
total_chars = 0
|
||||
for m in moments:
|
||||
line = f"- [{m['occurred_at']}] {m['content']}"
|
||||
if total_chars + len(line) > 1500:
|
||||
break
|
||||
lines.append(line)
|
||||
total_chars += len(line)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Daily prep generator for the Journal.
|
||||
|
||||
Runs once per day per user (scheduled, or lazy on first journal-open of a
|
||||
new day). Two phases:
|
||||
|
||||
1. Gather structured data (tasks/events/weather/projects/recent moments/
|
||||
open threads) — deterministic, no LLM call.
|
||||
2. Hand the structured data to the LLM and ask it for a direct, informative
|
||||
conversational opener — flowing prose, briefing-style. Result is persisted
|
||||
as the first *assistant* message in today's journal Conversation, so it
|
||||
renders with the standard Illuminated Transcript bubble styling alongside
|
||||
the rest of the conversation.
|
||||
|
||||
The structured data is preserved on ``Message.msg_metadata.sections`` for
|
||||
provenance and future tooling.
|
||||
|
||||
Message shape:
|
||||
role: 'assistant'
|
||||
content: <prose opener>
|
||||
msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } }
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import Conversation, Message, async_session
|
||||
from fabledassistant.services.events import list_events
|
||||
from fabledassistant.services.journal_search import search_journal
|
||||
from fabledassistant.services.notes import list_notes
|
||||
from fabledassistant.services.projects import list_projects
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.services.weather import get_cached_weather_rows
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def gather_daily_sections(
|
||||
*,
|
||||
user_id: int,
|
||||
day_date: datetime.date,
|
||||
user_timezone: str,
|
||||
) -> dict:
|
||||
"""Gather all daily-prep sections and return them as a dict.
|
||||
|
||||
Pure data fetching — no LLM call. Each section degrades to an empty
|
||||
list/dict on failure so the caller always gets a complete shape.
|
||||
"""
|
||||
sections: dict = {}
|
||||
|
||||
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",
|
||||
)
|
||||
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"] = []
|
||||
|
||||
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(
|
||||
user_id=user_id,
|
||||
date_from=day_start,
|
||||
date_to=day_end,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("daily_prep events section failed for user %d", user_id)
|
||||
sections["events"] = []
|
||||
|
||||
try:
|
||||
weather_rows = await get_cached_weather_rows(user_id)
|
||||
sections["weather"] = [w.to_dict() for w in weather_rows]
|
||||
except Exception:
|
||||
logger.exception("daily_prep weather section failed for user %d", user_id)
|
||||
sections["weather"] = []
|
||||
|
||||
try:
|
||||
projects = await list_projects(user_id=user_id, status="active")
|
||||
sections["projects"] = [
|
||||
{
|
||||
"id": p.id,
|
||||
"title": p.title,
|
||||
"auto_summary": p.auto_summary,
|
||||
}
|
||||
for p in projects[:5]
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("daily_prep projects section failed for user %d", user_id)
|
||||
sections["projects"] = []
|
||||
|
||||
try:
|
||||
sections["recent_moments"] = await search_journal(
|
||||
user_id=user_id,
|
||||
date_from=day_date - datetime.timedelta(days=3),
|
||||
date_to=day_date - datetime.timedelta(days=1),
|
||||
limit=10,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("daily_prep recent_moments section failed for user %d", user_id)
|
||||
sections["recent_moments"] = []
|
||||
|
||||
try:
|
||||
sections["open_threads"] = await _open_threads(user_id=user_id, day_date=day_date)
|
||||
except Exception:
|
||||
logger.exception("daily_prep open_threads section failed for user %d", user_id)
|
||||
sections["open_threads"] = []
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
|
||||
"""Heuristic: moments from the last 7 days that look unresolved.
|
||||
|
||||
Treated as 'unresolved' when they have no linked tasks/notes and aren't
|
||||
pinned. Starting heuristic — refine empirically.
|
||||
"""
|
||||
candidates = await search_journal(
|
||||
user_id=user_id,
|
||||
date_from=day_date - datetime.timedelta(days=7),
|
||||
date_to=day_date - datetime.timedelta(days=1),
|
||||
limit=50,
|
||||
)
|
||||
return [
|
||||
m for m in candidates
|
||||
if not m.get("task_ids")
|
||||
and not m.get("note_ids")
|
||||
and not m.get("pinned")
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
lines.append("")
|
||||
|
||||
events = sections.get("events") or []
|
||||
if events:
|
||||
lines.append("CALENDAR EVENTS TODAY:")
|
||||
for e in events[:8]:
|
||||
title = e.get("title", "Untitled")
|
||||
when = e.get("start_dt", "?")
|
||||
location = e.get("location") or ""
|
||||
line = f" - {title} at {when}"
|
||||
if location:
|
||||
line += f" ({location})"
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
weather = sections.get("weather") or []
|
||||
if weather:
|
||||
lines.append("WEATHER:")
|
||||
for w in weather:
|
||||
label = w.get("location_label") or w.get("location_key") or "Location"
|
||||
forecast_json = w.get("forecast_json") or {}
|
||||
daily = forecast_json.get("daily") or {}
|
||||
today_max = (daily.get("temperature_2m_max") or [None])[0]
|
||||
today_min = (daily.get("temperature_2m_min") or [None])[0]
|
||||
precip = (daily.get("precipitation_probability_max") or [None])[0]
|
||||
bits = [label]
|
||||
if today_max is not None and today_min is not None:
|
||||
bits.append(f"high {today_max}° / low {today_min}°")
|
||||
if precip is not None:
|
||||
bits.append(f"{precip}% chance of precipitation")
|
||||
lines.append(" - " + ", ".join(bits))
|
||||
lines.append("")
|
||||
|
||||
projects = sections.get("projects") or []
|
||||
if projects:
|
||||
lines.append("ACTIVE PROJECTS:")
|
||||
for p in projects[:5]:
|
||||
line = f" - {p.get('title', '?')}"
|
||||
if p.get("auto_summary"):
|
||||
summary = p["auto_summary"][:160]
|
||||
line += f" — {summary}"
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
recent_moments = sections.get("recent_moments") or []
|
||||
if recent_moments:
|
||||
lines.append("RECENT JOURNAL MOMENTS (last few days):")
|
||||
for m in recent_moments[:8]:
|
||||
day = m.get("day_date", "?")
|
||||
content = (m.get("content") or "").strip()
|
||||
lines.append(f" - [{day}] {content}")
|
||||
lines.append("")
|
||||
|
||||
open_threads = sections.get("open_threads") or []
|
||||
if open_threads:
|
||||
lines.append("OPEN THREADS (mentioned recently but not resolved):")
|
||||
for m in open_threads[:5]:
|
||||
day = m.get("day_date", "?")
|
||||
content = (m.get("content") or "").strip()
|
||||
lines.append(f" - [{day}] {content}")
|
||||
lines.append("")
|
||||
|
||||
if not lines:
|
||||
return "(No data for today — quiet morning.)"
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
_PREP_SYSTEM_PROMPT = (
|
||||
"You are briefing the user on their day. Direct and informative — tell them what's "
|
||||
"actually on their plate so they can step into the day with a clear picture.\n\n"
|
||||
"Rules:\n"
|
||||
"- LEAD with the practical data: tasks due today, calendar events, weather.\n"
|
||||
"- Be specific and concrete. Use real task titles, event times, temperatures, "
|
||||
"precipitation chances. Don't paraphrase data into vague summaries.\n"
|
||||
"- Write in flowing sentences — no markdown, no bullet points, no headers — but "
|
||||
"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"
|
||||
"- 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?\", "
|
||||
"\"Anything to set down?\", \"How's the morning shaping up?\" — pick one, keep it under 8 words.\n"
|
||||
"- Don't fabricate. Skip categories with no data; don't acknowledge their absence.\n"
|
||||
"- Voice is competent assistant briefing the user. Not a friend writing a letter."
|
||||
)
|
||||
|
||||
|
||||
def _fallback_prep_text(day_date: datetime.date) -> str:
|
||||
"""If the LLM call fails, return a minimal greeting so the user still sees something."""
|
||||
weekday = day_date.strftime("%A")
|
||||
return f"{weekday}, {day_date.isoformat()}. What's on your mind?"
|
||||
|
||||
|
||||
async def _generate_prep_prose(
|
||||
*,
|
||||
sections: dict,
|
||||
day_date: datetime.date,
|
||||
user_id: int,
|
||||
) -> str:
|
||||
"""Ask the LLM for a direct conversational journal opener built from the sections."""
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
|
||||
model = (await get_setting(user_id, "default_model", "")) or Config.OLLAMA_MODEL
|
||||
if not model:
|
||||
logger.warning("No LLM model configured for daily prep — using fallback text")
|
||||
return _fallback_prep_text(day_date)
|
||||
|
||||
rendered = _render_sections_for_prompt(sections)
|
||||
user_trigger = (
|
||||
f"Today is {day_date.strftime('%A, %B %-d, %Y')} ({day_date.isoformat()}).\n\n"
|
||||
f"Here is what I gathered for you:\n\n{rendered}\n\n"
|
||||
f"Write the opener for today's journal."
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": _PREP_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_trigger},
|
||||
]
|
||||
|
||||
try:
|
||||
prose = await generate_completion(
|
||||
messages=messages,
|
||||
model=model,
|
||||
max_tokens=400,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Daily prep prose generation failed for day %s", day_date)
|
||||
return _fallback_prep_text(day_date)
|
||||
|
||||
prose = (prose or "").strip()
|
||||
if not prose:
|
||||
logger.warning("LLM returned empty prep prose for day %s — using fallback", day_date)
|
||||
return _fallback_prep_text(day_date)
|
||||
return prose
|
||||
|
||||
|
||||
async def ensure_daily_prep_message(
|
||||
*,
|
||||
user_id: int,
|
||||
day_date: datetime.date,
|
||||
user_timezone: str,
|
||||
force: bool = False,
|
||||
) -> Message:
|
||||
"""Get or create today's journal Conversation, then ensure the prep message exists.
|
||||
|
||||
The prep message is an *assistant* role message containing the prose opener,
|
||||
with the structured sections preserved on ``msg_metadata``. If a legacy
|
||||
system-role prep exists from an earlier version, it gets upgraded in place
|
||||
on the next call.
|
||||
|
||||
With ``force=True`` the prose is regenerated even when a prep already exists.
|
||||
Used by the manual /api/journal/trigger-prep endpoint.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Conversation.day_date == day_date,
|
||||
)
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
if conv is None:
|
||||
conv = Conversation(
|
||||
user_id=user_id,
|
||||
conversation_type="journal",
|
||||
day_date=day_date,
|
||||
title=day_date.isoformat(),
|
||||
)
|
||||
session.add(conv)
|
||||
await session.flush()
|
||||
|
||||
# Find any existing prep (system or assistant role from any version).
|
||||
prep_stmt = select(Message).where(Message.conversation_id == conv.id)
|
||||
existing_prep = None
|
||||
for msg in (await session.execute(prep_stmt)).scalars():
|
||||
if msg.msg_metadata and msg.msg_metadata.get("kind") == "daily_prep":
|
||||
existing_prep = msg
|
||||
break
|
||||
|
||||
# If we already have an assistant-role prep with prose content and the
|
||||
# caller didn't ask to force regeneration, we're done.
|
||||
if (
|
||||
existing_prep
|
||||
and not force
|
||||
and existing_prep.role == "assistant"
|
||||
and (existing_prep.content or "").strip()
|
||||
):
|
||||
return existing_prep
|
||||
|
||||
sections = await gather_daily_sections(
|
||||
user_id=user_id, day_date=day_date, user_timezone=user_timezone
|
||||
)
|
||||
prose = await _generate_prep_prose(
|
||||
sections=sections, day_date=day_date, user_id=user_id
|
||||
)
|
||||
new_metadata = {"kind": "daily_prep", "sections": sections}
|
||||
|
||||
if existing_prep:
|
||||
# Upgrade in place: bump role, replace content + metadata.
|
||||
existing_prep.role = "assistant"
|
||||
existing_prep.content = prose
|
||||
existing_prep.msg_metadata = new_metadata
|
||||
await session.commit()
|
||||
return existing_prep
|
||||
|
||||
prep_msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role="assistant",
|
||||
content=prose,
|
||||
msg_metadata=new_metadata,
|
||||
)
|
||||
session.add(prep_msg)
|
||||
await session.commit()
|
||||
return prep_msg
|
||||
|
||||
|
||||
# Backwards-compat alias — older imports may use the old name.
|
||||
generate_daily_prep = gather_daily_sections
|
||||
@@ -0,0 +1,140 @@
|
||||
"""APScheduler instance for the Journal — daily prep generation.
|
||||
|
||||
One per-user cron job: generate today's daily prep at the configured
|
||||
prep_time in the user's local timezone. Replaces briefing_scheduler.
|
||||
|
||||
Mirrors the BackgroundScheduler + threadsafe-async-call pattern used by
|
||||
event_scheduler.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import User, async_session
|
||||
from fabledassistant.services.journal_prep import ensure_daily_prep_message
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"prep_enabled": True,
|
||||
"prep_hour": 5,
|
||||
"prep_minute": 0,
|
||||
"day_rollover_hour": 4,
|
||||
"morning_end_hour": 12,
|
||||
"midday_end_hour": 18,
|
||||
}
|
||||
|
||||
|
||||
async def get_journal_config(user_id: int) -> dict:
|
||||
"""Load a user's journal_config (JSON in settings) merged with defaults."""
|
||||
raw = await get_setting(user_id, "journal_config", "")
|
||||
config: dict = {}
|
||||
if raw:
|
||||
try:
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
||||
if isinstance(parsed, dict):
|
||||
config = parsed
|
||||
except Exception:
|
||||
logger.warning("Invalid journal_config for user %d; using defaults", user_id)
|
||||
return {**DEFAULT_CONFIG, **config}
|
||||
|
||||
|
||||
async def get_user_timezone(user_id: int) -> str:
|
||||
return await get_setting(user_id, "user_timezone", "UTC") or "UTC"
|
||||
|
||||
|
||||
def _resolve_tz(tz_str: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
async def _do_daily_prep(user_id: int) -> None:
|
||||
try:
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
config = await get_journal_config(user_id)
|
||||
rollover = int(config.get("day_rollover_hour", 4))
|
||||
now = datetime.datetime.now(tz)
|
||||
# Today is the day after the most recent rollover.
|
||||
if now.hour < rollover:
|
||||
today = (now - datetime.timedelta(days=1)).date()
|
||||
else:
|
||||
today = now.date()
|
||||
await ensure_daily_prep_message(
|
||||
user_id=user_id, day_date=today, user_timezone=tz_str
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Daily prep failed for user %d", user_id)
|
||||
|
||||
|
||||
def _run_daily_prep_threadsafe(user_id: int) -> None:
|
||||
if _loop is None:
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop)
|
||||
|
||||
|
||||
async def update_user_schedule(user_id: int) -> None:
|
||||
"""Add or replace this user's daily-prep job using their current config."""
|
||||
if _scheduler is None:
|
||||
return
|
||||
job_id = f"journal_prep_{user_id}"
|
||||
if _scheduler.get_job(job_id):
|
||||
_scheduler.remove_job(job_id)
|
||||
|
||||
config = await get_journal_config(user_id)
|
||||
if not config.get("prep_enabled", True):
|
||||
return
|
||||
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
prep_hour = int(config.get("prep_hour", 5))
|
||||
prep_minute = int(config.get("prep_minute", 0))
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_daily_prep_threadsafe,
|
||||
trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz),
|
||||
args=[user_id],
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
|
||||
async def _register_all_user_jobs() -> None:
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(select(User))).scalars().all()
|
||||
for user in users:
|
||||
await update_user_schedule(user.id)
|
||||
|
||||
|
||||
def start_journal_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global _scheduler, _loop
|
||||
if _scheduler is not None:
|
||||
return
|
||||
_loop = loop
|
||||
_scheduler = BackgroundScheduler()
|
||||
_scheduler.start()
|
||||
asyncio.run_coroutine_threadsafe(_register_all_user_jobs(), loop)
|
||||
logger.info("Journal scheduler started")
|
||||
|
||||
|
||||
def stop_journal_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
logger.info("Journal scheduler stopped")
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Search across Moments and (optionally) journal transcripts.
|
||||
|
||||
Three modes, expressed through one tool surface:
|
||||
1. Pure temporal: no query, just date range -> ordered by occurred_at DESC.
|
||||
2. Pure entity: person_id or place_id filter -> junction lookup.
|
||||
3. Semantic: query string -> embedding similarity, optionally constrained.
|
||||
|
||||
This module ONLY queries Moments and (optionally) journal-conversation
|
||||
messages. It MUST NOT touch notes or note_embeddings. The notes-RAG and
|
||||
journal-RAG isolation is a hard invariant of the journal design.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import math
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import (
|
||||
Conversation,
|
||||
Message,
|
||||
Moment,
|
||||
MomentEmbedding,
|
||||
async_session,
|
||||
moment_people,
|
||||
moment_places,
|
||||
)
|
||||
from fabledassistant.services.embeddings import get_embedding
|
||||
|
||||
DEFAULT_LIMIT = 10
|
||||
DEFAULT_THRESHOLD = 0.55
|
||||
|
||||
|
||||
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = math.sqrt(sum(x * x for x in a))
|
||||
norm_b = math.sqrt(sum(y * y for y in b))
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return 0.0
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
|
||||
async def search_journal(
|
||||
*,
|
||||
user_id: int,
|
||||
query: str | None = None,
|
||||
person_id: int | None = None,
|
||||
place_id: int | None = None,
|
||||
tag: str | None = None,
|
||||
date_from: datetime.date | None = None,
|
||||
date_to: datetime.date | None = None,
|
||||
include_transcripts: bool = False,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
threshold: float = DEFAULT_THRESHOLD,
|
||||
) -> list[dict]:
|
||||
"""Return Moments (and optional transcript snippets) matching the filters.
|
||||
|
||||
Result rows: dicts from Moment.to_dict() plus an optional `score` when
|
||||
`query` is set. Transcript snippets carry `kind='transcript'` and `id=None`
|
||||
to distinguish them.
|
||||
"""
|
||||
moment_rows = await _search_moments(
|
||||
user_id=user_id,
|
||||
query=query,
|
||||
person_id=person_id,
|
||||
place_id=place_id,
|
||||
tag=tag,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
threshold=threshold,
|
||||
)
|
||||
|
||||
if not include_transcripts:
|
||||
return moment_rows
|
||||
|
||||
transcript_rows = await _search_transcripts(
|
||||
user_id=user_id,
|
||||
query=query,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
)
|
||||
return moment_rows + transcript_rows
|
||||
|
||||
|
||||
async def _search_moments(
|
||||
*,
|
||||
user_id: int,
|
||||
query: str | None,
|
||||
person_id: int | None,
|
||||
place_id: int | None,
|
||||
tag: str | None,
|
||||
date_from: datetime.date | None,
|
||||
date_to: datetime.date | None,
|
||||
limit: int,
|
||||
threshold: float,
|
||||
) -> list[dict]:
|
||||
async with async_session() as session:
|
||||
stmt = select(Moment).where(Moment.user_id == user_id)
|
||||
|
||||
if person_id is not None:
|
||||
stmt = stmt.join(moment_people, moment_people.c.moment_id == Moment.id).where(
|
||||
moment_people.c.person_id == person_id
|
||||
)
|
||||
if place_id is not None:
|
||||
stmt = stmt.join(moment_places, moment_places.c.moment_id == Moment.id).where(
|
||||
moment_places.c.place_id == place_id
|
||||
)
|
||||
if tag is not None:
|
||||
stmt = stmt.where(Moment.tags.any(tag))
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(Moment.day_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(Moment.day_date <= date_to)
|
||||
|
||||
if query is None:
|
||||
stmt = stmt.order_by(Moment.occurred_at.desc()).limit(limit)
|
||||
moments = (await session.execute(stmt)).scalars().all()
|
||||
return [m.to_dict() for m in moments]
|
||||
|
||||
# Semantic mode. Pull a wider candidate set, then rank in Python.
|
||||
candidate_stmt = stmt.order_by(Moment.occurred_at.desc()).limit(limit * 5)
|
||||
candidates = (await session.execute(candidate_stmt)).scalars().all()
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
candidate_ids = [m.id for m in candidates]
|
||||
emb_stmt = select(MomentEmbedding).where(
|
||||
MomentEmbedding.moment_id.in_(candidate_ids)
|
||||
)
|
||||
embeddings = {
|
||||
e.moment_id: e.embedding
|
||||
for e in (await session.execute(emb_stmt)).scalars()
|
||||
}
|
||||
|
||||
query_vec = await get_embedding(query)
|
||||
scored = []
|
||||
for m in candidates:
|
||||
vec = embeddings.get(m.id)
|
||||
if vec is None:
|
||||
continue
|
||||
score = _cosine(query_vec, vec)
|
||||
if score >= threshold:
|
||||
row = m.to_dict()
|
||||
row["score"] = score
|
||||
scored.append(row)
|
||||
|
||||
scored.sort(key=lambda r: r["score"], reverse=True)
|
||||
return scored[:limit]
|
||||
|
||||
|
||||
async def _search_transcripts(
|
||||
*,
|
||||
user_id: int,
|
||||
query: str | None,
|
||||
date_from: datetime.date | None,
|
||||
date_to: datetime.date | None,
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
"""Fallback substring search over raw journal Messages.
|
||||
|
||||
Substring is intentional: transcripts catch what the LLM didn't extract
|
||||
as Moments. Semantic search over messages would require a third embedding
|
||||
index, which we deliberately don't maintain.
|
||||
"""
|
||||
if query is None:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(Message, Conversation.day_date)
|
||||
.join(Conversation, Message.conversation_id == Conversation.id)
|
||||
.where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Message.content.ilike(f"%{query}%"),
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(Conversation.day_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(Conversation.day_date <= date_to)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return [
|
||||
{
|
||||
"id": None,
|
||||
"kind": "transcript",
|
||||
"message_id": msg.id,
|
||||
"conversation_id": msg.conversation_id,
|
||||
"day_date": day_date.isoformat() if day_date else None,
|
||||
"occurred_at": msg.created_at.isoformat(),
|
||||
"content": msg.content[:400],
|
||||
"raw_excerpt": None,
|
||||
"tags": [],
|
||||
"people": [],
|
||||
"places": [],
|
||||
}
|
||||
for msg, day_date in rows
|
||||
]
|
||||
@@ -573,6 +573,34 @@ async def build_context(
|
||||
"""
|
||||
exclude_set = set(exclude_note_ids or [])
|
||||
from datetime import date as date_type
|
||||
|
||||
# --- Journal short-circuit ---
|
||||
# Journal conversations get a different persona, calibration, and an
|
||||
# ambient-moments context block. CRUCIALLY, no notes-RAG injection here
|
||||
# (preserves the notes/journal isolation invariant).
|
||||
if conv_id is not None:
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Conversation as _Conversation
|
||||
async with _async_session() as _sess:
|
||||
_conv = await _sess.get(_Conversation, conv_id)
|
||||
if _conv and getattr(_conv, "conversation_type", None) == "journal":
|
||||
from fabledassistant.services.journal_pipeline import build_journal_system_prompt
|
||||
day_date = _conv.day_date or date_type.today()
|
||||
system_content = await build_journal_system_prompt(
|
||||
user_id=user_id,
|
||||
day_date=day_date,
|
||||
user_timezone=user_timezone or "UTC",
|
||||
)
|
||||
messages_out: list[dict] = [{"role": "system", "content": system_content}]
|
||||
messages_out.extend(history)
|
||||
messages_out.append({"role": "user", "content": user_message})
|
||||
return messages_out, {
|
||||
"context_note_id": None,
|
||||
"context_note_title": None,
|
||||
"auto_notes": [],
|
||||
"auto_injected_notes": [],
|
||||
}
|
||||
|
||||
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
|
||||
today = date_type.today().isoformat()
|
||||
has_caldav = await is_caldav_configured(user_id)
|
||||
@@ -595,7 +623,7 @@ async def build_context(
|
||||
"search_projects", "create_milestone", "update_milestone", "list_milestones",
|
||||
"save_person", "save_place", "create_list", "add_to_list", "clear_checked_items",
|
||||
"set_rag_scope", "get_profile", "update_profile", "get_weather", "calculate",
|
||||
"get_rss_items", "add_rss_feed", "read_article",
|
||||
"read_article",
|
||||
]
|
||||
if has_caldav:
|
||||
actions.extend(["create_event", "list_events", "search_events", "update_event", "delete_event", "list_calendars"])
|
||||
@@ -632,7 +660,7 @@ async def build_context(
|
||||
tool_guidance = "\n".join(tool_lines)
|
||||
|
||||
static_block = (
|
||||
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
|
||||
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Scribe. "
|
||||
"Help users with their notes, tasks, and general questions. "
|
||||
"When note context is provided, use it to give relevant answers.\n\n"
|
||||
f"{tool_guidance}"
|
||||
@@ -655,8 +683,8 @@ async def build_context(
|
||||
# --- System message: stable content only ---
|
||||
# Workspace context and history summary stay here because they carry
|
||||
# behavioural instructions / conversational state, not retrieved content.
|
||||
# Everything retrieval-based (RAG notes, RSS, URL content, current note,
|
||||
# briefing articles) goes into the user turn below so the system message
|
||||
# Everything retrieval-based (RAG notes, URL content, current note)
|
||||
# goes into the user turn below so the system message
|
||||
# prefix stays byte-for-byte identical across requests, enabling Ollama's
|
||||
# KV prefix cache to fire reliably.
|
||||
|
||||
@@ -698,25 +726,6 @@ async def build_context(
|
||||
f"\n\n--- Earlier Conversation ---\n{history_summary}\n--- End Earlier Conversation ---"
|
||||
)
|
||||
|
||||
# Detect briefing conversation — used for both system prompt instruction and article injection
|
||||
_is_briefing_conv = False
|
||||
if conv_id is not None:
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Conversation as _Conversation
|
||||
async with _async_session() as _sess:
|
||||
_conv = await _sess.get(_Conversation, conv_id)
|
||||
if _conv and getattr(_conv, "conversation_type", None) == "briefing":
|
||||
_is_briefing_conv = True
|
||||
|
||||
if _is_briefing_conv:
|
||||
system_content += (
|
||||
"\n\nYou are in a briefing conversation. "
|
||||
"The conversation history contains today's briefing — news stories, weather, and tasks. "
|
||||
"When the user asks about a topic, person, or event from the briefing, answer directly "
|
||||
"from the conversation history and the article context that follows. "
|
||||
"Do NOT search the web for information that is already present in the briefing."
|
||||
)
|
||||
|
||||
context_meta: dict = {
|
||||
"context_note_id": None,
|
||||
"context_note_title": None,
|
||||
@@ -752,27 +761,18 @@ async def build_context(
|
||||
orphan_only = rag_project_id is None
|
||||
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
|
||||
|
||||
# Skip RAG auto-injection on the first turn of a seeded article discussion.
|
||||
# The article body is already the sole context the user wants — pulling in
|
||||
# unrelated orphan notes tricks the model into summarizing those instead.
|
||||
# Follow-up turns keep RAG on because by then the user's own messages drive
|
||||
# the query rather than the generic seed prompt.
|
||||
from fabledassistant.services.article_context import ARTICLE_DISCUSS_SEED
|
||||
_skip_rag_for_article_seed = user_message.strip() == ARTICLE_DISCUSS_SEED
|
||||
try:
|
||||
from fabledassistant.services.embeddings import semantic_search_notes
|
||||
for score, note in await semantic_search_notes(
|
||||
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
|
||||
project_id=effective_project_id,
|
||||
orphan_only=orphan_only,
|
||||
):
|
||||
found_scored.append((score, note))
|
||||
except Exception:
|
||||
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
|
||||
|
||||
if not _skip_rag_for_article_seed:
|
||||
try:
|
||||
from fabledassistant.services.embeddings import semantic_search_notes
|
||||
for score, note in await semantic_search_notes(
|
||||
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
|
||||
project_id=effective_project_id,
|
||||
orphan_only=orphan_only,
|
||||
):
|
||||
found_scored.append((score, note))
|
||||
except Exception:
|
||||
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
|
||||
|
||||
if not found_scored and not _skip_rag_for_article_seed:
|
||||
if not found_scored:
|
||||
keywords = _extract_keywords(user_message)
|
||||
if keywords:
|
||||
try:
|
||||
@@ -862,12 +862,6 @@ async def build_context(
|
||||
f"--- Content from {url} ---\n{content}\n--- End URL Content ---"
|
||||
)
|
||||
|
||||
# Briefing article context for follow-up Q&A
|
||||
if _is_briefing_conv:
|
||||
article_context = await _build_briefing_article_context(conv_id) # type: ignore[arg-type]
|
||||
if article_context:
|
||||
user_context_parts.append(article_context.strip())
|
||||
|
||||
# Build final user message — context prefix (if any) followed by the actual message
|
||||
if user_context_parts:
|
||||
user_turn = "\n\n".join(user_context_parts) + "\n\n" + user_message
|
||||
@@ -878,72 +872,3 @@ async def build_context(
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": user_turn})
|
||||
return messages, context_meta
|
||||
|
||||
|
||||
async def _build_briefing_article_context(conv_id: int) -> str:
|
||||
"""Fetch article content from today's briefing message and return a
|
||||
formatted context block for injection into the system prompt.
|
||||
|
||||
Looks at the most recent assistant briefing messages for rss_item_ids
|
||||
in their metadata, then loads those items from the DB.
|
||||
Capped at 10 articles × 500 chars to keep token use reasonable.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from sqlalchemy import select, text as _text
|
||||
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Message
|
||||
|
||||
async with _async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.conversation_id == conv_id,
|
||||
Message.role == "assistant",
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
|
||||
rss_item_ids: list[int] = []
|
||||
for msg in messages:
|
||||
meta = msg.msg_metadata or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = _json.loads(meta)
|
||||
except Exception:
|
||||
continue
|
||||
ids = meta.get("rss_item_ids") or []
|
||||
if ids:
|
||||
rss_item_ids = ids
|
||||
break
|
||||
|
||||
if not rss_item_ids:
|
||||
return ""
|
||||
|
||||
async with _async_session() as session:
|
||||
result = await session.execute(
|
||||
_text("""
|
||||
SELECT i.title, i.url, i.content, f.title AS feed_title
|
||||
FROM rss_items i
|
||||
JOIN rss_feeds f ON f.id = i.feed_id
|
||||
WHERE i.id = ANY(:ids)
|
||||
ORDER BY i.published_at DESC NULLS LAST
|
||||
LIMIT 10
|
||||
""").bindparams(ids=rss_item_ids[:10])
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
lines = ["\n\nARTICLE CONTEXT (source articles from today's briefing):"]
|
||||
for row in rows:
|
||||
lines.append(f"\n[{row['feed_title']}] {row['title']}")
|
||||
if row["url"]:
|
||||
lines.append(f"URL: {row['url']}")
|
||||
if row["content"]:
|
||||
lines.append(row["content"][:500])
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Moment CRUD, entity linking, and embedding sync.
|
||||
|
||||
Moments are small structured extractions from journal conversations.
|
||||
Stored separately from Notes — they have their own embedding index and
|
||||
the isolation between notes-RAG and journal-RAG is enforced at the API
|
||||
boundary, not just the schema.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fabledassistant.models import (
|
||||
Moment,
|
||||
MomentEmbedding,
|
||||
async_session,
|
||||
moment_notes,
|
||||
moment_people,
|
||||
moment_places,
|
||||
moment_tasks,
|
||||
)
|
||||
from fabledassistant.services.embeddings import get_embedding
|
||||
|
||||
|
||||
async def create_moment(
|
||||
*,
|
||||
user_id: int,
|
||||
content: str,
|
||||
occurred_at: datetime.datetime,
|
||||
day_date: datetime.date,
|
||||
conversation_id: int | None = None,
|
||||
source_message_id: int | None = None,
|
||||
raw_excerpt: str | None = None,
|
||||
tags: Sequence[str] = (),
|
||||
person_ids: Sequence[int] = (),
|
||||
place_ids: Sequence[int] = (),
|
||||
task_ids: Sequence[int] = (),
|
||||
note_ids: Sequence[int] = (),
|
||||
) -> Moment:
|
||||
"""Insert a Moment, write its embedding, create junction rows."""
|
||||
async with async_session() as session:
|
||||
moment = Moment(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
source_message_id=source_message_id,
|
||||
day_date=day_date,
|
||||
occurred_at=occurred_at,
|
||||
content=content,
|
||||
raw_excerpt=raw_excerpt,
|
||||
tags=list(tags),
|
||||
)
|
||||
session.add(moment)
|
||||
await session.flush()
|
||||
|
||||
await _set_links(
|
||||
session,
|
||||
moment_id=moment.id,
|
||||
person_ids=person_ids,
|
||||
place_ids=place_ids,
|
||||
task_ids=task_ids,
|
||||
note_ids=note_ids,
|
||||
)
|
||||
|
||||
embedding_vector = await get_embedding(content)
|
||||
session.add(
|
||||
MomentEmbedding(
|
||||
moment_id=moment.id,
|
||||
user_id=user_id,
|
||||
embedding=embedding_vector,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(moment, ["people", "places", "tasks", "notes"])
|
||||
return moment
|
||||
|
||||
|
||||
async def get_moment(*, user_id: int, moment_id: int) -> Moment | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_moment(
|
||||
*,
|
||||
user_id: int,
|
||||
moment_id: int,
|
||||
content: str | None = None,
|
||||
tags: Sequence[str] | None = None,
|
||||
pinned: bool | None = None,
|
||||
person_ids: Sequence[int] | None = None,
|
||||
place_ids: Sequence[int] | None = None,
|
||||
task_ids: Sequence[int] | None = None,
|
||||
note_ids: Sequence[int] | None = None,
|
||||
) -> Moment | None:
|
||||
"""Update fields and (optionally) replace junction sets.
|
||||
|
||||
None for a junction parameter ⇒ leave unchanged. Empty sequence ⇒ clear.
|
||||
Mirrors the Notes update convention.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
|
||||
)
|
||||
moment = result.scalar_one_or_none()
|
||||
if moment is None:
|
||||
return None
|
||||
|
||||
if content is not None:
|
||||
moment.content = content
|
||||
embedding_vector = await get_embedding(content)
|
||||
await session.execute(
|
||||
delete(MomentEmbedding).where(MomentEmbedding.moment_id == moment_id)
|
||||
)
|
||||
session.add(
|
||||
MomentEmbedding(
|
||||
moment_id=moment_id,
|
||||
user_id=user_id,
|
||||
embedding=embedding_vector,
|
||||
)
|
||||
)
|
||||
if tags is not None:
|
||||
moment.tags = list(tags)
|
||||
if pinned is not None:
|
||||
moment.pinned = pinned
|
||||
|
||||
await _set_links(
|
||||
session,
|
||||
moment_id=moment_id,
|
||||
person_ids=person_ids,
|
||||
place_ids=place_ids,
|
||||
task_ids=task_ids,
|
||||
note_ids=note_ids,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(moment, ["people", "places", "tasks", "notes"])
|
||||
return moment
|
||||
|
||||
|
||||
async def delete_moment(*, user_id: int, moment_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
|
||||
)
|
||||
moment = result.scalar_one_or_none()
|
||||
if moment is None:
|
||||
return False
|
||||
await session.delete(moment)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def _set_links(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
moment_id: int,
|
||||
person_ids: Sequence[int] | None,
|
||||
place_ids: Sequence[int] | None,
|
||||
task_ids: Sequence[int] | None,
|
||||
note_ids: Sequence[int] | None,
|
||||
) -> None:
|
||||
"""Replace junction-table rows.
|
||||
|
||||
None ⇒ leave existing rows alone. Empty sequence ⇒ clear all rows.
|
||||
"""
|
||||
for ids, table, fk_col in [
|
||||
(person_ids, moment_people, "person_id"),
|
||||
(place_ids, moment_places, "place_id"),
|
||||
(task_ids, moment_tasks, "task_id"),
|
||||
(note_ids, moment_notes, "note_id"),
|
||||
]:
|
||||
if ids is None:
|
||||
continue
|
||||
await session.execute(delete(table).where(table.c.moment_id == moment_id))
|
||||
if ids:
|
||||
await session.execute(
|
||||
table.insert(),
|
||||
[{"moment_id": moment_id, fk_col: i} for i in ids],
|
||||
)
|
||||
@@ -91,7 +91,7 @@ async def notify_security_event(
|
||||
</table>
|
||||
<p style="margin:16px 0 0;color:#9ca3af;font-size:12px;">If this wasn't you, change your password immediately.</p>
|
||||
"""
|
||||
await send_email(email, f"Fabled Assistant - {label}", _email_html("Security Alert", body))
|
||||
await send_email(email, f"Fabled Scribe - {label}", _email_html("Security Alert", body))
|
||||
except Exception:
|
||||
logger.exception("Failed to send security notification for user %d", user_id)
|
||||
|
||||
@@ -113,7 +113,7 @@ async def send_password_reset_email(email: str, reset_url: str) -> None:
|
||||
<p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
|
||||
<p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{reset_url}</p>
|
||||
"""
|
||||
await send_email(email, "Fabled Assistant - Password Reset", _email_html("Password Reset", body))
|
||||
await send_email(email, "Fabled Scribe - Password Reset", _email_html("Password Reset", body))
|
||||
|
||||
|
||||
async def send_password_reset_success_email(email: str) -> None:
|
||||
@@ -125,14 +125,14 @@ async def send_password_reset_success_email(email: str) -> None:
|
||||
</p>
|
||||
<p style="margin:0;color:#6b7280;font-size:13px;">If you didn't make this change, please contact your administrator immediately.</p>
|
||||
"""
|
||||
await send_email(email, "Fabled Assistant - Password Changed", _email_html("Password Changed", body))
|
||||
await send_email(email, "Fabled Scribe - Password Changed", _email_html("Password Changed", body))
|
||||
|
||||
|
||||
async def send_invitation_email(email: str, invite_url: str, invited_by_username: str) -> None:
|
||||
"""Send a branded invitation email with a registration link."""
|
||||
body = f"""
|
||||
<p style="margin:0 0 16px;color:#374151;font-size:15px;">
|
||||
<strong>{invited_by_username}</strong> has invited you to join Fabled Assistant.
|
||||
<strong>{invited_by_username}</strong> has invited you to join Fabled Scribe.
|
||||
Click the button below to create your account.
|
||||
</p>
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
@@ -146,7 +146,7 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
|
||||
<p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
|
||||
<p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{invite_url}</p>
|
||||
"""
|
||||
await send_email(email, "Fabled Assistant - You're Invited!", _email_html("You're Invited!", body))
|
||||
await send_email(email, "Fabled Scribe - You're Invited!", _email_html("You're Invited!", body))
|
||||
|
||||
|
||||
async def check_due_tasks() -> None:
|
||||
@@ -225,7 +225,7 @@ async def check_due_tasks() -> None:
|
||||
{task_rows}
|
||||
</table>
|
||||
"""
|
||||
await send_email(email, f"Fabled Assistant - {len(user_tasks)} Task(s) Due", _email_html("Task Reminders", body))
|
||||
await send_email(email, f"Fabled Scribe - {len(user_tasks)} Task(s) Due", _email_html("Task Reminders", body))
|
||||
await log_audit(
|
||||
"task_reminder",
|
||||
user_id=user_id,
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
"""RSS feed service: fetch, parse with feedparser, and cache items to DB."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import feedparser
|
||||
import html2text
|
||||
import httpx
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.rss_feed import RssFeed, RssItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keep only items from the last N days to avoid unbounded growth
|
||||
ITEM_MAX_AGE_DAYS = 90
|
||||
|
||||
_h2t = html2text.HTML2Text()
|
||||
_h2t.ignore_links = True
|
||||
_h2t.ignore_images = True
|
||||
_h2t.ignore_emphasis = False
|
||||
_h2t.body_width = 0 # No line-wrapping
|
||||
|
||||
|
||||
def _html_to_text(html: str) -> str:
|
||||
"""Convert HTML to clean plain text via html2text."""
|
||||
if not html or "<" not in html:
|
||||
return html
|
||||
try:
|
||||
return _h2t.handle(html).strip()
|
||||
except Exception:
|
||||
return html
|
||||
|
||||
|
||||
async def get_or_fetch_full_article(item: RssItem) -> str | None:
|
||||
"""Return the full article body, fetching+caching on miss.
|
||||
|
||||
Checks ``item.content_full`` first — populated either by the enrichment
|
||||
pass at feed-ingest time or by a previous discuss-click. On miss, fetches
|
||||
via ``_fetch_full_article`` and writes through. Returns ``None`` only if
|
||||
the fetch itself fails; ``item.content_full == ""`` is still a cache hit.
|
||||
|
||||
Callers must pass an RssItem attached to an open session if they want
|
||||
the write-through to persist — otherwise the fetched text is returned
|
||||
but the cache stays empty and the next click will re-fetch.
|
||||
"""
|
||||
if item.content_full is not None:
|
||||
return item.content_full
|
||||
if not item.url:
|
||||
return None
|
||||
text = await _fetch_full_article(item.url)
|
||||
if text is None:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(RssItem, item.id)
|
||||
if fresh is not None:
|
||||
fresh.content_full = text
|
||||
fresh.content_fetched_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return text
|
||||
|
||||
|
||||
async def _fetch_full_article(url: str) -> str | None:
|
||||
"""Fetch a URL and extract its main article text via trafilatura.
|
||||
|
||||
Returns clean plain text, or None if extraction fails or yields nothing useful.
|
||||
Runs trafilatura in a thread executor since it does synchronous HTML parsing.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, headers={
|
||||
"User-Agent": "Mozilla/5.0 (compatible; FabledAssistant/1.0; +https://fabledsword.com)",
|
||||
}) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
raw_html = resp.text
|
||||
except Exception:
|
||||
logger.debug("Failed to fetch article URL %s", url)
|
||||
return None
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
import trafilatura
|
||||
text = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: trafilatura.extract(
|
||||
raw_html,
|
||||
include_comments=False,
|
||||
include_tables=True,
|
||||
favor_recall=True,
|
||||
),
|
||||
)
|
||||
return text or None
|
||||
except Exception:
|
||||
logger.debug("trafilatura extraction failed for %s", url, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def extract_item(entry) -> dict:
|
||||
"""Extract a clean item dict from a feedparser entry object."""
|
||||
# Prefer full content over summary (feedparser uses a list of Content objects)
|
||||
content = ""
|
||||
raw_content = getattr(entry, "content", None)
|
||||
if isinstance(raw_content, list) and raw_content:
|
||||
content = raw_content[0].value
|
||||
else:
|
||||
content = entry.get("summary", "")
|
||||
content = _html_to_text(content)
|
||||
|
||||
pub = None
|
||||
if entry.published_parsed:
|
||||
try:
|
||||
pub = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"guid": entry.get("id", entry.get("link", "")),
|
||||
"title": entry.get("title", ""),
|
||||
"url": entry.get("link", ""),
|
||||
"content": content,
|
||||
"published_at": pub,
|
||||
}
|
||||
|
||||
|
||||
async def _parse_feed(url: str) -> feedparser.FeedParserDict:
|
||||
"""Run feedparser in a thread executor so we don't block the event loop."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, feedparser.parse, url)
|
||||
|
||||
|
||||
async def fetch_and_cache_feed(feed_id: int, url: str) -> int:
|
||||
"""
|
||||
Fetch a feed URL, parse it, and upsert new items into rss_items.
|
||||
Returns the number of new items stored.
|
||||
"""
|
||||
scheme = url.split("://")[0].lower() if "://" in url else ""
|
||||
if scheme not in ("http", "https"):
|
||||
logger.warning("Blocked RSS fetch with non-http(s) scheme: %s", url[:80])
|
||||
return 0
|
||||
try:
|
||||
parsed = await _parse_feed(url)
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch RSS feed %s", url, exc_info=True)
|
||||
return 0
|
||||
|
||||
if parsed.bozo and not parsed.entries:
|
||||
logger.warning("Malformed RSS feed %s: %s", url, parsed.bozo_exception)
|
||||
return 0
|
||||
|
||||
new_count = 0
|
||||
feed_user_id: int | None = None
|
||||
|
||||
async with async_session() as session:
|
||||
for entry in parsed.entries:
|
||||
item_data = extract_item(entry)
|
||||
if not item_data["guid"]:
|
||||
continue
|
||||
# Check if already stored
|
||||
existing = await session.execute(
|
||||
select(RssItem).where(
|
||||
RssItem.feed_id == feed_id,
|
||||
RssItem.guid == item_data["guid"],
|
||||
)
|
||||
)
|
||||
if existing.scalars().first() is not None:
|
||||
continue
|
||||
item = RssItem(
|
||||
feed_id=feed_id,
|
||||
**item_data,
|
||||
)
|
||||
session.add(item)
|
||||
new_count += 1
|
||||
|
||||
# Update last_fetched_at on the feed
|
||||
feed_row = await session.get(RssFeed, feed_id)
|
||||
if feed_row:
|
||||
feed_row.last_fetched_at = datetime.now(timezone.utc)
|
||||
feed_user_id = feed_row.user_id
|
||||
# Auto-populate title from feed metadata if blank
|
||||
if not feed_row.title and parsed.feed.get("title"):
|
||||
feed_row.title = parsed.feed.title[:200]
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Collect IDs of unclassified items after commit.
|
||||
# We query classified_at IS NULL (not just the items inserted above) because
|
||||
# classification is best-effort and may have failed on previous fetches.
|
||||
# Re-queuing all unclassified items for this feed on each fetch is intentional:
|
||||
# it provides automatic retry without a separate retry loop. The classifier
|
||||
# only writes to items it successfully classifies, so already-classified items
|
||||
# are not re-processed (they have classified_at set).
|
||||
unclassified_ids: list[int] = []
|
||||
new_item_data: list[tuple[int, str, str]] = [] # (id, title, content) for embedding
|
||||
if new_count > 0:
|
||||
result = await session.execute(
|
||||
select(RssItem.id, RssItem.title, RssItem.content, RssItem.classified_at).where(
|
||||
RssItem.feed_id == feed_id,
|
||||
)
|
||||
)
|
||||
for row in result.fetchall():
|
||||
item_id, title, content, classified_at = row
|
||||
if classified_at is None:
|
||||
unclassified_ids.append(item_id)
|
||||
new_item_data.append((item_id, title or "", content or ""))
|
||||
|
||||
# Prune old items to keep DB tidy
|
||||
await _prune_old_items(feed_id)
|
||||
|
||||
# Fire-and-forget classification for unclassified items
|
||||
if unclassified_ids and feed_user_id is not None:
|
||||
from fabledassistant.services.rss_classifier import classify_and_store
|
||||
asyncio.create_task(classify_and_store(unclassified_ids, feed_user_id))
|
||||
|
||||
# Collect (id, url) for newly inserted items to enrich with full article text
|
||||
new_items_for_enrichment: list[tuple[int, str]] = []
|
||||
if new_count > 0:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssItem.id, RssItem.url).where(
|
||||
RssItem.feed_id == feed_id,
|
||||
RssItem.url != "",
|
||||
).order_by(RssItem.fetched_at.desc()).limit(new_count)
|
||||
)
|
||||
new_items_for_enrichment = list(result.fetchall())
|
||||
|
||||
# Fire-and-forget: fetch full article text, then re-embed with richer content
|
||||
if new_items_for_enrichment and feed_user_id is not None:
|
||||
from fabledassistant.services.embeddings import upsert_rss_item_embedding
|
||||
|
||||
async def _enrich_and_embed() -> None:
|
||||
for item_id, article_url in new_items_for_enrichment:
|
||||
full_text = await _fetch_full_article(article_url)
|
||||
if full_text and len(full_text) > 200:
|
||||
async with async_session() as session:
|
||||
item = await session.get(RssItem, item_id)
|
||||
if item:
|
||||
item.content = full_text
|
||||
# Populate the discuss-click cache too so the
|
||||
# first click skips straight to the map-reduce
|
||||
# step without re-fetching.
|
||||
item.content_full = full_text
|
||||
item.content_fetched_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await upsert_rss_item_embedding(
|
||||
item_id, feed_user_id, item.title or "", item.content
|
||||
)
|
||||
else:
|
||||
# Enrich failed — still embed with RSS-provided content
|
||||
async with async_session() as session:
|
||||
item = await session.get(RssItem, item_id)
|
||||
if item:
|
||||
await upsert_rss_item_embedding(
|
||||
item_id, feed_user_id, item.title or "", item.content or ""
|
||||
)
|
||||
await asyncio.sleep(0.5) # Polite pacing between article fetches
|
||||
|
||||
asyncio.create_task(_enrich_and_embed())
|
||||
elif new_item_data and feed_user_id is not None:
|
||||
# No URLs to enrich — embed with RSS content only
|
||||
from fabledassistant.services.embeddings import upsert_rss_item_embedding
|
||||
|
||||
async def _embed_only() -> None:
|
||||
for item_id, title, content in new_item_data:
|
||||
await upsert_rss_item_embedding(item_id, feed_user_id, title, content)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
asyncio.create_task(_embed_only())
|
||||
|
||||
return new_count
|
||||
|
||||
|
||||
async def _prune_old_items(feed_id: int) -> None:
|
||||
"""Delete items older than ITEM_MAX_AGE_DAYS from a feed."""
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
text("""
|
||||
DELETE FROM rss_items
|
||||
WHERE feed_id = :feed_id
|
||||
AND published_at < NOW() - INTERVAL '90 days'
|
||||
""").bindparams(feed_id=feed_id)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_recent_items(user_id: int, limit: int = 20) -> list[dict]:
|
||||
"""Return the most recent RSS items across all of a user's feeds."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssItem, RssFeed.title.label("feed_title"))
|
||||
.join(RssFeed, RssItem.feed_id == RssFeed.id)
|
||||
.where(RssFeed.user_id == user_id)
|
||||
.order_by(RssItem.published_at.desc().nullslast())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{**item.to_dict(), "feed_title": feed_title}
|
||||
for item, feed_title in rows
|
||||
]
|
||||
|
||||
|
||||
async def refresh_all_feeds(user_id: int) -> dict[int, int]:
|
||||
"""Fetch all feeds for a user. Returns {feed_id: new_items_count}."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssFeed).where(RssFeed.user_id == user_id)
|
||||
)
|
||||
feeds = list(result.scalars().all())
|
||||
|
||||
results = {}
|
||||
for feed in feeds:
|
||||
count = await fetch_and_cache_feed(feed.id, feed.url)
|
||||
results[feed.id] = count
|
||||
return results
|
||||
@@ -1,152 +0,0 @@
|
||||
"""
|
||||
RSS item topic classifier.
|
||||
|
||||
Classifies RSS items into topic tags using a fast non-streaming LLM call.
|
||||
Called from rss.py after new items are stored — fire-and-forget.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from fabledassistant.config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STANDARD_TOPICS = [
|
||||
"technology", "science", "politics", "business",
|
||||
"health", "environment", "local", "entertainment", "sports", "other",
|
||||
]
|
||||
|
||||
_CLASSIFY_PROMPT = """\
|
||||
Classify each news item into 1-3 topics. Use only topics from this list: {vocab}.
|
||||
Return ONLY a JSON object mapping item_id (as string) to a list of topics.
|
||||
Example: {{"1": ["technology", "ai"], "2": ["politics"]}}
|
||||
|
||||
Items:
|
||||
{items_block}"""
|
||||
|
||||
|
||||
async def _llm_classify(prompt: str, model: str) -> str:
|
||||
"""Make a fast non-streaming LLM call and return the raw text response."""
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": False,
|
||||
"options": {"num_ctx": 2048, "temperature": 0.0},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(f"{Config.OLLAMA_URL}/api/chat", json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("message", {}).get("content", "")
|
||||
|
||||
|
||||
async def classify_items_batch(
|
||||
items: list[dict],
|
||||
user_include_topics: list[str],
|
||||
model: str | None = None,
|
||||
) -> dict[int, list[str]]:
|
||||
"""
|
||||
Classify a batch of RSS items into topic tags.
|
||||
|
||||
Args:
|
||||
items: list of dicts with 'id', 'title', 'content'
|
||||
user_include_topics: extra topics from user preferences to add to vocabulary
|
||||
model: Ollama model name; defaults to Config.OLLAMA_MODEL
|
||||
|
||||
Returns:
|
||||
dict mapping item_id (int) -> list of topic strings.
|
||||
Items not returned had classification fail; callers should leave classified_at=NULL.
|
||||
"""
|
||||
if not items:
|
||||
return {}
|
||||
|
||||
if model is None:
|
||||
model = Config.OLLAMA_BACKGROUND_MODEL
|
||||
|
||||
vocab = STANDARD_TOPICS + [t for t in user_include_topics if t not in STANDARD_TOPICS]
|
||||
items_block = "\n".join(
|
||||
f"[{item['id']}] {item['title']} — {item.get('content', '')[:300]}"
|
||||
for item in items
|
||||
)
|
||||
prompt = _CLASSIFY_PROMPT.format(vocab=", ".join(vocab), items_block=items_block)
|
||||
|
||||
try:
|
||||
raw = await _llm_classify(prompt, model)
|
||||
# Strip <think>...</think> blocks emitted by reasoning models (e.g. qwen3)
|
||||
raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL)
|
||||
# Extract JSON from response (LLM may wrap it in markdown)
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("```")[1]
|
||||
if raw.startswith("json"):
|
||||
raw = raw[4:]
|
||||
raw = raw.strip()
|
||||
# Allow control characters that may appear in LLM-generated JSON strings
|
||||
parsed = json.loads(raw, strict=False)
|
||||
return {int(k): v for k, v in parsed.items() if isinstance(v, list)}
|
||||
except Exception:
|
||||
logger.warning("RSS classification failed", exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
async def classify_and_store(
|
||||
item_ids: list[int],
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""
|
||||
Classify unclassified RSS items and write results to DB.
|
||||
Called as a fire-and-forget task from rss.py.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.rss_feed import RssItem
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
if not item_ids:
|
||||
return
|
||||
|
||||
# Load the items
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssItem).where(RssItem.id.in_(item_ids))
|
||||
)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
if not items:
|
||||
return
|
||||
|
||||
# Get user's include topics to extend vocabulary
|
||||
raw_include = await get_setting(user_id, "briefing_include_topics", "[]")
|
||||
try:
|
||||
include_topics = json.loads(raw_include) if isinstance(raw_include, str) else []
|
||||
except Exception:
|
||||
include_topics = []
|
||||
|
||||
model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
|
||||
|
||||
# Classify in batches of 10
|
||||
batch_size = 10
|
||||
all_results: dict[int, list[str]] = {}
|
||||
for i in range(0, len(items), batch_size):
|
||||
batch = items[i: i + batch_size]
|
||||
batch_dicts = [{"id": it.id, "title": it.title, "content": it.content} for it in batch]
|
||||
results = await classify_items_batch(batch_dicts, include_topics, model=model)
|
||||
all_results.update(results)
|
||||
|
||||
# Write back to DB
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as session:
|
||||
for item in items:
|
||||
item_db = await session.get(RssItem, item.id)
|
||||
if item_db is None:
|
||||
continue
|
||||
topics = all_results.get(item.id)
|
||||
if topics is not None:
|
||||
item_db.topics = topics
|
||||
item_db.classified_at = now
|
||||
await session.commit()
|
||||
@@ -8,13 +8,14 @@ of the app depends on.
|
||||
# Import every tool module so their @tool decorators run at import time.
|
||||
# Order does not matter — registration is additive.
|
||||
from fabledassistant.services.tools import ( # noqa: F401
|
||||
article,
|
||||
calendar,
|
||||
entities,
|
||||
journal,
|
||||
notes,
|
||||
profile,
|
||||
projects,
|
||||
rag,
|
||||
rss,
|
||||
tasks,
|
||||
utility,
|
||||
weather,
|
||||
@@ -22,12 +23,10 @@ from fabledassistant.services.tools import ( # noqa: F401
|
||||
)
|
||||
from fabledassistant.services.tools._registry import (
|
||||
execute_tool,
|
||||
get_briefing_tools,
|
||||
get_tools_for_user,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"execute_tool",
|
||||
"get_briefing_tools",
|
||||
"get_tools_for_user",
|
||||
]
|
||||
|
||||
@@ -27,6 +27,7 @@ class ToolDef:
|
||||
handler: ToolHandler
|
||||
read_only: bool = False
|
||||
briefing: bool = False
|
||||
journal: bool = False
|
||||
requires: str | None = None
|
||||
required_params: list[str] = field(default_factory=list)
|
||||
|
||||
@@ -57,6 +58,7 @@ def tool(
|
||||
required: list[str] | None = None,
|
||||
read_only: bool = False,
|
||||
briefing: bool = False,
|
||||
journal: bool = False,
|
||||
requires: str | None = None,
|
||||
) -> Callable[[ToolHandler], ToolHandler]:
|
||||
"""Register an async tool handler with its schema metadata."""
|
||||
@@ -71,6 +73,7 @@ def tool(
|
||||
handler=fn,
|
||||
read_only=read_only,
|
||||
briefing=briefing,
|
||||
journal=journal,
|
||||
requires=requires,
|
||||
required_params=required or [],
|
||||
)
|
||||
@@ -84,33 +87,32 @@ async def _check_requires(user_id: int, requires: str) -> bool:
|
||||
return await is_caldav_configured(user_id)
|
||||
if requires == "searxng":
|
||||
return Config.searxng_enabled()
|
||||
if requires == "rss":
|
||||
from fabledassistant.services.settings import get_setting
|
||||
return (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
|
||||
return True
|
||||
|
||||
|
||||
async def get_tools_for_user(user_id: int) -> list[dict]:
|
||||
"""Build the tool schema list for a user based on configured integrations."""
|
||||
async def get_tools_for_user(
|
||||
user_id: int, *, conversation_type: str = "chat"
|
||||
) -> list[dict]:
|
||||
"""Build the tool schema list for a user, scoped by conversation type.
|
||||
|
||||
- 'chat': all tools except those marked journal-only.
|
||||
- 'journal': all tools except set_rag_scope (scope is implicit in journal).
|
||||
"""
|
||||
tools: list[dict] = []
|
||||
for td in _REGISTRY.values():
|
||||
if td.requires and not await _check_requires(user_id, td.requires):
|
||||
continue
|
||||
if conversation_type == "journal":
|
||||
if td.name == "set_rag_scope":
|
||||
continue
|
||||
else:
|
||||
if td.journal:
|
||||
continue
|
||||
tools.append(td.schema())
|
||||
logger.debug("User %d: %d tools available", user_id, len(tools))
|
||||
return tools
|
||||
|
||||
|
||||
async def get_briefing_tools(user_id: int) -> list[dict]:
|
||||
"""Return only the tool schemas marked ``briefing=True``."""
|
||||
all_tools = await get_tools_for_user(user_id)
|
||||
names = {td.name for td in _REGISTRY.values() if td.briefing}
|
||||
filtered = [t for t in all_tools if t["function"]["name"] in names]
|
||||
logger.debug(
|
||||
"Briefing tools for user %d: %d of %d selected",
|
||||
user_id, len(filtered), len(all_tools),
|
||||
"User %d / %s: %d tools available", user_id, conversation_type, len(tools)
|
||||
)
|
||||
return filtered
|
||||
return tools
|
||||
|
||||
|
||||
async def execute_tool(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Generic article-reading LLM tool.
|
||||
|
||||
The ``read_article`` tool fetches any URL and returns its main body text via
|
||||
trafilatura. URL-generic — not coupled to any feed system.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fabledassistant.services.article_fetcher import fetch_article_text
|
||||
from fabledassistant.services.tools._registry import tool
|
||||
|
||||
|
||||
@tool(
|
||||
name="read_article",
|
||||
description=(
|
||||
"Fetch the main body text of an article at a URL. Use when the user asks "
|
||||
"to read, summarize, or discuss a specific article they've linked. "
|
||||
"Returns the cleaned article text or an empty result if extraction fails."
|
||||
),
|
||||
parameters={
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The article URL to fetch.",
|
||||
},
|
||||
},
|
||||
required=["url"],
|
||||
read_only=True,
|
||||
)
|
||||
async def read_article_tool(*, user_id, arguments, **_ctx):
|
||||
url = (arguments.get("url") or "").strip()
|
||||
if not url:
|
||||
return {"success": False, "error": "url is required"}
|
||||
content = await fetch_article_text(url)
|
||||
if not content:
|
||||
return {"success": True, "type": "article", "data": {"url": url, "content": None, "note": "no content extracted"}}
|
||||
return {"success": True, "type": "article", "data": {"url": url, "content": content[:6000]}}
|
||||
@@ -0,0 +1,264 @@
|
||||
"""LLM tools for journal conversations: record_moment and search_journal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from fabledassistant.models import Note, async_session
|
||||
from fabledassistant.services.journal_search import search_journal as svc_search_journal
|
||||
from fabledassistant.services.moments import create_moment
|
||||
from fabledassistant.services.tools._registry import tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _resolve_entity_ids_by_name(
|
||||
*,
|
||||
user_id: int,
|
||||
names: list[str],
|
||||
note_type: str | None = None,
|
||||
is_task_only: bool | None = None,
|
||||
) -> list[int]:
|
||||
"""Case-insensitive title lookup → note IDs.
|
||||
|
||||
Names with no match are silently dropped (logged at debug). Used by
|
||||
record_moment to resolve user-mentioned names without forcing the
|
||||
LLM to track tool-return IDs across calls.
|
||||
"""
|
||||
if not names:
|
||||
return []
|
||||
cleaned = [n.strip() for n in names if n and n.strip()]
|
||||
if not cleaned:
|
||||
return []
|
||||
lowered = [n.lower() for n in cleaned]
|
||||
async with async_session() as session:
|
||||
stmt = select(Note.id, func.lower(Note.title).label("ltitle")).where(
|
||||
Note.user_id == user_id,
|
||||
func.lower(Note.title).in_(lowered),
|
||||
)
|
||||
if note_type is not None:
|
||||
stmt = stmt.where(Note.note_type == note_type)
|
||||
if is_task_only is True:
|
||||
stmt = stmt.where(Note.status.isnot(None))
|
||||
elif is_task_only is False:
|
||||
stmt = stmt.where(Note.status.is_(None))
|
||||
rows = (await session.execute(stmt)).all()
|
||||
# Pick first match per name; preserve input order.
|
||||
by_lower: dict[str, int] = {}
|
||||
for note_id, ltitle in rows:
|
||||
if ltitle not in by_lower:
|
||||
by_lower[ltitle] = note_id
|
||||
resolved: list[int] = []
|
||||
for low, original in zip(lowered, cleaned):
|
||||
if low in by_lower:
|
||||
resolved.append(by_lower[low])
|
||||
else:
|
||||
logger.debug(
|
||||
"record_moment: no entity match for %r (note_type=%s, task_only=%s)",
|
||||
original, note_type, is_task_only,
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
@tool(
|
||||
name="record_moment",
|
||||
description=(
|
||||
"Record a meaningful beat from the conversation as a structured Moment. "
|
||||
"Use freely (no confirmation) for anything significant the user mentions: "
|
||||
"events, encounters, decisions, observations, feelings worth remembering. "
|
||||
"Each Moment is one or two sentences distilling the beat — not a full transcript. "
|
||||
"STRONGLY PREFER the *_names parameters when linking entities — the server "
|
||||
"resolves names to IDs by lookup, so you cannot accidentally invent or "
|
||||
"re-use the wrong ID. Use *_ids only when you have an exact ID returned "
|
||||
"from another tool call in this same turn."
|
||||
),
|
||||
parameters={
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "1-2 sentence distillation of the moment in the user's voice or third-person.",
|
||||
},
|
||||
"occurred_at": {
|
||||
"type": "string",
|
||||
"description": "ISO 8601 datetime when the moment happened. Pass 'now' for the present moment; the handler resolves it.",
|
||||
},
|
||||
"raw_excerpt": {
|
||||
"type": "string",
|
||||
"description": "Optional: literal user phrase the moment summarizes. Useful for trust UI.",
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional tags (no # prefix).",
|
||||
},
|
||||
"person_names": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "PREFERRED. Names of people mentioned (e.g. ['Victoria', 'Mom']). Server resolves to existing person notes by case-insensitive title match. Names with no match are silently skipped.",
|
||||
},
|
||||
"place_names": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "PREFERRED. Names of places mentioned (e.g. ['the new ramen place']). Server resolves to existing place notes by title.",
|
||||
},
|
||||
"task_titles": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Titles of tasks this moment references. Server resolves to task IDs by title match.",
|
||||
},
|
||||
"note_titles": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Titles of notes this moment references. Server resolves to note IDs by title match.",
|
||||
},
|
||||
"person_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"description": "DISCOURAGED — use person_names instead. Only valid if you obtained the ID from a tool result in this same turn. Never invent IDs.",
|
||||
},
|
||||
"place_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"description": "DISCOURAGED — use place_names instead. Same caveat as person_ids.",
|
||||
},
|
||||
"task_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"description": "DISCOURAGED — use task_titles instead. Same caveat as person_ids.",
|
||||
},
|
||||
"note_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"description": "DISCOURAGED — use note_titles instead. Same caveat as person_ids.",
|
||||
},
|
||||
},
|
||||
required=["content"],
|
||||
read_only=False,
|
||||
journal=True,
|
||||
)
|
||||
async def record_moment_tool(*, user_id, arguments, conv_id=None, **_ctx):
|
||||
content = arguments.get("content", "").strip()
|
||||
if not content:
|
||||
return {"success": False, "error": "content is required"}
|
||||
|
||||
occurred_raw = arguments.get("occurred_at")
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
if occurred_raw and occurred_raw != "now":
|
||||
try:
|
||||
occurred_dt = datetime.datetime.fromisoformat(occurred_raw)
|
||||
if occurred_dt.tzinfo is None:
|
||||
occurred_dt = occurred_dt.replace(tzinfo=datetime.timezone.utc)
|
||||
except ValueError:
|
||||
occurred_dt = now
|
||||
else:
|
||||
occurred_dt = now
|
||||
|
||||
# Start with any explicit IDs the LLM provided.
|
||||
person_ids = list(arguments.get("person_ids") or [])
|
||||
place_ids = list(arguments.get("place_ids") or [])
|
||||
task_ids = list(arguments.get("task_ids") or [])
|
||||
note_ids = list(arguments.get("note_ids") or [])
|
||||
|
||||
# Resolve names → IDs and merge. Names are the preferred path because the
|
||||
# LLM is unreliable at tracking IDs across tool calls (see prior bug:
|
||||
# hallucinated person_ids=[1,2] referencing test-data notes).
|
||||
person_ids.extend(
|
||||
await _resolve_entity_ids_by_name(
|
||||
user_id=user_id,
|
||||
names=arguments.get("person_names") or [],
|
||||
note_type="person",
|
||||
)
|
||||
)
|
||||
place_ids.extend(
|
||||
await _resolve_entity_ids_by_name(
|
||||
user_id=user_id,
|
||||
names=arguments.get("place_names") or [],
|
||||
note_type="place",
|
||||
)
|
||||
)
|
||||
task_ids.extend(
|
||||
await _resolve_entity_ids_by_name(
|
||||
user_id=user_id,
|
||||
names=arguments.get("task_titles") or [],
|
||||
is_task_only=True,
|
||||
)
|
||||
)
|
||||
note_ids.extend(
|
||||
await _resolve_entity_ids_by_name(
|
||||
user_id=user_id,
|
||||
names=arguments.get("note_titles") or [],
|
||||
is_task_only=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Dedupe while preserving order.
|
||||
person_ids = list(dict.fromkeys(person_ids))
|
||||
place_ids = list(dict.fromkeys(place_ids))
|
||||
task_ids = list(dict.fromkeys(task_ids))
|
||||
note_ids = list(dict.fromkeys(note_ids))
|
||||
|
||||
moment = await create_moment(
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
occurred_at=occurred_dt,
|
||||
day_date=occurred_dt.date(),
|
||||
conversation_id=conv_id,
|
||||
raw_excerpt=arguments.get("raw_excerpt"),
|
||||
tags=arguments.get("tags") or [],
|
||||
person_ids=person_ids,
|
||||
place_ids=place_ids,
|
||||
task_ids=task_ids,
|
||||
note_ids=note_ids,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "moment_recorded",
|
||||
"moment": moment.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_journal",
|
||||
description=(
|
||||
"Search the user's journal: past Moments and (optionally) raw transcripts. "
|
||||
"Use to recall things mentioned in prior days. Three modes: "
|
||||
"(a) date filter only -> recent moments in that range; "
|
||||
"(b) person_id/place_id filter -> moments mentioning that entity; "
|
||||
"(c) query string -> semantic search, optionally constrained by date/entity."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "Optional semantic query."},
|
||||
"person_id": {"type": "integer", "description": "Filter to this person."},
|
||||
"place_id": {"type": "integer", "description": "Filter to this place."},
|
||||
"tag": {"type": "string", "description": "Filter to moments with this tag."},
|
||||
"date_from": {"type": "string", "description": "ISO date lower bound (inclusive)."},
|
||||
"date_to": {"type": "string", "description": "ISO date upper bound (inclusive)."},
|
||||
"include_transcripts": {
|
||||
"type": "boolean",
|
||||
"description": "If true, also return matching raw transcript excerpts when query is set. Default false.",
|
||||
},
|
||||
"limit": {"type": "integer", "description": "Max results, default 10."},
|
||||
},
|
||||
required=[],
|
||||
read_only=True,
|
||||
journal=True,
|
||||
)
|
||||
async def search_journal_tool(*, user_id, arguments, **_ctx) -> dict[str, Any]:
|
||||
df_raw = arguments.get("date_from")
|
||||
dt_raw = arguments.get("date_to")
|
||||
df = datetime.date.fromisoformat(df_raw) if df_raw else None
|
||||
dt = datetime.date.fromisoformat(dt_raw) if dt_raw else None
|
||||
results = await svc_search_journal(
|
||||
user_id=user_id,
|
||||
query=arguments.get("query"),
|
||||
person_id=arguments.get("person_id"),
|
||||
place_id=arguments.get("place_id"),
|
||||
tag=arguments.get("tag"),
|
||||
date_from=df,
|
||||
date_to=dt,
|
||||
include_transcripts=bool(arguments.get("include_transcripts", False)),
|
||||
limit=int(arguments.get("limit", 10)),
|
||||
)
|
||||
return {"success": True, "type": "journal_search_results", "count": len(results), "results": results}
|
||||
@@ -1,101 +0,0 @@
|
||||
"""RSS and article tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fabledassistant.services.tools._registry import tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
name="get_rss_items",
|
||||
description="Get recent items from the user's RSS feeds (news, blogs, Reddit, podcasts). Returns titles, URLs, and summaries of recent posts.",
|
||||
parameters={
|
||||
"limit": {"type": "integer", "description": "Number of items to return (default 15, max 50)"},
|
||||
"category": {"type": "string", "description": "Filter by feed category (e.g. 'news', 'tech'). Omit for all."},
|
||||
},
|
||||
read_only=True,
|
||||
briefing=True,
|
||||
requires="rss",
|
||||
)
|
||||
async def get_rss_items_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.services.rss import get_recent_items
|
||||
|
||||
limit = min(int(arguments.get("limit", 15)), 50)
|
||||
items = await get_recent_items(user_id, limit=limit)
|
||||
return {"data": {"items": items, "count": len(items)}}
|
||||
|
||||
|
||||
@tool(
|
||||
name="add_rss_feed",
|
||||
description="Add an RSS/Atom feed. Use when user asks to subscribe to or track a feed, blog, subreddit, or podcast.",
|
||||
parameters={
|
||||
"url": {"type": "string", "description": "The RSS/Atom feed URL to add."},
|
||||
"category": {"type": "string", "description": "Optional category label (e.g. 'news', 'tech', 'reddit'). Omit if unsure."},
|
||||
},
|
||||
required=["url"],
|
||||
requires="rss",
|
||||
)
|
||||
async def add_rss_feed_tool(*, user_id, arguments, **_ctx):
|
||||
import asyncio as _asyncio
|
||||
|
||||
from sqlalchemy import select as _select
|
||||
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.rss_feed import RssFeed
|
||||
from fabledassistant.services.rss import fetch_and_cache_feed
|
||||
|
||||
url = str(arguments.get("url", "")).strip()
|
||||
if not url:
|
||||
return {"error": "url is required"}
|
||||
category = arguments.get("category") or None
|
||||
async with _async_session() as session:
|
||||
existing = await session.execute(
|
||||
_select(RssFeed).where(RssFeed.user_id == user_id, RssFeed.url == url)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
return {"error": "Feed already added", "url": url}
|
||||
feed = RssFeed(user_id=user_id, url=url, title="", category=category)
|
||||
session.add(feed)
|
||||
await session.commit()
|
||||
await session.refresh(feed)
|
||||
feed_id = feed.id
|
||||
_asyncio.create_task(fetch_and_cache_feed(feed_id, url))
|
||||
return {"data": {"id": feed_id, "url": url, "message": "Feed added and fetching started."}}
|
||||
|
||||
|
||||
@tool(
|
||||
name="read_article",
|
||||
description=(
|
||||
"Fetch and read the full text of a web page or article from a URL. "
|
||||
"Use when the user shares a URL and wants you to read it, "
|
||||
"or to get the full content of a linked page. "
|
||||
"Do NOT use lookup for URLs — use this tool instead."
|
||||
),
|
||||
parameters={
|
||||
"url": {"type": "string", "description": "The URL to fetch and read"},
|
||||
},
|
||||
required=["url"],
|
||||
read_only=True,
|
||||
briefing=True,
|
||||
)
|
||||
async def read_article_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
|
||||
url = arguments.get("url", "").strip()
|
||||
if not url:
|
||||
return {"success": False, "error": "No URL provided"}
|
||||
content = await _fetch_full_article(url)
|
||||
if not content:
|
||||
return {"success": False, "error": f"Could not fetch article content from {url}"}
|
||||
_TOOL_CONTENT_CAP = 40_000
|
||||
truncated = len(content) > _TOOL_CONTENT_CAP
|
||||
return {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": url,
|
||||
"content": content[:_TOOL_CONTENT_CAP],
|
||||
"truncated": truncated,
|
||||
}
|
||||
@@ -26,18 +26,18 @@ logger = logging.getLogger(__name__)
|
||||
briefing=True,
|
||||
)
|
||||
async def get_weather_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.services.briefing_pipeline import _get_temp_unit
|
||||
from fabledassistant.services.weather import (
|
||||
_fetch_open_meteo,
|
||||
geocode,
|
||||
get_cached_weather,
|
||||
get_temp_unit,
|
||||
parse_forecast,
|
||||
refresh_location_cache,
|
||||
)
|
||||
|
||||
location = (arguments.get("location") or "").strip()
|
||||
num_days = max(1, min(8, int(arguments.get("days") or 1)))
|
||||
temp_unit = await _get_temp_unit(user_id)
|
||||
temp_unit = await get_temp_unit(user_id)
|
||||
|
||||
def _apply_unit(days: list[dict]) -> list[dict]:
|
||||
if temp_unit != "F":
|
||||
@@ -82,7 +82,7 @@ async def get_weather_tool(*, user_id, arguments, **_ctx):
|
||||
if stale_keys:
|
||||
try:
|
||||
from fabledassistant.services.settings import get_setting as _wx_get_setting
|
||||
raw_cfg = await _wx_get_setting(user_id, "briefing_config", "{}")
|
||||
raw_cfg = await _wx_get_setting(user_id, "journal_config", "{}")
|
||||
cfg = _wx_json.loads(raw_cfg) if isinstance(raw_cfg, str) else (raw_cfg or {})
|
||||
cfg_locs = cfg.get("locations", {}) if isinstance(cfg, dict) else {}
|
||||
for key in stale_keys:
|
||||
|
||||
@@ -70,14 +70,14 @@ async def lookup_tool(*, user_id, arguments, **_ctx):
|
||||
if search_results:
|
||||
# Sequential fetches: trafilatura/lxml is not safe to run concurrently
|
||||
# via run_in_executor — parallel calls can trip a libxml2 double-free.
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
from fabledassistant.services.article_fetcher import fetch_article_text
|
||||
|
||||
for r in search_results[:2]:
|
||||
url = r.get("url", "")
|
||||
if not url:
|
||||
continue
|
||||
try:
|
||||
content = await _fetch_full_article(url)
|
||||
content = await fetch_article_text(url)
|
||||
except Exception:
|
||||
content = None
|
||||
web_payload.append({
|
||||
|
||||
@@ -13,11 +13,13 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
# Briefing day boundary — kept in sync with the compilation slot in
|
||||
# ``briefing_scheduler.SLOTS``. The briefing day flips at this local hour
|
||||
# (not midnight) so the 00:00–04:00 local window still shows yesterday's
|
||||
# briefing until the 4am compilation generates the new one.
|
||||
BRIEFING_DAY_START_HOUR = 4
|
||||
# Day-rollover boundary for journal/day-anchored views. The "day" flips at
|
||||
# this local hour (not midnight) so the 00:00–04:00 local window still
|
||||
# shows yesterday's content until the user "starts" the new day.
|
||||
DAY_ROLLOVER_HOUR = 4
|
||||
|
||||
# Backwards-compat alias for code paths still referencing the old name.
|
||||
BRIEFING_DAY_START_HOUR = DAY_ROLLOVER_HOUR
|
||||
|
||||
|
||||
async def get_user_tz(user_id: int) -> ZoneInfo:
|
||||
@@ -35,13 +37,16 @@ async def user_today(user_id: int) -> date:
|
||||
return datetime.now(tz).date()
|
||||
|
||||
|
||||
async def user_briefing_date(user_id: int) -> date:
|
||||
"""Return the current "briefing day" in the user's local timezone.
|
||||
async def user_day_date(user_id: int) -> date:
|
||||
"""Return the current "day" in the user's local timezone.
|
||||
|
||||
The briefing day flips at ``BRIEFING_DAY_START_HOUR`` (4am local),
|
||||
aligned with the compilation slot that generates the day's briefing.
|
||||
Between 00:00 and 04:00 local this still returns *yesterday*, so the
|
||||
UI keeps showing the in-progress briefing until the new one is built.
|
||||
The day flips at ``DAY_ROLLOVER_HOUR`` (4am local) rather than midnight,
|
||||
so the 00:00–04:00 local window still returns *yesterday* — the user's
|
||||
journal stays anchored to yesterday until they cross the rollover.
|
||||
"""
|
||||
tz = await get_user_tz(user_id)
|
||||
return (datetime.now(tz) - timedelta(hours=BRIEFING_DAY_START_HOUR)).date()
|
||||
return (datetime.now(tz) - timedelta(hours=DAY_ROLLOVER_HOUR)).date()
|
||||
|
||||
|
||||
# Backwards-compat alias used by briefing-only modules being torn down in Stage B.
|
||||
user_briefing_date = user_day_date
|
||||
|
||||
@@ -39,6 +39,13 @@ def describe_weathercode(code: int) -> str:
|
||||
return _WMO_CODES.get(code, f"Unknown (code {code})")
|
||||
|
||||
|
||||
async def get_temp_unit(user_id: int) -> str:
|
||||
"""Read the user's preferred temperature unit ('C' or 'F'), default 'C'."""
|
||||
from fabledassistant.services.settings import get_setting
|
||||
raw = await get_setting(user_id, "temp_unit", "C")
|
||||
return raw if raw in ("C", "F") else "C"
|
||||
|
||||
|
||||
def parse_forecast(raw: dict) -> list[dict]:
|
||||
"""Convert Open-Meteo daily response into a clean list of day dicts."""
|
||||
daily = raw.get("daily", {})
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read_article tool tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_success():
|
||||
"""read_article returns success with content when _fetch_full_article succeeds."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.rss._fetch_full_article",
|
||||
new_callable=AsyncMock,
|
||||
return_value="Article body text here.",
|
||||
):
|
||||
result = await execute_tool(user_id=1, tool_name="read_article", arguments={"url": "https://example.com/article"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "article_content"
|
||||
assert result["url"] == "https://example.com/article"
|
||||
assert result["content"] == "Article body text here."
|
||||
assert result["truncated"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_fetch_failure():
|
||||
"""read_article returns success=False when _fetch_full_article returns None."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.rss._fetch_full_article",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
result = await execute_tool(user_id=1, tool_name="read_article", arguments={"url": "https://example.com/broken"})
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Could not fetch" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_truncates_at_40k():
|
||||
"""read_article truncates content at 40,000 chars and sets truncated=True."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
|
||||
long_content = "x" * 50_000
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.rss._fetch_full_article",
|
||||
new_callable=AsyncMock,
|
||||
return_value=long_content,
|
||||
):
|
||||
result = await execute_tool(user_id=1, tool_name="read_article", arguments={"url": "https://example.com/long"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(result["content"]) == 40_000
|
||||
assert result["truncated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_empty_url():
|
||||
"""read_article returns success=False when URL is empty."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
|
||||
result = await execute_tool(user_id=1, tool_name="read_article", arguments={"url": ""})
|
||||
assert result["success"] is False
|
||||
assert "No URL provided" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# History builder tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_history(messages: list[dict]) -> list[dict]:
|
||||
"""Replicate the fixed history builder from routes/chat.py."""
|
||||
history = []
|
||||
for msg in messages:
|
||||
if msg["role"] == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg["role"], "content": msg.get("content") or ""}
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
return history
|
||||
|
||||
|
||||
def test_history_builder_replays_tool_calls():
|
||||
"""History builder with tool_calls produces assistant entry + tool result entry."""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": "https://example.com"},
|
||||
"result": {"success": True, "content": "Article text"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "Summarize it", "tool_calls": None},
|
||||
]
|
||||
history = _build_history(messages)
|
||||
|
||||
assert len(history) == 3
|
||||
assert history[0]["role"] == "assistant"
|
||||
assert history[0]["tool_calls"][0]["function"]["name"] == "read_article"
|
||||
assert history[1]["role"] == "tool"
|
||||
assert json.loads(history[1]["content"])["success"] is True
|
||||
assert history[2]["role"] == "user"
|
||||
assert history[2]["content"] == "Summarize it"
|
||||
|
||||
|
||||
def test_history_builder_no_tool_calls_unchanged():
|
||||
"""History builder with tool_calls=None produces same output as before."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello", "tool_calls": None},
|
||||
{"role": "assistant", "content": "Hi there!", "tool_calls": None},
|
||||
]
|
||||
history = _build_history(messages)
|
||||
|
||||
assert len(history) == 2
|
||||
assert history[0] == {"role": "user", "content": "Hello"}
|
||||
assert history[1] == {"role": "assistant", "content": "Hi there!"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prepare_article_context tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_article_context_small_passthrough():
|
||||
"""Articles under CHAR_BUDGET pass through unchanged with zero LLM calls."""
|
||||
from fabledassistant.services import article_context
|
||||
|
||||
body = "A short article.\n\nWith two paragraphs."
|
||||
with patch(
|
||||
"fabledassistant.services.article_context.generate_completion",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_gen:
|
||||
out = await article_context.prepare_article_context(
|
||||
"Title", "https://example.com", body, "test-model",
|
||||
)
|
||||
|
||||
assert out == body
|
||||
mock_gen.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_article_context_large_runs_map_reduce():
|
||||
"""Articles over CHAR_BUDGET are chunked and map-reduced via the background model."""
|
||||
from fabledassistant.services import article_context
|
||||
|
||||
# CHAR_BUDGET is 48_000 — build a body well over that with paragraph breaks
|
||||
# so the chunker has natural splits to work with.
|
||||
paragraph = ("Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 40).strip()
|
||||
body = "\n\n".join([paragraph] * 30) # ~70k+ chars across 30 paragraphs
|
||||
assert len(body) > article_context.CHAR_BUDGET
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.article_context.generate_completion",
|
||||
new_callable=AsyncMock,
|
||||
return_value="Summary of this section with specific claims preserved.",
|
||||
) as mock_gen:
|
||||
out = await article_context.prepare_article_context(
|
||||
"Long Article", "https://example.com/long", body, "test-model",
|
||||
)
|
||||
|
||||
# At least one LLM call fired (the map step ran)
|
||||
assert mock_gen.await_count >= 1
|
||||
# Output carries the oversized-article header and section markers
|
||||
assert "longer than the chat window" in out
|
||||
assert "## Section 1" in out
|
||||
# Map output is much smaller than the raw body
|
||||
assert len(out) < len(body)
|
||||
|
||||
|
||||
def test_chunk_by_paragraph_respects_boundaries():
|
||||
"""Chunker splits on paragraph breaks, not mid-sentence."""
|
||||
from fabledassistant.services.article_context import _chunk_by_paragraph, CHUNK_CHARS
|
||||
|
||||
paragraphs = [f"Paragraph {i}. " + ("x" * 1000) for i in range(20)]
|
||||
body = "\n\n".join(paragraphs)
|
||||
|
||||
chunks = _chunk_by_paragraph(body)
|
||||
|
||||
# Each chunk stays under the budget
|
||||
for chunk in chunks:
|
||||
assert len(chunk) <= CHUNK_CHARS
|
||||
# Total content is preserved (modulo overlap duplication, so ≥ original)
|
||||
assert sum(len(c) for c in chunks) >= len(body) * 0.9
|
||||
|
||||
|
||||
def test_chunk_by_paragraph_handles_oversized_paragraph():
|
||||
"""A single paragraph larger than CHUNK_CHARS gets split mid-paragraph."""
|
||||
from fabledassistant.services.article_context import _chunk_by_paragraph, CHUNK_CHARS
|
||||
|
||||
body = "x" * (CHUNK_CHARS * 3) # one huge paragraph, no breaks
|
||||
chunks = _chunk_by_paragraph(body)
|
||||
|
||||
assert len(chunks) >= 3
|
||||
for chunk in chunks:
|
||||
assert len(chunk) <= CHUNK_CHARS
|
||||
@@ -1,40 +0,0 @@
|
||||
def test_conversation_has_briefing_fields():
|
||||
"""Conversation model must declare conversation_type and briefing_date."""
|
||||
from fabledassistant.models.conversation import Conversation
|
||||
cols = {c.key for c in Conversation.__table__.columns}
|
||||
assert "conversation_type" in cols
|
||||
assert "briefing_date" in cols
|
||||
|
||||
|
||||
def test_rss_models_exist():
|
||||
from fabledassistant.models.rss_feed import RssFeed, RssItem
|
||||
feed_cols = {c.key for c in RssFeed.__table__.columns}
|
||||
item_cols = {c.key for c in RssItem.__table__.columns}
|
||||
assert {"id", "user_id", "url", "title", "last_fetched_at"} <= feed_cols
|
||||
assert {"id", "feed_id", "guid", "title", "url", "published_at", "content"} <= item_cols
|
||||
|
||||
|
||||
def test_weather_cache_model_exists():
|
||||
from fabledassistant.models.weather_cache import WeatherCache
|
||||
cols = {c.key for c in WeatherCache.__table__.columns}
|
||||
assert {"id", "user_id", "location_key", "location_label", "forecast_json", "previous_json", "fetched_at"} <= cols
|
||||
|
||||
|
||||
def test_message_metadata_field_exists():
|
||||
from fabledassistant.models.conversation import Message
|
||||
|
||||
# DB column is named 'metadata'; Python attribute is 'msg_metadata'
|
||||
# (SQLAlchemy reserves 'metadata' as an attribute name on mapped classes)
|
||||
col_names = {c.name for c in Message.__table__.columns}
|
||||
assert "metadata" in col_names
|
||||
|
||||
m = Message(conversation_id=1, role="assistant", content="hi", msg_metadata={"foo": 1})
|
||||
assert m.msg_metadata == {"foo": 1}
|
||||
|
||||
|
||||
def test_rss_item_topics_field_exists():
|
||||
from fabledassistant.models.rss_feed import RssItem
|
||||
|
||||
cols = {c.key for c in RssItem.__table__.columns}
|
||||
assert "topics" in cols
|
||||
assert "classified_at" in cols
|
||||
@@ -1,39 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_profile_note_returns_body():
|
||||
"""get_profile_body() should return empty string when no profile note exists."""
|
||||
with patch("fabledassistant.services.briefing_profile.list_notes", new_callable=AsyncMock) as mock_list:
|
||||
mock_list.return_value = ([], 0)
|
||||
from fabledassistant.services.briefing_profile import get_profile_body
|
||||
body = await get_profile_body(user_id=1)
|
||||
assert body == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_message_accepts_metadata():
|
||||
"""post_message should accept an optional metadata dict and store it."""
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.id = 1
|
||||
|
||||
with patch("fabledassistant.services.briefing_conversations.async_session") as mock_session_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.get = AsyncMock(return_value=None)
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
from fabledassistant.services.briefing_conversations import post_message
|
||||
metadata = {"weather": {"location": "Berlin"}, "rss_item_ids": [1, 2]}
|
||||
await post_message(1, "assistant", "Hello", metadata=metadata)
|
||||
|
||||
# Verify Message was constructed with msg_metadata
|
||||
call_args = mock_session.add.call_args[0][0]
|
||||
assert call_args.msg_metadata == metadata
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Tests for briefing_scheduler slot gating and work-day gate."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
def _make_scheduler():
|
||||
"""Return a mock APScheduler with tracked job IDs."""
|
||||
sched = MagicMock()
|
||||
sched.get_job.return_value = None
|
||||
return sched
|
||||
|
||||
|
||||
def test_add_user_jobs_skips_disabled_slots():
|
||||
"""_add_user_jobs should not register jobs for slots set to False in config."""
|
||||
from fabledassistant.services import briefing_scheduler as bs
|
||||
|
||||
mock_sched = _make_scheduler()
|
||||
bs._scheduler = mock_sched
|
||||
bs._loop = MagicMock()
|
||||
|
||||
config = {"slots": {"compilation": True, "morning": False, "midday": False, "afternoon": False}}
|
||||
bs._add_user_jobs(user_id=1, tz="UTC", config=config)
|
||||
|
||||
added_ids = [call.kwargs.get("id") or call.args[3] for call in mock_sched.add_job.call_args_list]
|
||||
# Only compilation should be scheduled
|
||||
assert any("compilation" in jid for jid in added_ids)
|
||||
assert not any("morning" in jid for jid in added_ids)
|
||||
assert not any("midday" in jid for jid in added_ids)
|
||||
assert not any("afternoon" in jid for jid in added_ids)
|
||||
|
||||
|
||||
def test_add_user_jobs_schedules_all_when_config_missing():
|
||||
"""_add_user_jobs with no config should schedule all 4 slots (safe default)."""
|
||||
from fabledassistant.services import briefing_scheduler as bs
|
||||
|
||||
mock_sched = _make_scheduler()
|
||||
bs._scheduler = mock_sched
|
||||
bs._loop = MagicMock()
|
||||
|
||||
bs._add_user_jobs(user_id=1, tz="UTC", config=None)
|
||||
|
||||
added_ids = [
|
||||
call.kwargs.get("id") or call.args[3]
|
||||
for call in mock_sched.add_job.call_args_list
|
||||
]
|
||||
assert any("compilation" in jid for jid in added_ids)
|
||||
assert any("morning" in jid for jid in added_ids)
|
||||
assert any("midday" in jid for jid in added_ids)
|
||||
assert any("afternoon" in jid for jid in added_ids)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_slot_skips_morning_on_non_work_day():
|
||||
"""_run_slot_for_user should return early for morning slot when today is not a work day."""
|
||||
from fabledassistant.services import briefing_scheduler as bs
|
||||
|
||||
fake_profile = MagicMock()
|
||||
fake_profile.work_schedule = {"days": ["Mon", "Tue", "Wed", "Thu", "Fri"]}
|
||||
|
||||
# Patch datetime at module level (it's imported at module level in briefing_scheduler)
|
||||
# Patch get_setting and get_profile at their source modules (they're lazily imported)
|
||||
with patch("fabledassistant.services.briefing_scheduler.datetime") as mock_dt, \
|
||||
patch("fabledassistant.services.user_profile.get_profile", new_callable=AsyncMock, return_value=fake_profile), \
|
||||
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="UTC"), \
|
||||
patch("fabledassistant.services.briefing_pipeline.run_slot_injection", new_callable=AsyncMock) as mock_inject:
|
||||
|
||||
mock_now = MagicMock()
|
||||
mock_now.strftime.return_value = "Sat"
|
||||
mock_dt.now.return_value = mock_now
|
||||
|
||||
await bs._run_slot_for_user(user_id=1, slot="morning")
|
||||
mock_inject.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_slot_morning_runs_on_work_day():
|
||||
"""_run_slot_for_user should proceed for morning slot when today is a work day."""
|
||||
from fabledassistant.services import briefing_scheduler as bs
|
||||
|
||||
fake_profile = MagicMock()
|
||||
fake_profile.work_schedule = {"days": ["Mon", "Tue", "Wed", "Thu", "Fri"]}
|
||||
|
||||
with patch("fabledassistant.services.briefing_scheduler.datetime") as mock_dt, \
|
||||
patch("fabledassistant.services.user_profile.get_profile", new_callable=AsyncMock, return_value=fake_profile), \
|
||||
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="UTC"), \
|
||||
patch("fabledassistant.services.briefing_conversations.get_or_create_today_conversation", new_callable=AsyncMock) as mock_conv, \
|
||||
patch("fabledassistant.services.briefing_pipeline.run_slot_injection", new_callable=AsyncMock, return_value=("", {})) as mock_inject, \
|
||||
patch("fabledassistant.services.briefing_conversations.post_message", new_callable=AsyncMock), \
|
||||
patch("fabledassistant.services.push.send_push_notification", new_callable=AsyncMock):
|
||||
|
||||
mock_now = MagicMock()
|
||||
mock_now.strftime.return_value = "Mon"
|
||||
mock_dt.now.return_value = mock_now
|
||||
mock_conv.return_value = MagicMock(id=1)
|
||||
|
||||
await bs._run_slot_for_user(user_id=1, slot="morning")
|
||||
mock_inject.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_put_timezone_updates_scheduler():
|
||||
"""Saving user_timezone should call update_user_schedule when briefing is enabled."""
|
||||
import json
|
||||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
||||
|
||||
enabled_config = json.dumps({"enabled": True, "slots": {}, "locations": {}})
|
||||
config = json.loads(enabled_config)
|
||||
|
||||
with patch("fabledassistant.services.briefing_scheduler._add_user_jobs") as mock_add:
|
||||
update_user_schedule(1, config, tz_override="America/New_York")
|
||||
mock_add.assert_called_once()
|
||||
call_args = mock_add.call_args
|
||||
assert call_args.args[0] == 1 # user_id
|
||||
assert call_args.args[2] == config # config passed through
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Unit tests for the cosine helper inside journal_search."""
|
||||
import math
|
||||
|
||||
from fabledassistant.services.journal_search import _cosine
|
||||
|
||||
|
||||
def test_cosine_identical_vectors():
|
||||
v = [1.0, 2.0, 3.0]
|
||||
assert math.isclose(_cosine(v, v), 1.0, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_cosine_orthogonal_vectors():
|
||||
a = [1.0, 0.0]
|
||||
b = [0.0, 1.0]
|
||||
assert math.isclose(_cosine(a, b), 0.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_cosine_zero_vector_returns_zero():
|
||||
assert _cosine([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]) == 0.0
|
||||
|
||||
|
||||
def test_cosine_empty_returns_zero():
|
||||
assert _cosine([], [1.0]) == 0.0
|
||||
@@ -34,7 +34,7 @@ async def test_lookup_wikipedia_miss_searxng_fallback():
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
||||
patch("fabledassistant.config.Config") as mock_config, \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
|
||||
patch("fabledassistant.services.rss._fetch_full_article", new_callable=AsyncMock, return_value="Full article about QUIC..."):
|
||||
patch("fabledassistant.services.article_fetcher.fetch_article_text", new_callable=AsyncMock, return_value="Full article about QUIC..."):
|
||||
mock_config.searxng_enabled.return_value = True
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
||||
|
||||
@@ -59,7 +59,7 @@ async def test_lookup_parallel_both_sources():
|
||||
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \
|
||||
patch("fabledassistant.config.Config") as mock_config, \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
|
||||
patch("fabledassistant.services.rss._fetch_full_article", new_callable=AsyncMock, return_value="Current president is..."):
|
||||
patch("fabledassistant.services.article_fetcher.fetch_article_text", new_callable=AsyncMock, return_value="Current president is..."):
|
||||
mock_config.searxng_enabled.return_value = True
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "president of the united states"})
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"""Tests for news API — retention constant and endpoint formatting."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def test_rss_item_max_age_is_90_days():
|
||||
"""Retention window should be 90 days."""
|
||||
from fabledassistant.services.rss import ITEM_MAX_AGE_DAYS
|
||||
|
||||
assert ITEM_MAX_AGE_DAYS == 90
|
||||
|
||||
|
||||
def _make_mock_session(rows):
|
||||
"""Return a mock async context manager whose session.execute returns rows."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.mappings.return_value.all.return_value = rows
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=False)
|
||||
return mock_cm
|
||||
|
||||
|
||||
def test_list_news_item_serialisation():
|
||||
"""News item serialisation should truncate content to 300 chars and include reaction."""
|
||||
pub = datetime(2026, 3, 28, 8, 0, tzinfo=timezone.utc)
|
||||
item = {
|
||||
"id": 1,
|
||||
"title": "EU AI Act deadline",
|
||||
"url": "https://example.com/ai",
|
||||
"content": "x" * 500,
|
||||
"published_at": pub,
|
||||
"topics": ["tech"],
|
||||
"feed_title": "TechCrunch",
|
||||
"reaction": "up",
|
||||
}
|
||||
|
||||
result = {
|
||||
"id": item["id"],
|
||||
"title": item["title"],
|
||||
"url": item["url"],
|
||||
"snippet": (item["content"] or "")[:300],
|
||||
"published_at": item["published_at"].isoformat() if item["published_at"] else None,
|
||||
"topics": item["topics"] or [],
|
||||
"source": item["feed_title"],
|
||||
"reaction": item["reaction"],
|
||||
}
|
||||
|
||||
assert result["snippet"] == "x" * 300
|
||||
assert result["source"] == "TechCrunch"
|
||||
assert result["reaction"] == "up"
|
||||
assert result["published_at"] == pub.isoformat()
|
||||
|
||||
|
||||
|
||||
def test_build_briefing_article_context_metadata_extraction():
|
||||
"""Verify the rss_item_ids extraction logic from message metadata."""
|
||||
import json
|
||||
|
||||
# Simulates the logic in _build_briefing_article_context
|
||||
def extract_ids(msg_metadata):
|
||||
meta = msg_metadata or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
return []
|
||||
return meta.get("rss_item_ids") or []
|
||||
|
||||
assert extract_ids({}) == []
|
||||
assert extract_ids(None) == []
|
||||
assert extract_ids({"rss_item_ids": [1, 2, 3]}) == [1, 2, 3]
|
||||
assert extract_ids(json.dumps({"rss_item_ids": [4, 5]})) == [4, 5]
|
||||
assert extract_ids("not json") == []
|
||||
|
||||
|
||||
def test_list_news_null_content_serialisation():
|
||||
"""Null content should produce empty snippet without error."""
|
||||
item = {
|
||||
"id": 2,
|
||||
"title": "No content article",
|
||||
"url": "https://example.com/b",
|
||||
"content": None,
|
||||
"published_at": None,
|
||||
"topics": None,
|
||||
"feed_title": "Hacker News",
|
||||
"reaction": None,
|
||||
}
|
||||
|
||||
result = {
|
||||
"id": item["id"],
|
||||
"title": item["title"],
|
||||
"url": item["url"],
|
||||
"snippet": (item["content"] or "")[:300],
|
||||
"published_at": item["published_at"].isoformat() if item["published_at"] else None,
|
||||
"topics": item["topics"] or [],
|
||||
"source": item["feed_title"],
|
||||
"reaction": item["reaction"],
|
||||
}
|
||||
|
||||
assert result["snippet"] == ""
|
||||
assert result["topics"] == []
|
||||
assert result["published_at"] is None
|
||||
assert result["reaction"] is None
|
||||
@@ -1,132 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def test_extract_item_fields():
|
||||
"""extract_item() should pull title, link, id, summary from a feedparser entry."""
|
||||
from fabledassistant.services.rss import extract_item
|
||||
entry = MagicMock()
|
||||
entry.get = lambda k, d="": {"title": "Test Post", "link": "https://example.com/1",
|
||||
"id": "guid-1", "summary": "A summary"}.get(k, d)
|
||||
entry.published_parsed = None
|
||||
item = extract_item(entry)
|
||||
assert item["title"] == "Test Post"
|
||||
assert item["url"] == "https://example.com/1"
|
||||
assert item["guid"] == "guid-1"
|
||||
assert item["content"] == "A summary"
|
||||
|
||||
|
||||
def test_extract_item_does_not_truncate_content():
|
||||
"""extract_item() should store content without truncation."""
|
||||
from fabledassistant.services.rss import extract_item
|
||||
long_text = "x" * 100_000
|
||||
entry = MagicMock()
|
||||
entry.get = lambda k, d="": {"summary": long_text, "title": "", "link": "", "id": "g"}.get(k, d)
|
||||
entry.content = []
|
||||
entry.published_parsed = None
|
||||
item = extract_item(entry)
|
||||
assert len(item["content"]) == 100_000
|
||||
|
||||
|
||||
def test_extract_item_prefers_content_over_summary():
|
||||
"""extract_item() should prefer 'content' field over 'summary' when present."""
|
||||
from fabledassistant.services.rss import extract_item
|
||||
entry = MagicMock()
|
||||
content_obj = MagicMock()
|
||||
content_obj.value = "Full content here"
|
||||
entry.get = lambda k, d="": {
|
||||
"title": "T", "link": "http://x.com", "id": "g",
|
||||
"summary": "Short summary",
|
||||
}.get(k, d)
|
||||
entry.content = [content_obj]
|
||||
entry.published_parsed = None
|
||||
item = extract_item(entry)
|
||||
assert item["content"] == "Full content here"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classify_items_batch_returns_topic_map():
|
||||
"""classify_items_batch should return a dict mapping item_id to topic list."""
|
||||
from unittest.mock import AsyncMock
|
||||
from fabledassistant.services.rss_classifier import classify_items_batch
|
||||
|
||||
fake_response = '{"1": ["technology", "ai"], "2": ["politics"]}'
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.rss_classifier._llm_classify",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_response,
|
||||
):
|
||||
items = [
|
||||
{"id": 1, "title": "OpenAI releases GPT-5", "content": "..."},
|
||||
{"id": 2, "title": "EU passes new law", "content": "..."},
|
||||
]
|
||||
result = await classify_items_batch(items, user_include_topics=[])
|
||||
assert result[1] == ["technology", "ai"]
|
||||
assert result[2] == ["politics"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classify_items_batch_handles_llm_failure():
|
||||
"""classify_items_batch should return empty dict on LLM error."""
|
||||
from unittest.mock import AsyncMock
|
||||
from fabledassistant.services.rss_classifier import classify_items_batch
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.rss_classifier._llm_classify",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("LLM unavailable"),
|
||||
):
|
||||
items = [{"id": 5, "title": "Some news", "content": ""}]
|
||||
result = await classify_items_batch(items, user_include_topics=[])
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_score_rss_items_excludes_blacklisted_topics():
|
||||
"""Items with excluded topics should be removed."""
|
||||
from fabledassistant.services.briefing_preferences import score_and_filter_items
|
||||
|
||||
items = [
|
||||
{"id": 1, "title": "Tech news", "topics": ["technology"], "published_at": "2026-03-25T08:00:00"},
|
||||
{"id": 2, "title": "Sports score", "topics": ["sports"], "published_at": "2026-03-25T08:00:00"},
|
||||
]
|
||||
result = score_and_filter_items(
|
||||
items,
|
||||
include_topics=["technology"],
|
||||
exclude_topics=["sports"],
|
||||
topic_scores={},
|
||||
max_items=10,
|
||||
)
|
||||
ids = [r["id"] for r in result]
|
||||
assert 1 in ids
|
||||
assert 2 not in ids
|
||||
|
||||
|
||||
def test_score_rss_items_boosts_included_topics():
|
||||
"""Items matching include_topics should rank higher than neutral items."""
|
||||
from fabledassistant.services.briefing_preferences import score_and_filter_items
|
||||
|
||||
items = [
|
||||
{"id": 1, "title": "Random news", "topics": ["other"], "published_at": "2026-03-25T07:00:00"},
|
||||
{"id": 2, "title": "Tech news", "topics": ["technology"], "published_at": "2026-03-25T06:00:00"},
|
||||
]
|
||||
result = score_and_filter_items(
|
||||
items,
|
||||
include_topics=["technology"],
|
||||
exclude_topics=[],
|
||||
topic_scores={},
|
||||
max_items=10,
|
||||
)
|
||||
assert result[0]["id"] == 2
|
||||
|
||||
|
||||
def test_score_rss_items_no_preferences_returns_all():
|
||||
"""With no preferences, all items should be returned sorted by recency."""
|
||||
from fabledassistant.services.briefing_preferences import score_and_filter_items
|
||||
|
||||
items = [
|
||||
{"id": 1, "title": "A", "topics": [], "published_at": "2026-03-24T10:00:00"},
|
||||
{"id": 2, "title": "B", "topics": [], "published_at": "2026-03-25T10:00:00"},
|
||||
]
|
||||
result = score_and_filter_items(items, [], [], {}, max_items=10)
|
||||
assert result[0]["id"] == 2 # Newer first
|
||||
@@ -26,12 +26,12 @@ async def test_get_user_tz_falls_back_to_utc_on_bad_input():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_briefing_date_before_4am_returns_yesterday():
|
||||
"""00:00–03:59 local still shows yesterday's briefing day."""
|
||||
async def test_user_day_date_before_4am_returns_yesterday():
|
||||
"""00:00–03:59 local still shows yesterday's day_date."""
|
||||
from fabledassistant.services import tz as tz_mod
|
||||
|
||||
ny = ZoneInfo("America/New_York")
|
||||
# 02:00 NY on 2026-04-13 → briefing day = 2026-04-12
|
||||
# 02:00 NY on 2026-04-13 → day_date = 2026-04-12 (still pre-rollover)
|
||||
fake_now = datetime(2026, 4, 13, 2, 0, tzinfo=ny)
|
||||
|
||||
class _FakeDatetime(datetime):
|
||||
@@ -41,12 +41,12 @@ async def test_user_briefing_date_before_4am_returns_yesterday():
|
||||
|
||||
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
|
||||
patch.object(tz_mod, "datetime", _FakeDatetime):
|
||||
day = await tz_mod.user_briefing_date(1)
|
||||
day = await tz_mod.user_day_date(1)
|
||||
assert day.isoformat() == "2026-04-12"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_briefing_date_after_4am_returns_today():
|
||||
async def test_user_day_date_after_4am_returns_today():
|
||||
from fabledassistant.services import tz as tz_mod
|
||||
|
||||
ny = ZoneInfo("America/New_York")
|
||||
@@ -59,5 +59,5 @@ async def test_user_briefing_date_after_4am_returns_today():
|
||||
|
||||
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
|
||||
patch.object(tz_mod, "datetime", _FakeDatetime):
|
||||
day = await tz_mod.user_briefing_date(1)
|
||||
day = await tz_mod.user_day_date(1)
|
||||
assert day.isoformat() == "2026-04-13"
|
||||
|
||||
Reference in New Issue
Block a user