Your Fabled Assistant instance can send email notifications.
+
Your Fabled Scribe instance can send email notifications.
"""
- 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))
diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py
index 676aab0..ba8060a 100644
--- a/src/fabledassistant/services/llm.py
+++ b/src/fabledassistant/services/llm.py
@@ -632,7 +632,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}"
diff --git a/src/fabledassistant/services/notifications.py b/src/fabledassistant/services/notifications.py
index a6c4b71..2ec1cb6 100644
--- a/src/fabledassistant/services/notifications.py
+++ b/src/fabledassistant/services/notifications.py
@@ -91,7 +91,7 @@ async def notify_security_event(
If this wasn't you, change your password immediately.
- {invited_by_username} has invited you to join Fabled Assistant.
+ {invited_by_username} has invited you to join Fabled Scribe.
Click the button below to create your account.
- The Briefing is a daily conversation that summarises your day — tasks, calendar,
- weather, and news — and checks in a few times throughout the day.
-
-
- It learns from your responses over time and adapts to your schedule.
- Let's take a minute to set it up.
-
-
-
-
-
-
-
-
-
Where are you?
-
- Enter your home and work addresses. The briefing uses these to fetch weather
- for the right places. You can skip either one.
-
-
-
-
-
-
-
-
-
✓ {{ homeConfirmed }}
-
{{ homeError }}
-
-
-
-
-
-
-
-
-
✓ {{ workConfirmed }}
-
{{ workError }}
-
-
-
-
-
-
-
-
-
-
-
When do you go to the office?
-
- The 8am slot is labelled "you're at the office" on days you have marked as office days.
- Toggle any days you typically commute.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
What do you follow?
-
- Add RSS or Atom feeds and the briefing will summarise recent items each morning.
- You can add or remove feeds any time in Settings.
-
- 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.
⚠ Setting this to the same model as Chat Model will wipe the KV cache after every background task, increasing response latency.
diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py
index 70fbb86..fce073b 100644
--- a/src/fabledassistant/app.py
+++ b/src/fabledassistant/app.py
@@ -320,16 +320,6 @@ 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())
diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py
index 09740e9..87a00ba 100644
--- a/src/fabledassistant/config.py
+++ b/src/fabledassistant/config.py
@@ -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
diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py
index 2eda686..a8b9c71 100644
--- a/src/fabledassistant/models/__init__.py
+++ b/src/fabledassistant/models/__init__.py
@@ -38,11 +38,9 @@ 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,
diff --git a/src/fabledassistant/models/rss_feed.py b/src/fabledassistant/models/rss_feed.py
deleted file mode 100644
index 0df5f49..0000000
--- a/src/fabledassistant/models/rss_feed.py
+++ /dev/null
@@ -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,
- }
diff --git a/src/fabledassistant/models/rss_item_embedding.py b/src/fabledassistant/models/rss_item_embedding.py
deleted file mode 100644
index 0a1cf83..0000000
--- a/src/fabledassistant/models/rss_item_embedding.py
+++ /dev/null
@@ -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),
- )
diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py
index 6212d57..55f6c2a 100644
--- a/src/fabledassistant/routes/chat.py
+++ b/src/fabledassistant/routes/chat.py
@@ -507,81 +507,3 @@ async def delete_model_route():
return jsonify({"error": str(e)}), 500
-@chat_bp.route("/from-article/", 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
diff --git a/src/fabledassistant/services/article_context.py b/src/fabledassistant/services/article_context.py
deleted file mode 100644
index 79bd685..0000000
--- a/src/fabledassistant/services/article_context.py
+++ /dev/null
@@ -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
diff --git a/src/fabledassistant/services/article_fetcher.py b/src/fabledassistant/services/article_fetcher.py
new file mode 100644
index 0000000..5f3f717
--- /dev/null
+++ b/src/fabledassistant/services/article_fetcher.py
@@ -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
diff --git a/src/fabledassistant/services/embeddings.py b/src/fabledassistant/services/embeddings.py
index 8c59101..69643ef 100644
--- a/src/fabledassistant/services/embeddings.py
+++ b/src/fabledassistant/services/embeddings.py
@@ -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))
diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py
index 31455c4..057fe43 100644
--- a/src/fabledassistant/services/generation_task.py
+++ b/src/fabledassistant/services/generation_task.py
@@ -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",
@@ -593,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
diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py
index a5c2bd6..21238c6 100644
--- a/src/fabledassistant/services/llm.py
+++ b/src/fabledassistant/services/llm.py
@@ -623,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"])
@@ -683,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.
@@ -726,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,
@@ -780,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:
@@ -890,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
@@ -906,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)
diff --git a/src/fabledassistant/services/rss.py b/src/fabledassistant/services/rss.py
deleted file mode 100644
index 1785709..0000000
--- a/src/fabledassistant/services/rss.py
+++ /dev/null
@@ -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
diff --git a/src/fabledassistant/services/rss_classifier.py b/src/fabledassistant/services/rss_classifier.py
deleted file mode 100644
index 6ddbb9b..0000000
--- a/src/fabledassistant/services/rss_classifier.py
+++ /dev/null
@@ -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 ... blocks emitted by reasoning models (e.g. qwen3)
- raw = re.sub(r".*?", "", 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()
diff --git a/src/fabledassistant/services/rss_filtering.py b/src/fabledassistant/services/rss_filtering.py
deleted file mode 100644
index 9a142ac..0000000
--- a/src/fabledassistant/services/rss_filtering.py
+++ /dev/null
@@ -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]]
diff --git a/src/fabledassistant/services/tools/__init__.py b/src/fabledassistant/services/tools/__init__.py
index b5a32ae..6d6b4e4 100644
--- a/src/fabledassistant/services/tools/__init__.py
+++ b/src/fabledassistant/services/tools/__init__.py
@@ -8,6 +8,7 @@ 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,
@@ -15,7 +16,6 @@ from fabledassistant.services.tools import ( # noqa: F401
profile,
projects,
rag,
- rss,
tasks,
utility,
weather,
diff --git a/src/fabledassistant/services/tools/_registry.py b/src/fabledassistant/services/tools/_registry.py
index 021cca6..1c4531e 100644
--- a/src/fabledassistant/services/tools/_registry.py
+++ b/src/fabledassistant/services/tools/_registry.py
@@ -87,9 +87,6 @@ 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
diff --git a/src/fabledassistant/services/tools/article.py b/src/fabledassistant/services/tools/article.py
new file mode 100644
index 0000000..9a97f2d
--- /dev/null
+++ b/src/fabledassistant/services/tools/article.py
@@ -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]}}
diff --git a/src/fabledassistant/services/tools/rss.py b/src/fabledassistant/services/tools/rss.py
deleted file mode 100644
index 292a4be..0000000
--- a/src/fabledassistant/services/tools/rss.py
+++ /dev/null
@@ -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,
- }
diff --git a/src/fabledassistant/services/tools/web.py b/src/fabledassistant/services/tools/web.py
index 3b9c249..8a2652e 100644
--- a/src/fabledassistant/services/tools/web.py
+++ b/src/fabledassistant/services/tools/web.py
@@ -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({
diff --git a/tests/test_article_reading.py b/tests/test_article_reading.py
deleted file mode 100644
index 715013b..0000000
--- a/tests/test_article_reading.py
+++ /dev/null
@@ -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
diff --git a/tests/test_news_api.py b/tests/test_news_api.py
deleted file mode 100644
index 533fc64..0000000
--- a/tests/test_news_api.py
+++ /dev/null
@@ -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
diff --git a/tests/test_rss_service.py b/tests/test_rss_service.py
deleted file mode 100644
index b6e7f3f..0000000
--- a/tests/test_rss_service.py
+++ /dev/null
@@ -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.rss_filtering 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.rss_filtering 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.rss_filtering 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
From 297f2252a3c62f550336a2c796ae266b4e8a4f7d Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 26 Apr 2026 12:44:09 -0400
Subject: [PATCH 14/23] fix(test): repoint lookup tests to
article_fetcher.fetch_article_text
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The two test_lookup_tool.py cases were patching the now-deleted
fabledassistant.services.rss._fetch_full_article. The trafilatura URL→text
helper moved to services/article_fetcher in the RSS hard-cut.
Co-Authored-By: Claude Opus 4.7
---
tests/test_lookup_tool.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/test_lookup_tool.py b/tests/test_lookup_tool.py
index f53e73d..1d9833d 100644
--- a/tests/test_lookup_tool.py
+++ b/tests/test_lookup_tool.py
@@ -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"})
From dda62265c9c628d1fa22fcc1309c4bfab2f98cf1 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 26 Apr 2026 12:53:06 -0400
Subject: [PATCH 15/23] fix(journal): drop dangling JournalSetupWizard import
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The setup-wizard component was deleted in the RSS hard-cut (it was
briefing-config-shaped), but JournalView still imported it — breaking
the Vite build.
Removed the import + showWizard / wizardChecked / checkSetup /
onWizardDone plumbing. JournalView now mounts straight into the day view.
Co-Authored-By: Claude Opus 4.7
---
frontend/src/views/JournalView.vue | 31 +++---------------------------
1 file changed, 3 insertions(+), 28 deletions(-)
diff --git a/frontend/src/views/JournalView.vue b/frontend/src/views/JournalView.vue
index 36e2b52..205c2b4 100644
--- a/frontend/src/views/JournalView.vue
+++ b/frontend/src/views/JournalView.vue
@@ -4,11 +4,9 @@ import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import { useChatStore } from '@/stores/chat'
import ChatPanel from '@/components/ChatPanel.vue'
import WeatherCard from '@/components/WeatherCard.vue'
-import JournalSetupWizard from '@/components/JournalSetupWizard.vue'
import {
apiGet,
apiPost,
- getJournalConfig,
getJournalToday,
getJournalDay,
getJournalDays,
@@ -50,23 +48,6 @@ interface CurrentConditions {
const chatStore = useChatStore()
-// ── Setup wizard ─────────────────────────────────────────────────────────────
-const showWizard = ref(false)
-const wizardChecked = ref(false)
-
-async function checkSetup() {
- const config = await getJournalConfig()
- // Wizard offers location/feed setup. Skip if already configured.
- const hasLocations = !!(config.locations?.home || config.locations?.work)
- if (!hasLocations) showWizard.value = true
- wizardChecked.value = true
-}
-
-async function onWizardDone() {
- showWizard.value = false
- await loadAll()
-}
-
// ── Day picker + conversation state ──────────────────────────────────────────
const days = ref([])
const todayDate = ref(null)
@@ -252,20 +233,14 @@ onUnmounted(() => {
})
onMounted(async () => {
- await checkSetup()
- if (!showWizard.value) {
- await loadAll()
- currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
- }
+ await loadAll()
+ currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
})
-
-
-
-
+
Journal
From c9f2134ad415e9c1d25b0b0455e1fa46ddb9e666 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 26 Apr 2026 15:42:30 -0400
Subject: [PATCH 16/23] feat(journal): conversational LLM-generated daily prep
(replaces card)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The structured prep card was data-rich but voiceless. Replaced with an
LLM-generated conversational opener — same shape the briefing's compilation
slot had — that renders as a normal assistant chat bubble at the top of
the day's conversation.
Backend (services/journal_prep.py):
- Renamed generate_daily_prep -> gather_daily_sections (still pure data
fetching, no LLM); kept the old name as a backwards-compat alias.
- New _generate_prep_prose: hands the gathered sections to generate_completion
with a warm-conversational system prompt; returns prose. Falls back to a
plain greeting if the LLM call fails or no model is configured.
- ensure_daily_prep_message now persists the prep as role='assistant' with
the prose as content. Structured sections stay on msg_metadata for
provenance. Auto-upgrades legacy system-role preps in place on next call.
Frontend:
- Drop the structured block from JournalView.
The prep is now just the first chat bubble — picks up the existing
Illuminated Transcript styling automatically.
- Drop dayMessages / prepMessage / prepSections / asArray helpers — no
longer needed.
- ChatMessage hideMessage filter: comment refined to clarify it only
catches LEGACY system-role prep rows. Current preps are assistant-role
and render normally.
Net effect: open /journal -> first thing you see is a warm assistant bubble
that talks about your day -> input bar below to reply.
Co-Authored-By: Claude Opus 4.7
---
frontend/src/components/ChatMessage.vue | 7 +-
frontend/src/views/JournalView.vue | 90 +-------
src/fabledassistant/services/journal_prep.py | 231 +++++++++++++++++--
3 files changed, 212 insertions(+), 116 deletions(-)
diff --git a/frontend/src/components/ChatMessage.vue b/frontend/src/components/ChatMessage.vue
index 2652f94..59df77b 100644
--- a/frontend/src/components/ChatMessage.vue
+++ b/frontend/src/components/ChatMessage.vue
@@ -43,9 +43,10 @@ function formatMs(ms: number | null | undefined): string {
const metadata = computed(() => (props.message.metadata ?? {}) as Record);
-// Hide daily-prep system messages — they're rendered separately as a structured
-// card in JournalView. Without this filter they render as a flat unstyled block
-// inside the chat (no .role-system bubble styling exists).
+// 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"
);
diff --git a/frontend/src/views/JournalView.vue b/frontend/src/views/JournalView.vue
index 205c2b4..551ecfe 100644
--- a/frontend/src/views/JournalView.vue
+++ b/frontend/src/views/JournalView.vue
@@ -13,7 +13,6 @@ import {
triggerJournalPrep,
listEvents,
type EventEntry,
- type JournalMessage,
} from '@/api/client'
interface WeatherDay {
@@ -53,21 +52,8 @@ const days = ref([])
const todayDate = ref(null)
const selectedDay = ref(null)
const dayConvId = ref(null)
-const dayMessages = ref([])
const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value)
-// ── Daily prep card ──────────────────────────────────────────────────────────
-const prepMessage = computed(() => {
- return dayMessages.value.find(
- (m) => m.role === 'system' && (m.metadata as { kind?: string } | null)?.kind === 'daily_prep',
- ) ?? null
-})
-const prepSections = computed>(() => {
- const meta = prepMessage.value?.metadata as { sections?: Record } | null
- return meta?.sections ?? {}
-})
-function asArray(v: unknown): unknown[] { return Array.isArray(v) ? v : [] }
-
// ── Weather panel ────────────────────────────────────────────────────────────
const weatherData = ref([])
const selectedWeatherIdx = ref(0)
@@ -153,7 +139,6 @@ async function loadEvents() {
// ── Day load + switch ────────────────────────────────────────────────────────
async function loadDay(iso: string) {
const payload = iso === todayDate.value ? await getJournalToday() : await getJournalDay(iso)
- dayMessages.value = payload.messages
if (payload.conversation) {
dayConvId.value = payload.conversation.id
await chatStore.fetchConversation(payload.conversation.id)
@@ -167,7 +152,6 @@ async function loadAll() {
const today = await getJournalToday()
todayDate.value = today.day_date
selectedDay.value = today.day_date
- dayMessages.value = today.messages
if (today.conversation) {
dayConvId.value = today.conversation.id
await chatStore.fetchConversation(today.conversation.id)
@@ -259,55 +243,8 @@ onMounted(async () => {
-
+
-
-
Today
-
-
-
Tasks
-
-
- {{ t.title }} · due {{ t.due_date }}
-
-
-
-
-
-
Calendar
-
-
- {{ e.title }} — {{ e.start_dt }}
-
-
-
-
-
-
Active projects
-
-
{{ p.title }}
-
-
-
-
-
Recent moments
-
-
- {{ m.day_date }} — {{ m.content }}
-
-
-
-
-
-
Open threads
-
-
- {{ m.day_date }} — {{ m.content }}
-
-
-
-
-
{
.journal-chat-panel { flex: 1; min-height: 0; }
-.daily-prep {
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- padding: 1rem 1.25rem;
- margin: 1rem 1rem 0.5rem;
- background: var(--color-bg-card);
- flex-shrink: 0;
-}
-.daily-prep > header h2 {
- font-family: 'Fraunces', Georgia, serif;
- font-size: 1.1rem;
- margin: 0 0 0.5rem 0;
-}
-.prep-section { margin-top: 0.75rem; }
-.prep-section h3 {
- font-size: 0.72rem;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--color-text-muted);
- margin: 0 0 0.25rem 0;
- font-weight: 600;
-}
-.prep-section ul { margin: 0; padding-left: 1.1rem; font-size: 0.88rem; color: var(--color-text); }
-.prep-section li { margin-bottom: 0.15rem; }
-
/* ── Right column ─────────────────────────────────────────────────────────── */
.journal-right {
grid-column: 2;
diff --git a/src/fabledassistant/services/journal_prep.py b/src/fabledassistant/services/journal_prep.py
index b919483..28b8c54 100644
--- a/src/fabledassistant/services/journal_prep.py
+++ b/src/fabledassistant/services/journal_prep.py
@@ -1,17 +1,23 @@
"""Daily prep generator for the Journal.
Runs once per day per user (scheduled, or lazy on first journal-open of a
-new day). Gathers raw data into a structured prep block that becomes the
-first message of the day's journal Conversation.
+new day). Two phases:
-The output shape lives on Message.msg_metadata as:
- { kind: 'daily_prep', sections: { tasks, events, weather,
- projects, recent_moments, open_threads } }
+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 warm conversational
+ opener — flowing prose, not a card. The 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.
-Deliberately deterministic — no LLM call here. The journal LLM weaves
-narrative into the conversation later, when the user actually starts
-talking. Generating prose at scheduled-prep time would burn model time
-on something the user may never read.
+The structured data is preserved on ``Message.msg_metadata.sections`` for
+provenance and future tooling, but is NOT visually surfaced as a card.
+
+Message shape:
+ role: 'assistant'
+ content:
+ msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } }
"""
from __future__ import annotations
@@ -20,23 +26,29 @@ 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 generate_daily_prep(
+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."""
+ """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:
@@ -66,7 +78,6 @@ async def generate_daily_prep(
try:
day_start = datetime.datetime.combine(day_date, datetime.time.min)
day_end = datetime.datetime.combine(day_date, datetime.time.max)
- # list_events already returns dicts.
sections["events"] = await list_events(
user_id=user_id,
date_from=day_start,
@@ -137,6 +148,156 @@ async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
]
+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 opening the user's daily journal with a warm, conversational greeting. "
+ "Weave the data they're handing you into a flowing prose welcome — like a "
+ "thoughtful friend recapping what's on the user's plate today and gently inviting "
+ "them to journal about it.\n\n"
+ "Rules:\n"
+ "- Write flowing sentences. NO markdown, NO bullet points, NO headers.\n"
+ "- Reference items that genuinely matter; skip categories with no real data.\n"
+ "- If RECENT JOURNAL MOMENTS are present, briefly acknowledge what they were doing, thinking, or feeling.\n"
+ "- If OPEN THREADS are present, gently surface one or two as questions worth revisiting.\n"
+ "- Aim for 5 to 9 sentences. Warm but brief.\n"
+ "- Close with one short open invitation to journal — examples: \"What's on your mind?\", "
+ "\"How are you starting the day?\", \"Anything you want to set down before this kicks off?\"\n"
+ "- Don't fabricate details. If a category is empty, just skip it; don't lie about what's there.\n"
+ "- Don't list things mechanically (\"You have 3 tasks today...\"). Talk like a person."
+)
+
+
+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"Good morning. {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 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=600,
+ )
+ 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,
@@ -146,8 +307,13 @@ async def ensure_daily_prep_message(
) -> Message:
"""Get or create today's journal Conversation, then ensure the prep message exists.
- With force=True, regenerate even if a prep message already exists today
- (used by the manual /api/journal/trigger-prep endpoint).
+ 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(
@@ -168,33 +334,50 @@ async def ensure_daily_prep_message(
session.add(conv)
await session.flush()
- prep_stmt = select(Message).where(
- Message.conversation_id == conv.id,
- Message.role == "system",
- )
+ # 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 existing_prep and not force:
+ # 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 generate_daily_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:
- existing_prep.msg_metadata = {"kind": "daily_prep", "sections": sections}
+ # 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="system",
- content="",
- msg_metadata={"kind": "daily_prep", "sections": sections},
+ 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
From 590a07bc1393695373712c2449594fe055a8fde7 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 26 Apr 2026 16:10:57 -0400
Subject: [PATCH 17/23] =?UTF-8?q?fix(journal):=20tighten=20prep=20prompt?=
=?UTF-8?q?=20=E2=80=94=20direct=20briefing,=20not=20flowery=20letter?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previous system prompt asked for "warm, conversational, like a friend
writing a letter" which produced flowery preludes that buried the actual
data. Rewritten to:
- Lead with practical data (tasks, events, weather) — concrete and specific
- 4-7 sentences total, tight prose, no padding
- Recent moments / open threads mentioned briefly at the END as context,
not as the lead
- Voice: "competent assistant briefing the user" not "friend writing a letter"
- Close with a short journal invitation under 8 words
Also dropped max_tokens 600 -> 400 to bias toward concision.
Co-Authored-By: Claude Opus 4.7
---
src/fabledassistant/services/journal_prep.py | 30 +++++++++++---------
1 file changed, 16 insertions(+), 14 deletions(-)
diff --git a/src/fabledassistant/services/journal_prep.py b/src/fabledassistant/services/journal_prep.py
index 28b8c54..27a0335 100644
--- a/src/fabledassistant/services/journal_prep.py
+++ b/src/fabledassistant/services/journal_prep.py
@@ -232,20 +232,22 @@ def _render_sections_for_prompt(sections: dict) -> str:
_PREP_SYSTEM_PROMPT = (
- "You are opening the user's daily journal with a warm, conversational greeting. "
- "Weave the data they're handing you into a flowing prose welcome — like a "
- "thoughtful friend recapping what's on the user's plate today and gently inviting "
- "them to journal about it.\n\n"
+ "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"
- "- Write flowing sentences. NO markdown, NO bullet points, NO headers.\n"
- "- Reference items that genuinely matter; skip categories with no real data.\n"
- "- If RECENT JOURNAL MOMENTS are present, briefly acknowledge what they were doing, thinking, or feeling.\n"
- "- If OPEN THREADS are present, gently surface one or two as questions worth revisiting.\n"
- "- Aim for 5 to 9 sentences. Warm but brief.\n"
- "- Close with one short open invitation to journal — examples: \"What's on your mind?\", "
- "\"How are you starting the day?\", \"Anything you want to set down before this kicks off?\"\n"
- "- Don't fabricate details. If a category is empty, just skip it; don't lie about what's there.\n"
- "- Don't list things mechanically (\"You have 3 tasks today...\"). Talk like a person."
+ "- 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."
)
@@ -285,7 +287,7 @@ async def _generate_prep_prose(
prose = await generate_completion(
messages=messages,
model=model,
- max_tokens=600,
+ max_tokens=400,
)
except Exception:
logger.exception("Daily prep prose generation failed for day %s", day_date)
From c5498273c36fea46a1b9553aae28dc19d7a3d041 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 26 Apr 2026 16:19:53 -0400
Subject: [PATCH 18/23] feat(mcp): replace briefing tools with journal tools
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The MCP was still calling /api/briefing/* endpoints I deleted in the
journal hard-cut. Replaced the briefing tool surface with a journal
equivalent so external MCP clients can inspect and control the journal
the same way they could the briefing.
Changes:
- New fable_mcp/tools/journal.py — helpers for /api/journal/* endpoints
- Delete fable_mcp/tools/briefing.py — RSS endpoints are gone too
- server.py: drop fable_list_rss_feeds, fable_add_rss_feed, fable_remove_rss_feed,
fable_list_briefings, fable_get_today_briefing, fable_get_briefing_messages,
fable_trigger_briefing, fable_reset_today_briefing
- server.py: add fable_get_today_journal, fable_get_journal_day,
fable_list_journal_days, fable_trigger_journal_prep, fable_get_journal_config,
fable_list_moments
- server.py: fable_get_conversation now points at journal.get_conversation
(same /api/chat/conversations/{id} endpoint, just lives under journal helpers)
- Update _INSTRUCTIONS to describe the journal model (replacing the RSS/briefing
section) — explains the daily prep, moments, day payloads
- Update top-line docstring: "Fable Assistant" → "Fable Scribe"
Co-Authored-By: Claude Opus 4.7
---
fable-mcp/fable_mcp/server.py | 262 +++++----
fable-mcp/fable_mcp/tools/briefing.py | 98 ----
fable-mcp/fable_mcp/tools/journal.py | 96 ++++
fable-mcp/uv.lock | 771 ++++++++++++++++++++++++++
4 files changed, 994 insertions(+), 233 deletions(-)
delete mode 100644 fable-mcp/fable_mcp/tools/briefing.py
create mode 100644 fable-mcp/fable_mcp/tools/journal.py
create mode 100644 fable-mcp/uv.lock
diff --git a/fable-mcp/fable_mcp/server.py b/fable-mcp/fable_mcp/server.py
index b7c0ff7..2b4d3a1 100644
--- a/fable-mcp/fable_mcp/server.py
+++ b/fable-mcp/fable_mcp/server.py
@@ -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,
)
diff --git a/fable-mcp/fable_mcp/tools/briefing.py b/fable-mcp/fable_mcp/tools/briefing.py
deleted file mode 100644
index f8d872a..0000000
--- a/fable-mcp/fable_mcp/tools/briefing.py
+++ /dev/null
@@ -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}")
diff --git a/fable-mcp/fable_mcp/tools/journal.py b/fable-mcp/fable_mcp/tools/journal.py
new file mode 100644
index 0000000..32b3544
--- /dev/null
+++ b/fable-mcp/fable_mcp/tools/journal.py
@@ -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}")
diff --git a/fable-mcp/uv.lock b/fable-mcp/uv.lock
new file mode 100644
index 0000000..3b2a4f9
--- /dev/null
+++ b/fable-mcp/uv.lock
@@ -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" },
+]
From b728acd8411d6419843278c05d5a36bf911695e2 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 26 Apr 2026 17:16:33 -0400
Subject: [PATCH 19/23] fix(journal): name-based entity resolution + tighter
calibration + anti-repetition
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three real bugs surfaced from inspecting today's journal turns:
1. record_moment was getting fed hallucinated person_ids (the LLM passed
[1, 2] instead of the IDs save_person had just returned). Result: the
moment was linked to two random old test-data notes ("test task 2",
"Tell a joke"), not the people the user actually mentioned.
2. The calibration rule "ask before save_person" was being silently
ignored — model just called save_person on first mention of Victoria
and Mother without asking the user.
3. The model produced a verbatim-identical reply to its previous turn when
the user mentioned "overwhelmed" twice — same numbered-list of 4
options, same closing line. The "warm listener / ask gentle questions"
persona was pushing toward stock therapy-template patterns.
Fixes:
services/tools/journal.py — record_moment now accepts *_names parameters
(person_names, place_names, task_titles, note_titles). Server resolves
each name to a note ID via case-insensitive title match, scoped by
note_type or task-status. *_ids parameters still exist but are now
documented as DISCOURAGED. The LLM physically cannot invent the wrong ID
when using names — names with no match are silently dropped. Resolution
happens via _resolve_entity_ids_by_name helper.
services/journal_pipeline.py — JOURNAL_PERSONA tightened (no more
"warm/curious listener" framing that pushed toward stock comfort
patterns). JOURNAL_CALIBRATION rewritten as scannable sections with
imperative language: PEOPLE/PLACES require asking before save_person;
TASK/NOTE state changes use the confirmation flow; MOMENTS are silent
but MUST use *_names not *_ids; OTHER notes the no-set_rag_scope and
no-auto-notes invariants. Added a RESPONSE STYLE section that explicitly
forbids verbatim repetition and stock multi-option menus.
After deploy, force-regenerate today's prep via fable_trigger_journal_prep
to also pick up the tighter prep prompt from 590a07b.
Co-Authored-By: Claude Opus 4.7
---
.../services/journal_pipeline.py | 62 ++++++--
src/fabledassistant/services/tools/journal.py | 136 ++++++++++++++++--
2 files changed, 178 insertions(+), 20 deletions(-)
diff --git a/src/fabledassistant/services/journal_pipeline.py b/src/fabledassistant/services/journal_pipeline.py
index b65e4e2..e5db7f7 100644
--- a/src/fabledassistant/services/journal_pipeline.py
+++ b/src/fabledassistant/services/journal_pipeline.py
@@ -17,21 +17,61 @@ from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.user_profile import build_profile_context
JOURNAL_PERSONA = (
- "You are a warm, curious listener helping the user record their day. "
- "You are NOT a task manager. Your role is to listen, ask gentle "
- "follow-up questions when something seems significant or underspecified, "
- "and quietly maintain structure as a byproduct of conversation."
+ "You are a thoughtful journaling companion. The user is talking to you about "
+ "their day — listen, engage with what they actually said, and help them set down "
+ "what matters. You are not a customer-service bot. You are not a therapist. You "
+ "are not a task manager."
)
JOURNAL_CALIBRATION = """\
-CALIBRATION (very important):
-- When the user mentions a person you don't know, ask: "Is someone I should remember?" Confirm before calling update_person.
-- When a person reference is ambiguous (multiple matches), ask which one. Never guess.
-- When the user says something action-implying (e.g., "I finished X"), ASK before calling update_task. Silent updates feel derailing.
-- Use the existing tool's `confirmed=false` -> user-confirms -> `confirmed=true` pattern. The frontend renders the inline confirm UI automatically.
-- record_moment is the EXCEPTION: call it freely and silently when the user mentions a meaningful beat. Moments are cheap and user-correctable in the timeline view; gating them would kill the flow.
+CALIBRATION (read carefully — these rules are not optional):
+
+PEOPLE AND PLACES — DO NOT silently create them.
+- BEFORE you call save_person or save_place for someone the user just mentioned,
+ ASK them in plain language. Example: user says "I had coffee with Sarah." You
+ reply "Is Sarah someone I should add to your contacts? If so, who is she to you?"
+ WAIT for the user's reply, THEN call save_person.
+- If the user later confirms, only then call the save_person/save_place tool.
+- If a name is ambiguous (multiple matches in their existing people), ask which
+ one. Never guess.
+- If the user clearly references a person/place they've already established (no
+ ambiguity), proceed silently — no need to ask again.
+
+TASK / NOTE STATE CHANGES — confirmation pattern.
+- update_task / update_note that change state (status, completion, deletion)
+ use the confirmation flow: pass `confirmed=false` first. The frontend renders
+ an inline confirm UI. After the user clicks confirm, call again with
+ `confirmed=true`. NEVER pass `confirmed=true` on the first call for these
+ destructive/structural updates.
+- Pure-read tools (list_tasks, search_notes, search_journal, get_weather, etc.)
+ are free — no confirmation needed.
+
+MOMENTS — record them silently.
+- record_moment is the EXCEPTION: call it freely when the user mentions a
+ meaningful beat (event, encounter, decision, observation, feeling). No
+ confirmation. Moments are cheap and user-correctable later.
+- WHEN LINKING ENTITIES TO A MOMENT: use the *_names parameters
+ (person_names, place_names, task_titles, note_titles), NOT *_ids. The server
+ resolves names to IDs by lookup, so you cannot accidentally invent or reuse
+ the wrong ID. Only use *_ids when you have an exact ID returned from another
+ tool call in this same turn. NEVER invent IDs.
+
+OTHER:
- Do NOT call set_rag_scope. The journal scope is implicit.
-- Notes are not auto-retrieved here. If you need to reference notes, call search_notes explicitly.
+- Notes are not auto-retrieved. If you need to reference a note, call
+ search_notes explicitly.
+
+RESPONSE STYLE:
+- Vary your replies. NEVER repeat a previous reply verbatim. If the user gives
+ similar input twice (e.g., mentions feeling overwhelmed twice), respond
+ differently — pick one specific thread from the new input to dig into.
+- Avoid canned multi-option menus like "1. Show your calendar 2. List your
+ tasks 3. ...". They sound like a help-desk bot. Instead, pick a single
+ specific follow-up question or observation tied to what the user just said.
+- Acknowledge what is NEW in the user's latest message — don't restart from
+ scratch each turn.
+- Match the user's energy. Short replies for short messages; deeper engagement
+ for longer ones. Don't pad short replies into paragraphs.
"""
PHASE_GREETINGS = {
diff --git a/src/fabledassistant/services/tools/journal.py b/src/fabledassistant/services/tools/journal.py
index d675ec8..38cf416 100644
--- a/src/fabledassistant/services/tools/journal.py
+++ b/src/fabledassistant/services/tools/journal.py
@@ -5,6 +5,9 @@ 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
@@ -12,6 +15,54 @@ 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=(
@@ -19,7 +70,10 @@ logger = logging.getLogger(__name__)
"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. "
- "Link people/places/tasks/notes by ID when the user has mentioned them."
+ "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": {
@@ -39,25 +93,45 @@ logger = logging.getLogger(__name__)
"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": "Person IDs (note IDs with note_type='person') mentioned in this moment.",
+ "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": "Place IDs (note IDs with note_type='place') mentioned in this moment.",
+ "description": "DISCOURAGED — use place_names instead. Same caveat as person_ids.",
},
"task_ids": {
"type": "array",
"items": {"type": "integer"},
- "description": "Task IDs this moment references.",
+ "description": "DISCOURAGED — use task_titles instead. Same caveat as person_ids.",
},
"note_ids": {
"type": "array",
"items": {"type": "integer"},
- "description": "Note IDs this moment references.",
+ "description": "DISCOURAGED — use note_titles instead. Same caveat as person_ids.",
},
},
required=["content"],
@@ -81,6 +155,50 @@ async def record_moment_tool(*, user_id, arguments, conv_id=None, **_ctx):
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,
@@ -89,10 +207,10 @@ async def record_moment_tool(*, user_id, arguments, conv_id=None, **_ctx):
conversation_id=conv_id,
raw_excerpt=arguments.get("raw_excerpt"),
tags=arguments.get("tags") or [],
- person_ids=arguments.get("person_ids") or [],
- place_ids=arguments.get("place_ids") or [],
- task_ids=arguments.get("task_ids") or [],
- note_ids=arguments.get("note_ids") or [],
+ person_ids=person_ids,
+ place_ids=place_ids,
+ task_ids=task_ids,
+ note_ids=note_ids,
)
return {
"success": True,
From 4668c0950b88905247951a1b8aad45977cd49ffb Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 26 Apr 2026 17:48:41 -0400
Subject: [PATCH 20/23] fix(journal): minimal prep + quieter persona
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The prep was generating a multi-sentence recap of tasks/events/weather/
projects/recent moments via an LLM call. Per user direction, that's
redundant — the right-side widgets already show today's data — and the
verbosity made the journal feel chatty when the user wanted quiet.
Replaces the prep prose generator with a single phase-aware check-in
question (drawn from a static map: morning="How are you starting the
day?", midday="How's it going so far?", evening="How did the day shake
out?"). No LLM call. The structured `sections` are still gathered and
persisted on msg_metadata for provenance and possible future tooling
(e.g., search), they just don't render in the prep message.
Also pulls the journal persona way back. The prior framing pushed the
model toward stock therapy-template patterns ("I'm sorry you're feeling…"
+ numbered option lists). The new persona is "you are the user's journal —
listen, be quiet, stay out of the way." RESPONSE STYLE rules now lead the
calibration block and explicitly forbid:
- apologizing for the user's feelings
- offering to help / pitching tools
- multi-option menus
- verbatim repetition of prior replies
- padding short replies into paragraphs
Most replies should be one short sentence. Sometimes the right reply is
"Got it" + a record_moment tool call.
Co-Authored-By: Claude Opus 4.7
---
.../services/journal_pipeline.py | 45 ++--
src/fabledassistant/services/journal_prep.py | 205 ++++--------------
2 files changed, 69 insertions(+), 181 deletions(-)
diff --git a/src/fabledassistant/services/journal_pipeline.py b/src/fabledassistant/services/journal_pipeline.py
index e5db7f7..2bcf27c 100644
--- a/src/fabledassistant/services/journal_pipeline.py
+++ b/src/fabledassistant/services/journal_pipeline.py
@@ -17,21 +17,40 @@ from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.user_profile import build_profile_context
JOURNAL_PERSONA = (
- "You are a thoughtful journaling companion. The user is talking to you about "
- "their day — listen, engage with what they actually said, and help them set down "
- "what matters. You are not a customer-service bot. You are not a therapist. You "
- "are not a task manager."
+ "You are the user's journal. They are setting things down with you. Be quiet. "
+ "Listen. You are NOT a customer-service bot. You are NOT a therapist. You are "
+ "NOT a task manager. You are NOT helpful in the chatbot sense — you don't "
+ "offer to do things, you don't apologize for the user's feelings, you don't "
+ "try to reframe what they said. You are a place where words go down."
)
JOURNAL_CALIBRATION = """\
CALIBRATION (read carefully — these rules are not optional):
+RESPONSE STYLE — this is the single most important rule:
+- Most replies are ONE short sentence. A brief acknowledgement, or a single
+ specific follow-up question. Sometimes silence-equivalent: just record_moment
+ and reply with nothing more than "Got it." or a single open question.
+- NEVER apologize ("I'm sorry you're feeling…"). Don't reframe the user's
+ feelings. Don't validate. Don't reassure.
+- NEVER offer to do things ("Would you like me to…", "I can help by…",
+ "Let me…"). The user knows what tools exist. Don't pitch.
+- NEVER produce multi-option menus like "1. Show your calendar 2. List your
+ tasks 3. ...". They sound like a help-desk bot. Pick at most one specific
+ follow-up question, or none.
+- NEVER repeat a previous reply verbatim. If the user circles back on the
+ same theme, respond differently — pick a specific concrete detail from
+ the new message to react to.
+- Match the user's length. Short message → short reply. Don't pad.
+- It's OK to say nothing more than acknowledge a moment was recorded. Stay
+ out of the way.
+
PEOPLE AND PLACES — DO NOT silently create them.
- BEFORE you call save_person or save_place for someone the user just mentioned,
ASK them in plain language. Example: user says "I had coffee with Sarah." You
- reply "Is Sarah someone I should add to your contacts? If so, who is she to you?"
- WAIT for the user's reply, THEN call save_person.
-- If the user later confirms, only then call the save_person/save_place tool.
+ reply "Who's Sarah to you?" — WAIT for the user's reply, THEN call save_person.
+- If the user later confirms with relationship info, only then call the
+ save_person/save_place tool.
- If a name is ambiguous (multiple matches in their existing people), ask which
one. Never guess.
- If the user clearly references a person/place they've already established (no
@@ -60,18 +79,6 @@ OTHER:
- Do NOT call set_rag_scope. The journal scope is implicit.
- Notes are not auto-retrieved. If you need to reference a note, call
search_notes explicitly.
-
-RESPONSE STYLE:
-- Vary your replies. NEVER repeat a previous reply verbatim. If the user gives
- similar input twice (e.g., mentions feeling overwhelmed twice), respond
- differently — pick one specific thread from the new input to dig into.
-- Avoid canned multi-option menus like "1. Show your calendar 2. List your
- tasks 3. ...". They sound like a help-desk bot. Instead, pick a single
- specific follow-up question or observation tied to what the user just said.
-- Acknowledge what is NEW in the user's latest message — don't restart from
- scratch each turn.
-- Match the user's energy. Short replies for short messages; deeper engagement
- for longer ones. Don't pad short replies into paragraphs.
"""
PHASE_GREETINGS = {
diff --git a/src/fabledassistant/services/journal_prep.py b/src/fabledassistant/services/journal_prep.py
index 27a0335..388a5a3 100644
--- a/src/fabledassistant/services/journal_prep.py
+++ b/src/fabledassistant/services/journal_prep.py
@@ -1,22 +1,21 @@
"""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:
+new day).
-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 warm conversational
- opener — flowing prose, not a card. The 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 prep is a SINGLE CHECK-IN QUESTION — not a recap. The right-side
+widgets (weather, upcoming events) already surface today's data; the prep
+doesn't repeat it. Just opens the day with a plain prompt the user can
+respond to. Phase-aware (morning / midday / evening) so it matches when
+the user actually opens the journal.
-The structured data is preserved on ``Message.msg_metadata.sections`` for
-provenance and future tooling, but is NOT visually surfaced as a card.
+Structured-data gathering is preserved on ``Message.msg_metadata.sections``
+for provenance and possible future tooling (search, analysis), but the
+prep MESSAGE the user sees is just the phase greeting.
Message shape:
role: 'assistant'
- content:
+ content:
msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } }
"""
from __future__ import annotations
@@ -26,13 +25,11 @@ 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__)
@@ -148,156 +145,38 @@ async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
]
-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"Good morning. {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 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},
- ]
+def _phase_for_now(user_timezone: str) -> str:
+ """Return the time-of-day phase label for the user's local moment.
+ Mirrors journal_pipeline.determine_phase but accepts the timezone string
+ directly so this module doesn't have to import the pipeline (avoids
+ a circular dependency once the pipeline grows).
+ """
try:
- prose = await generate_completion(
- messages=messages,
- model=model,
- max_tokens=400,
- )
+ from zoneinfo import ZoneInfo
+ tz = ZoneInfo(user_timezone)
except Exception:
- logger.exception("Daily prep prose generation failed for day %s", day_date)
- return _fallback_prep_text(day_date)
+ from zoneinfo import ZoneInfo
+ tz = ZoneInfo("UTC")
+ h = datetime.datetime.now(tz).hour
+ if h < 4:
+ return "evening"
+ if h < 12:
+ return "morning"
+ if h < 18:
+ return "midday"
+ return "evening"
- 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
+
+_PHASE_PROMPTS = {
+ "morning": "How are you starting the day?",
+ "midday": "How's it going so far?",
+ "evening": "How did the day shake out?",
+}
+
+
+def _phase_prompt(phase: str) -> str:
+ return _PHASE_PROMPTS.get(phase, _PHASE_PROMPTS["morning"])
async def ensure_daily_prep_message(
@@ -357,9 +236,11 @@ async def ensure_daily_prep_message(
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
- )
+ # Prep prose is intentionally minimal — a single phase-aware check-in
+ # question. The right-side widgets surface tasks/events/weather; the
+ # prep doesn't recap. The structured `sections` are still persisted
+ # on msg_metadata for provenance and future tooling.
+ prose = _phase_prompt(_phase_for_now(user_timezone))
new_metadata = {"kind": "daily_prep", "sections": sections}
if existing_prep:
From 0ed9cbf6664079e2f9efa2715a77dd8f78ea8052 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 26 Apr 2026 18:04:34 -0400
Subject: [PATCH 21/23] fix(journal): restore prep prose; soften persona toward
chat-like
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per user clarification: previous over-rotation dropped the LLM-generated
prep prose entirely (just a phase greeting) and made the chat persona
extremely sparse ("you are a place where words go down"). User actually
wanted only the chat replies pulled back, NOT the prep dropped, and the
chat to behave largely like normal /chat — asking follow-ups and
verifying earlier details.
services/journal_prep.py — restored:
- _render_sections_for_prompt
- _PREP_SYSTEM_PROMPT (the direct, briefing-style prompt from 590a07b)
- _generate_prep_prose
- _fallback_prep_text
- ensure_daily_prep_message now calls _generate_prep_prose again
- removed _phase_for_now / _phase_prompt helpers (no longer needed)
services/journal_pipeline.py — persona rewritten:
- Old: "You are the user's journal. Be quiet. Listen. You are not helpful."
- New: "You are the user's assistant. Behave like the rest of the app's
chat: respond conversationally, ask follow-up questions, verify details
from earlier turns, use tools naturally."
- Calibration block reorganized: PEOPLE/PLACES (ask first), MOMENTS
(silent + use *_names), STATE-CHANGING TOOLS (confirmation flow),
OTHER, RESPONSE STYLE.
- RESPONSE STYLE keeps the no-apologizing / no-option-menus /
no-verbatim-repetition / match-user-length rules but drops the "be
quiet, one short sentence" framing.
Net behavior:
- Open journal → LLM-generated prep prose with today's tasks/events/weather
- Reply → assistant responds conversationally like /chat, asks follow-ups,
verifies details, uses tools
- Background: silently records moments via *_names, asks before creating
new people/places
Co-Authored-By: Claude Opus 4.7
---
.../services/journal_pipeline.py | 90 ++++----
src/fabledassistant/services/journal_prep.py | 205 ++++++++++++++----
2 files changed, 202 insertions(+), 93 deletions(-)
diff --git a/src/fabledassistant/services/journal_pipeline.py b/src/fabledassistant/services/journal_pipeline.py
index 2bcf27c..f50a9c1 100644
--- a/src/fabledassistant/services/journal_pipeline.py
+++ b/src/fabledassistant/services/journal_pipeline.py
@@ -17,68 +17,58 @@ from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.user_profile import build_profile_context
JOURNAL_PERSONA = (
- "You are the user's journal. They are setting things down with you. Be quiet. "
- "Listen. You are NOT a customer-service bot. You are NOT a therapist. You are "
- "NOT a task manager. You are NOT helpful in the chatbot sense — you don't "
- "offer to do things, you don't apologize for the user's feelings, you don't "
- "try to reframe what they said. You are a place where words go down."
+ "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 = """\
-CALIBRATION (read carefully — these rules are not optional):
+JOURNAL-SPECIFIC TOOL GUIDANCE:
-RESPONSE STYLE — this is the single most important rule:
-- Most replies are ONE short sentence. A brief acknowledgement, or a single
- specific follow-up question. Sometimes silence-equivalent: just record_moment
- and reply with nothing more than "Got it." or a single open question.
-- NEVER apologize ("I'm sorry you're feeling…"). Don't reframe the user's
- feelings. Don't validate. Don't reassure.
-- NEVER offer to do things ("Would you like me to…", "I can help by…",
- "Let me…"). The user knows what tools exist. Don't pitch.
-- NEVER produce multi-option menus like "1. Show your calendar 2. List your
- tasks 3. ...". They sound like a help-desk bot. Pick at most one specific
- follow-up question, or none.
-- NEVER repeat a previous reply verbatim. If the user circles back on the
- same theme, respond differently — pick a specific concrete detail from
- the new message to react to.
-- Match the user's length. Short message → short reply. Don't pad.
-- It's OK to say nothing more than acknowledge a moment was recorded. Stay
- out of the way.
+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.
-PEOPLE AND PLACES — DO NOT silently create them.
-- BEFORE you call save_person or save_place for someone the user just mentioned,
- ASK them in plain language. Example: user says "I had coffee with Sarah." You
- reply "Who's Sarah to you?" — WAIT for the user's reply, THEN call save_person.
-- If the user later confirms with relationship info, only then call the
- save_person/save_place tool.
-- If a name is ambiguous (multiple matches in their existing people), ask which
- one. Never guess.
-- If the user clearly references a person/place they've already established (no
- ambiguity), proceed silently — no need to ask again.
+MOMENTS — record silently.
+- Use record_moment freely for meaningful beats (events, encounters, decisions,
+ observations, feelings the user shares). No confirmation needed. Moments are
+ cheap and user-correctable later.
+- When linking entities to a moment, 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 the exact ID
+ returned from another tool call in this same turn. Never invent IDs.
-TASK / NOTE STATE CHANGES — confirmation pattern.
+STATE-CHANGING TOOLS — use the confirmation flow.
- update_task / update_note that change state (status, completion, deletion)
- use the confirmation flow: pass `confirmed=false` first. The frontend renders
- an inline confirm UI. After the user clicks confirm, call again with
- `confirmed=true`. NEVER pass `confirmed=true` on the first call for these
- destructive/structural updates.
+ 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.)
- are free — no confirmation needed.
-
-MOMENTS — record them silently.
-- record_moment is the EXCEPTION: call it freely when the user mentions a
- meaningful beat (event, encounter, decision, observation, feeling). No
- confirmation. Moments are cheap and user-correctable later.
-- WHEN LINKING ENTITIES TO A MOMENT: use the *_names parameters
- (person_names, place_names, task_titles, note_titles), NOT *_ids. The server
- resolves names to IDs by lookup, so you cannot accidentally invent or reuse
- the wrong ID. Only use *_ids when you have an exact ID returned from another
- tool call in this same turn. NEVER invent IDs.
+ don't need confirmation.
OTHER:
- Do NOT call set_rag_scope. The journal scope is implicit.
-- Notes are not auto-retrieved. If you need to reference a note, call
+- 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 = {
diff --git a/src/fabledassistant/services/journal_prep.py b/src/fabledassistant/services/journal_prep.py
index 388a5a3..ec7966a 100644
--- a/src/fabledassistant/services/journal_prep.py
+++ b/src/fabledassistant/services/journal_prep.py
@@ -1,21 +1,22 @@
"""Daily prep generator for the Journal.
Runs once per day per user (scheduled, or lazy on first journal-open of a
-new day).
+new day). Two phases:
-The prep is a SINGLE CHECK-IN QUESTION — not a recap. The right-side
-widgets (weather, upcoming events) already surface today's data; the prep
-doesn't repeat it. Just opens the day with a plain prompt the user can
-respond to. Phase-aware (morning / midday / evening) so it matches when
-the user actually opens the journal.
+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.
-Structured-data gathering is preserved on ``Message.msg_metadata.sections``
-for provenance and possible future tooling (search, analysis), but the
-prep MESSAGE the user sees is just the phase greeting.
+The structured data is preserved on ``Message.msg_metadata.sections`` for
+provenance and future tooling.
Message shape:
role: 'assistant'
- content:
+ content:
msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } }
"""
from __future__ import annotations
@@ -25,11 +26,13 @@ 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__)
@@ -145,38 +148,156 @@ async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
]
-def _phase_for_now(user_timezone: str) -> str:
- """Return the time-of-day phase label for the user's local moment.
+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},
+ ]
- Mirrors journal_pipeline.determine_phase but accepts the timezone string
- directly so this module doesn't have to import the pipeline (avoids
- a circular dependency once the pipeline grows).
- """
try:
- from zoneinfo import ZoneInfo
- tz = ZoneInfo(user_timezone)
+ prose = await generate_completion(
+ messages=messages,
+ model=model,
+ max_tokens=400,
+ )
except Exception:
- from zoneinfo import ZoneInfo
- tz = ZoneInfo("UTC")
- h = datetime.datetime.now(tz).hour
- if h < 4:
- return "evening"
- if h < 12:
- return "morning"
- if h < 18:
- return "midday"
- return "evening"
+ logger.exception("Daily prep prose generation failed for day %s", day_date)
+ return _fallback_prep_text(day_date)
-
-_PHASE_PROMPTS = {
- "morning": "How are you starting the day?",
- "midday": "How's it going so far?",
- "evening": "How did the day shake out?",
-}
-
-
-def _phase_prompt(phase: str) -> str:
- return _PHASE_PROMPTS.get(phase, _PHASE_PROMPTS["morning"])
+ 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(
@@ -236,11 +357,9 @@ async def ensure_daily_prep_message(
sections = await gather_daily_sections(
user_id=user_id, day_date=day_date, user_timezone=user_timezone
)
- # Prep prose is intentionally minimal — a single phase-aware check-in
- # question. The right-side widgets surface tasks/events/weather; the
- # prep doesn't recap. The structured `sections` are still persisted
- # on msg_metadata for provenance and future tooling.
- prose = _phase_prompt(_phase_for_now(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:
From 4faaa5246b9f3b9e3f70bba69ebd241d02f7395c Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 26 Apr 2026 21:37:29 -0400
Subject: [PATCH 22/23] fix(journal): make record_moment mandatory, not a "nice
to have"
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Inspection showed only ONE record_moment call across the entire day's
journal — and that one had hallucinated person_ids. Multiple clear beats
went uncaptured: AP installation, going to watch a show with daughter,
decompressing-with-game.
The prior calibration said "use record_moment freely for meaningful
beats" — too soft. The model treated it as optional, especially when
already in chatbot-reply mode.
Rewritten: record_moment is now framed as the model's PRIMARY JOB. The
calibration includes an explicit checklist of what counts as a beat
(event, encounter, decision, observation, plan, feeling, accomplishment)
and an explicit instruction to call record_moment FIRST, before composing
the reply. Multiple beats → multiple calls. The ONLY skip case spelled
out: purely meta-conversational messages (acknowledgements, meta-asks
about prior tool results).
Tests on a fresh conversation will tell us if this moves the needle —
today's journal is poisoned by ten prior chatbot-flavored turns that
the model is pattern-matching against in its own history.
Co-Authored-By: Claude Opus 4.7
---
.../services/journal_pipeline.py | 36 ++++++++++++++-----
1 file changed, 28 insertions(+), 8 deletions(-)
diff --git a/src/fabledassistant/services/journal_pipeline.py b/src/fabledassistant/services/journal_pipeline.py
index f50a9c1..0fa658c 100644
--- a/src/fabledassistant/services/journal_pipeline.py
+++ b/src/fabledassistant/services/journal_pipeline.py
@@ -38,14 +38,34 @@ PEOPLE / PLACES — ask before creating new entries.
- For unambiguous references to people they've already established, no need
to ask — proceed normally.
-MOMENTS — record silently.
-- Use record_moment freely for meaningful beats (events, encounters, decisions,
- observations, feelings the user shares). No confirmation needed. Moments are
- cheap and user-correctable later.
-- When linking entities to a moment, 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 the exact ID
- returned from another tool call in this same turn. Never invent IDs.
+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)
From a3071a53cb1ae86ac39adce28a70943516ca4694 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 26 Apr 2026 22:46:47 -0400
Subject: [PATCH 23/23] feat(routing): land on Journal at root; move Knowledge
to /knowledge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Routing changes:
- / now redirects to /journal (Journal becomes the home view)
- /knowledge becomes a real route pointing at KnowledgeView (was a redirect to /)
- /notes redirect target updated from / to /knowledge (the Knowledge surface,
which is where the notes-related dashboard lives)
To revert (Knowledge as home): change the redirect target on the / route
back to "/knowledge". Knowledge stays a real route either way, so the swap
is a one-line edit.
Nav: Knowledge link in AppHeader now points to /knowledge. The "active" check
simplified to route.path === "/knowledge" since / is no longer Knowledge's
home. The brand logo link stays at "/" — clicks still go "home", which is
now Journal.
Keyboard shortcuts in App.vue (Escape, g h, g t) still navigate to "/" and
correctly land on Journal via the redirect — no change needed there.
Co-Authored-By: Claude Opus 4.7
---
frontend/src/components/AppHeader.vue | 6 +++---
frontend/src/router/index.ts | 11 +++++++----
2 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue
index 839574c..7bf8791 100644
--- a/frontend/src/components/AppHeader.vue
+++ b/frontend/src/components/AppHeader.vue
@@ -16,7 +16,7 @@ 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);
@@ -74,7 +74,7 @@ router.afterEach(() => {