minizen 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- minizen/__init__.py +29 -0
- minizen/ai/__init__.py +5 -0
- minizen/ai/agent.py +74 -0
- minizen/cli/__init__.py +34 -0
- minizen/cli/commands/__init__.py +0 -0
- minizen/cli/commands/config.py +105 -0
- minizen/cli/commands/digest.py +135 -0
- minizen/cli/commands/run.py +224 -0
- minizen/cli/commands/setup.py +246 -0
- minizen/cli/state.py +15 -0
- minizen/config/__init__.py +6 -0
- minizen/config/loader.py +49 -0
- minizen/config/models.py +43 -0
- minizen/core/__init__.py +5 -0
- minizen/core/pipeline.py +45 -0
- minizen/main.py +4 -0
- minizen/providers/__init__.py +0 -0
- minizen/providers/email/__init__.py +5 -0
- minizen/providers/email/smtp.py +50 -0
- minizen/providers/email/template.py +187 -0
- minizen/providers/rss/__init__.py +5 -0
- minizen/providers/rss/miniflux.py +67 -0
- minizen-0.1.0.dist-info/METADATA +67 -0
- minizen-0.1.0.dist-info/RECORD +27 -0
- minizen-0.1.0.dist-info/WHEEL +4 -0
- minizen-0.1.0.dist-info/entry_points.txt +4 -0
- minizen-0.1.0.dist-info/licenses/LICENSE +21 -0
minizen/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""minizen — A quieter way to stay informed."""
|
|
2
|
+
|
|
3
|
+
from minizen.ai import DigestAgent, DigestResult
|
|
4
|
+
from minizen.config import (
|
|
5
|
+
AIConfig,
|
|
6
|
+
EmailConfig,
|
|
7
|
+
MinifluxConfig,
|
|
8
|
+
Settings,
|
|
9
|
+
load_settings,
|
|
10
|
+
)
|
|
11
|
+
from minizen.core import run_pipeline
|
|
12
|
+
from minizen.providers.email import EmailProvider
|
|
13
|
+
from minizen.providers.rss import Article, MinifluxProvider
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AIConfig",
|
|
19
|
+
"Article",
|
|
20
|
+
"DigestAgent",
|
|
21
|
+
"DigestResult",
|
|
22
|
+
"EmailConfig",
|
|
23
|
+
"EmailProvider",
|
|
24
|
+
"MinifluxConfig",
|
|
25
|
+
"MinifluxProvider",
|
|
26
|
+
"Settings",
|
|
27
|
+
"load_settings",
|
|
28
|
+
"run_pipeline",
|
|
29
|
+
]
|
minizen/ai/__init__.py
ADDED
minizen/ai/agent.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import cast
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
from pydantic_ai import Agent, AgentRunResult
|
|
6
|
+
|
|
7
|
+
from minizen.providers.rss.miniflux import Article
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
_SYSTEM_PROMPT = """\
|
|
12
|
+
You are a personal news curator. You receive a list of unread articles and must:
|
|
13
|
+
1. Select the top N most important and interesting articles.
|
|
14
|
+
2. Write a cohesive, well-structured Markdown digest covering those articles.
|
|
15
|
+
3. Return the digest and the IDs of the articles you selected.
|
|
16
|
+
|
|
17
|
+
Each article section must include a direct link to the original article URL.
|
|
18
|
+
Be concise. Prioritise articles with broad significance over niche topics.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DigestResult(BaseModel):
|
|
23
|
+
"""Structured output from the AI digest agent."""
|
|
24
|
+
|
|
25
|
+
markdown: str = Field(description="Markdown digest text produced by the agent.")
|
|
26
|
+
articles_used: list[int] = Field(
|
|
27
|
+
description="IDs of the articles selected for the digest."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DigestAgent:
|
|
32
|
+
"""AI-powered agent that selects and summarises articles into a Markdown digest."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, *, model: str, top_n: int) -> None:
|
|
35
|
+
"""Initialise the agent with the given model and article limit.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
model: pydantic-ai model identifier (e.g. ``anthropic:claude-haiku-4-5``).
|
|
39
|
+
top_n: Maximum number of articles to include in the digest.
|
|
40
|
+
"""
|
|
41
|
+
logger.debug("Initialising DigestAgent: model=%s, top_n=%d", model, top_n)
|
|
42
|
+
self._top_n = top_n
|
|
43
|
+
self._agent = Agent(
|
|
44
|
+
model=model,
|
|
45
|
+
output_type=DigestResult,
|
|
46
|
+
system_prompt=_SYSTEM_PROMPT,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def run(self, *, articles: list[Article]) -> DigestResult:
|
|
50
|
+
"""Select the top N articles and return a structured Markdown digest.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
articles: Full list of unread articles to choose from.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A ``DigestResult`` containing the Markdown text and the IDs of
|
|
57
|
+
articles that were included.
|
|
58
|
+
"""
|
|
59
|
+
logger.info("Running AI agent on %d article(s)", len(articles))
|
|
60
|
+
articles_text = "\n\n---\n\n".join(
|
|
61
|
+
f"ID: {a.id}\n"
|
|
62
|
+
f"Feed: {a.feed_name}\n"
|
|
63
|
+
f"Title: {a.title}\n"
|
|
64
|
+
f"URL: {a.url}\n"
|
|
65
|
+
f"Published: {a.published_at.isoformat()}\n\n"
|
|
66
|
+
f"{a.content}"
|
|
67
|
+
for a in articles
|
|
68
|
+
)
|
|
69
|
+
user_prompt = (
|
|
70
|
+
f"Please select the top {self._top_n} most important articles " # noqa: S608
|
|
71
|
+
f"from the following and write a digest:\n\n{articles_text}"
|
|
72
|
+
)
|
|
73
|
+
result = self._agent.run_sync(user_prompt)
|
|
74
|
+
return cast(AgentRunResult[DigestResult], result).output
|
minizen/cli/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from minizen.cli.commands import (
|
|
6
|
+
config as config_module,
|
|
7
|
+
digest as digest_module,
|
|
8
|
+
)
|
|
9
|
+
from minizen.cli.commands.run import run
|
|
10
|
+
from minizen.cli.commands.setup import setup
|
|
11
|
+
from minizen.cli.state import configure_logging
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="minizen",
|
|
15
|
+
help="A quieter way to stay informed.",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.callback()
|
|
21
|
+
def _callback(
|
|
22
|
+
verbose: Annotated[
|
|
23
|
+
bool,
|
|
24
|
+
typer.Option("--verbose", "-v", help="Enable debug logging."),
|
|
25
|
+
] = False,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Configure logging for the CLI session."""
|
|
28
|
+
configure_logging(verbose=verbose)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
app.command("run")(run)
|
|
32
|
+
app.command("setup")(setup)
|
|
33
|
+
app.add_typer(config_module.app, name="config")
|
|
34
|
+
app.add_typer(digest_module.app, name="digest")
|
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import tomli_w
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from minizen.config.loader import load_settings
|
|
10
|
+
|
|
11
|
+
_DEFAULT_CONFIG = Path.home() / ".config" / "minizen" / "config.toml"
|
|
12
|
+
|
|
13
|
+
_CONFIG_OPTION = Annotated[
|
|
14
|
+
Path,
|
|
15
|
+
typer.Option(help="Path to the TOML configuration file.", show_default=True),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Inspect and update configuration.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("show")
|
|
22
|
+
def show(config: _CONFIG_OPTION = _DEFAULT_CONFIG) -> None:
|
|
23
|
+
"""Display the current configuration."""
|
|
24
|
+
try:
|
|
25
|
+
with open(config, "rb") as f:
|
|
26
|
+
data = tomllib.load(f)
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
typer.echo(f"Config file not found: {config}")
|
|
29
|
+
typer.echo("Run `minizen setup` to create one.")
|
|
30
|
+
raise typer.Exit(code=1)
|
|
31
|
+
|
|
32
|
+
typer.echo(f"Config file: {config}")
|
|
33
|
+
mf = data.get("miniflux", {})
|
|
34
|
+
em = data.get("email", {})
|
|
35
|
+
ai = data.get("ai", {})
|
|
36
|
+
typer.echo(f" miniflux.url: {mf.get('url', '(unset)')}")
|
|
37
|
+
typer.echo(f" miniflux.api_key: {'(from env)' if mf.get('url') else '(unset)'}")
|
|
38
|
+
typer.echo(f" email.smtp_host: {em.get('smtp_host', '(unset)')}")
|
|
39
|
+
typer.echo(f" email.smtp_port: {em.get('smtp_port', '(unset)')}")
|
|
40
|
+
typer.echo(f" email.from_addr: {em.get('from_addr', '(unset)')}")
|
|
41
|
+
typer.echo(f" email.to_addr: {em.get('to_addr', '(unset)')}")
|
|
42
|
+
typer.echo(f" ai.model: {ai.get('model', 'anthropic:claude-haiku-4-5')}")
|
|
43
|
+
typer.echo(f" ai.top_n: {ai.get('top_n', 5)}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command("validate")
|
|
47
|
+
def validate(config: _CONFIG_OPTION = _DEFAULT_CONFIG) -> None:
|
|
48
|
+
"""Validate the configuration file and environment variables."""
|
|
49
|
+
try:
|
|
50
|
+
load_settings(config_path=config)
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
typer.echo(f"Config file not found: {config}")
|
|
53
|
+
typer.echo("Run `minizen setup` to create one.")
|
|
54
|
+
raise typer.Exit(code=1)
|
|
55
|
+
except KeyError as e:
|
|
56
|
+
typer.echo(f"Error: missing environment variable {e.args[0]}")
|
|
57
|
+
raise typer.Exit(code=1)
|
|
58
|
+
typer.echo("Configuration is valid.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_ALLOWED_KEYS = {
|
|
62
|
+
"miniflux.url",
|
|
63
|
+
"ai.model",
|
|
64
|
+
"ai.top_n",
|
|
65
|
+
"email.smtp_host",
|
|
66
|
+
"email.smtp_port",
|
|
67
|
+
"email.from_addr",
|
|
68
|
+
"email.to_addr",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("set")
|
|
73
|
+
def set_value(
|
|
74
|
+
key: Annotated[
|
|
75
|
+
str, typer.Argument(help="Dot-separated config key (e.g. ai.top_n).")
|
|
76
|
+
],
|
|
77
|
+
value: Annotated[str, typer.Argument(help="New value.")],
|
|
78
|
+
config: _CONFIG_OPTION = _DEFAULT_CONFIG,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Set a configuration value in the TOML file."""
|
|
81
|
+
if key not in _ALLOWED_KEYS:
|
|
82
|
+
allowed = ", ".join(sorted(_ALLOWED_KEYS))
|
|
83
|
+
typer.echo(f"Error: unknown config key '{key}'. Allowed: {allowed}")
|
|
84
|
+
raise typer.Exit(code=1)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
with open(config, "rb") as f:
|
|
88
|
+
data = tomllib.load(f)
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
typer.echo(f"Config file not found: {config}")
|
|
91
|
+
typer.echo("Run `minizen setup` to create one.")
|
|
92
|
+
raise typer.Exit(code=1)
|
|
93
|
+
|
|
94
|
+
section, field = key.split(".", 1)
|
|
95
|
+
if section not in data:
|
|
96
|
+
data[section] = {}
|
|
97
|
+
|
|
98
|
+
# Coerce numeric strings to int
|
|
99
|
+
coerced: str | int = value
|
|
100
|
+
with contextlib.suppress(ValueError):
|
|
101
|
+
coerced = int(value)
|
|
102
|
+
|
|
103
|
+
data[section][field] = coerced
|
|
104
|
+
config.write_bytes(tomli_w.dumps(data).encode())
|
|
105
|
+
typer.echo(f"Set {key} = {coerced!r}")
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from minizen.ai.agent import DigestAgent
|
|
8
|
+
from minizen.cli.state import configure_logging
|
|
9
|
+
from minizen.config.loader import load_settings
|
|
10
|
+
from minizen.config.models import Settings
|
|
11
|
+
from minizen.providers.email.smtp import EmailProvider
|
|
12
|
+
from minizen.providers.email.template import render_email
|
|
13
|
+
from minizen.providers.rss.miniflux import MinifluxProvider
|
|
14
|
+
|
|
15
|
+
_DEFAULT_CONFIG = Path.home() / ".config" / "minizen" / "config.toml"
|
|
16
|
+
|
|
17
|
+
_CONFIG_OPTION = Annotated[
|
|
18
|
+
Path,
|
|
19
|
+
typer.Option(help="Path to the TOML configuration file.", show_default=True),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
_VERBOSE_OPTION = Annotated[
|
|
23
|
+
bool,
|
|
24
|
+
typer.Option("--verbose", "-v", help="Enable debug logging."),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
_DRY_RUN_OPTION = Annotated[
|
|
28
|
+
bool,
|
|
29
|
+
typer.Option(
|
|
30
|
+
"--dry-run",
|
|
31
|
+
help="Fetch articles but skip LLM call and any external sends.",
|
|
32
|
+
),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(help="Preview or test the digest without marking articles as read.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load(config: Path) -> Settings:
|
|
39
|
+
"""Load settings from a TOML file, exiting with an error message on failure.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
config: Path to the TOML configuration file.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Fully loaded application settings.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
return load_settings(config_path=config)
|
|
49
|
+
except FileNotFoundError:
|
|
50
|
+
typer.echo(f"Config file not found: {config}")
|
|
51
|
+
typer.echo("Run `minizen setup` to create one.")
|
|
52
|
+
raise typer.Exit(code=1)
|
|
53
|
+
except KeyError as e:
|
|
54
|
+
typer.echo(f"Error: missing environment variable {e.args[0]}")
|
|
55
|
+
raise typer.Exit(code=1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("fetch")
|
|
59
|
+
def fetch(
|
|
60
|
+
config: _CONFIG_OPTION = _DEFAULT_CONFIG,
|
|
61
|
+
verbose: _VERBOSE_OPTION = False,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Fetch unread articles and print their titles and URLs."""
|
|
64
|
+
configure_logging(verbose=verbose)
|
|
65
|
+
settings = _load(config)
|
|
66
|
+
rss = MinifluxProvider(config=settings.miniflux)
|
|
67
|
+
articles = rss.fetch_unread()
|
|
68
|
+
if not articles:
|
|
69
|
+
typer.echo("No unread articles.")
|
|
70
|
+
return
|
|
71
|
+
typer.echo(f"{len(articles)} unread article(s):\n")
|
|
72
|
+
for article in articles:
|
|
73
|
+
typer.echo(f"[{article.feed_name}] {article.title}")
|
|
74
|
+
typer.echo(f" {article.url}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("preview")
|
|
78
|
+
def preview(
|
|
79
|
+
config: _CONFIG_OPTION = _DEFAULT_CONFIG,
|
|
80
|
+
verbose: _VERBOSE_OPTION = False,
|
|
81
|
+
dry_run: _DRY_RUN_OPTION = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Fetch and summarise articles, then print the Markdown digest."""
|
|
84
|
+
configure_logging(verbose=verbose)
|
|
85
|
+
settings = _load(config)
|
|
86
|
+
rss = MinifluxProvider(config=settings.miniflux)
|
|
87
|
+
articles = rss.fetch_unread()
|
|
88
|
+
if not articles:
|
|
89
|
+
typer.echo("No unread articles.")
|
|
90
|
+
return
|
|
91
|
+
if dry_run:
|
|
92
|
+
typer.echo(f"{len(articles)} unread article(s):\n")
|
|
93
|
+
for article in articles:
|
|
94
|
+
typer.echo(f"[{article.feed_name}] {article.title}")
|
|
95
|
+
typer.echo(f" {article.url}")
|
|
96
|
+
return
|
|
97
|
+
agent = DigestAgent(model=settings.ai.model, top_n=settings.ai.top_n)
|
|
98
|
+
result = agent.run(articles=articles)
|
|
99
|
+
typer.echo(result.markdown)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command("send-test")
|
|
103
|
+
def send_test(
|
|
104
|
+
config: _CONFIG_OPTION = _DEFAULT_CONFIG,
|
|
105
|
+
verbose: _VERBOSE_OPTION = False,
|
|
106
|
+
dry_run: _DRY_RUN_OPTION = False,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Send a test digest email without marking articles as read."""
|
|
109
|
+
configure_logging(verbose=verbose)
|
|
110
|
+
settings = _load(config)
|
|
111
|
+
rss = MinifluxProvider(config=settings.miniflux)
|
|
112
|
+
articles = rss.fetch_unread()
|
|
113
|
+
if not articles:
|
|
114
|
+
typer.echo("No unread articles.")
|
|
115
|
+
return
|
|
116
|
+
if dry_run:
|
|
117
|
+
typer.confirm(
|
|
118
|
+
"This will make a real LLM API call but will not send an email. Continue?",
|
|
119
|
+
abort=True,
|
|
120
|
+
)
|
|
121
|
+
agent = DigestAgent(model=settings.ai.model, top_n=settings.ai.top_n)
|
|
122
|
+
result = agent.run(articles=articles)
|
|
123
|
+
html, plain_text = render_email(result.markdown)
|
|
124
|
+
if dry_run:
|
|
125
|
+
typer.echo("Dry run — email not sent:\n")
|
|
126
|
+
typer.echo(plain_text)
|
|
127
|
+
return
|
|
128
|
+
today = date.today().strftime("%B %-d, %Y")
|
|
129
|
+
email = EmailProvider(config=settings.email)
|
|
130
|
+
email.send(
|
|
131
|
+
subject=f"[TEST] Your Daily Zen — {today}",
|
|
132
|
+
html=html,
|
|
133
|
+
plain_text=plain_text,
|
|
134
|
+
)
|
|
135
|
+
typer.echo("Test digest sent.")
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated, cast
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from minizen.config.loader import load_settings
|
|
7
|
+
from minizen.config.models import AIConfig, EmailConfig, MinifluxConfig, Settings
|
|
8
|
+
from minizen.core.pipeline import run_pipeline
|
|
9
|
+
|
|
10
|
+
_DEFAULT_CONFIG = Path.home() / ".config" / "minizen" / "config.toml"
|
|
11
|
+
_DEFAULT_MINIFLUX_URL = "https://reader.miniflux.app"
|
|
12
|
+
_DEFAULT_MODEL = "anthropic:claude-haiku-4-5"
|
|
13
|
+
_DEFAULT_TOP_N = 5
|
|
14
|
+
|
|
15
|
+
_DRY_RUN_OPTION = Annotated[
|
|
16
|
+
bool,
|
|
17
|
+
typer.Option(
|
|
18
|
+
"--dry-run",
|
|
19
|
+
help="Fetch articles but skip LLM call, email send, and mark-as-read.",
|
|
20
|
+
),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def apply_overrides(
|
|
25
|
+
settings: Settings,
|
|
26
|
+
*,
|
|
27
|
+
miniflux_url: str | None = None,
|
|
28
|
+
miniflux_api_key: str | None = None,
|
|
29
|
+
model: str | None = None,
|
|
30
|
+
top_n: int | None = None,
|
|
31
|
+
from_addr: str | None = None,
|
|
32
|
+
to_addr: str | None = None,
|
|
33
|
+
smtp_host: str | None = None,
|
|
34
|
+
smtp_port: int | None = None,
|
|
35
|
+
email_username: str | None = None,
|
|
36
|
+
email_password: str | None = None,
|
|
37
|
+
) -> Settings:
|
|
38
|
+
"""Return a copy of settings with any non-None flag values applied.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
settings: The base settings to override.
|
|
42
|
+
miniflux_url: Override for miniflux.url.
|
|
43
|
+
miniflux_api_key: Override for miniflux.api_key.
|
|
44
|
+
model: Override for ai.model.
|
|
45
|
+
top_n: Override for ai.top_n.
|
|
46
|
+
from_addr: Override for email.from_addr.
|
|
47
|
+
to_addr: Override for email.to_addr.
|
|
48
|
+
smtp_host: Override for email.smtp_host.
|
|
49
|
+
smtp_port: Override for email.smtp_port.
|
|
50
|
+
email_username: Override for email.username.
|
|
51
|
+
email_password: Override for email.password.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
A new Settings instance with overrides applied.
|
|
55
|
+
"""
|
|
56
|
+
miniflux_updates = {
|
|
57
|
+
k: v
|
|
58
|
+
for k, v in {"url": miniflux_url, "api_key": miniflux_api_key}.items()
|
|
59
|
+
if v is not None
|
|
60
|
+
}
|
|
61
|
+
ai_updates = {
|
|
62
|
+
k: v for k, v in {"model": model, "top_n": top_n}.items() if v is not None
|
|
63
|
+
}
|
|
64
|
+
email_updates = {
|
|
65
|
+
k: v
|
|
66
|
+
for k, v in {
|
|
67
|
+
"from_addr": from_addr,
|
|
68
|
+
"to_addr": to_addr,
|
|
69
|
+
"smtp_host": smtp_host,
|
|
70
|
+
"smtp_port": smtp_port,
|
|
71
|
+
"username": email_username,
|
|
72
|
+
"password": email_password,
|
|
73
|
+
}.items()
|
|
74
|
+
if v is not None
|
|
75
|
+
}
|
|
76
|
+
return settings.model_copy(
|
|
77
|
+
deep=True,
|
|
78
|
+
update={
|
|
79
|
+
"miniflux": settings.miniflux.model_copy(update=miniflux_updates),
|
|
80
|
+
"ai": settings.ai.model_copy(update=ai_updates),
|
|
81
|
+
"email": settings.email.model_copy(update=email_updates),
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _build_settings_from_flags(
|
|
87
|
+
*,
|
|
88
|
+
miniflux_url: str | None,
|
|
89
|
+
miniflux_api_key: str | None,
|
|
90
|
+
model: str | None,
|
|
91
|
+
top_n: int | None,
|
|
92
|
+
from_addr: str | None,
|
|
93
|
+
to_addr: str | None,
|
|
94
|
+
smtp_host: str | None,
|
|
95
|
+
smtp_port: int | None,
|
|
96
|
+
email_username: str | None,
|
|
97
|
+
email_password: str | None,
|
|
98
|
+
) -> Settings:
|
|
99
|
+
"""Build a Settings object entirely from CLI flags.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
miniflux_url: Miniflux base URL (defaults to hosted instance if None).
|
|
103
|
+
miniflux_api_key: Miniflux API key; required.
|
|
104
|
+
model: AI model identifier (defaults to claude-haiku-4-5 if None).
|
|
105
|
+
top_n: Max articles in digest (defaults to 5 if None).
|
|
106
|
+
from_addr: Sender email address; required.
|
|
107
|
+
to_addr: Recipient email address; required.
|
|
108
|
+
smtp_host: SMTP server hostname; required.
|
|
109
|
+
smtp_port: SMTP server port; required.
|
|
110
|
+
email_username: SMTP login username; required.
|
|
111
|
+
email_password: SMTP login password; required.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
A fully populated Settings instance.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
typer.Exit: With code 1 if any required field is absent.
|
|
118
|
+
"""
|
|
119
|
+
required = {
|
|
120
|
+
"--miniflux-api-key": miniflux_api_key,
|
|
121
|
+
"--from-addr": from_addr,
|
|
122
|
+
"--to-addr": to_addr,
|
|
123
|
+
"--smtp-host": smtp_host,
|
|
124
|
+
"--smtp-port": smtp_port,
|
|
125
|
+
"--email-username": email_username,
|
|
126
|
+
"--email-password": email_password,
|
|
127
|
+
}
|
|
128
|
+
missing = [flag for flag, value in required.items() if value is None]
|
|
129
|
+
if missing:
|
|
130
|
+
typer.echo("Config file not found. Required flags:")
|
|
131
|
+
for flag in missing:
|
|
132
|
+
typer.echo(f" {flag}")
|
|
133
|
+
raise typer.Exit(code=1)
|
|
134
|
+
|
|
135
|
+
return Settings(
|
|
136
|
+
miniflux=MinifluxConfig(
|
|
137
|
+
url=miniflux_url or _DEFAULT_MINIFLUX_URL,
|
|
138
|
+
api_key=cast(str, miniflux_api_key),
|
|
139
|
+
),
|
|
140
|
+
email=EmailConfig(
|
|
141
|
+
smtp_host=cast(str, smtp_host),
|
|
142
|
+
smtp_port=cast(int, smtp_port),
|
|
143
|
+
from_addr=cast(str, from_addr),
|
|
144
|
+
to_addr=cast(str, to_addr),
|
|
145
|
+
username=cast(str, email_username),
|
|
146
|
+
password=cast(str, email_password),
|
|
147
|
+
),
|
|
148
|
+
ai=AIConfig(
|
|
149
|
+
model=model or _DEFAULT_MODEL,
|
|
150
|
+
top_n=top_n or _DEFAULT_TOP_N,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def run(
|
|
156
|
+
config: Annotated[
|
|
157
|
+
Path,
|
|
158
|
+
typer.Option(help="Path to the TOML configuration file.", show_default=True),
|
|
159
|
+
] = _DEFAULT_CONFIG,
|
|
160
|
+
dry_run: _DRY_RUN_OPTION = False,
|
|
161
|
+
miniflux_url: Annotated[
|
|
162
|
+
str | None,
|
|
163
|
+
typer.Option("--miniflux-url", help="Miniflux base URL."),
|
|
164
|
+
] = None,
|
|
165
|
+
miniflux_api_key: Annotated[
|
|
166
|
+
str | None,
|
|
167
|
+
typer.Option("--miniflux-api-key", help="Miniflux API key."),
|
|
168
|
+
] = None,
|
|
169
|
+
model: Annotated[
|
|
170
|
+
str | None,
|
|
171
|
+
typer.Option("--model", help="AI model identifier."),
|
|
172
|
+
] = None,
|
|
173
|
+
top_n: Annotated[
|
|
174
|
+
int | None,
|
|
175
|
+
typer.Option("--top-n", help="Number of top articles to include."),
|
|
176
|
+
] = None,
|
|
177
|
+
from_addr: Annotated[
|
|
178
|
+
str | None,
|
|
179
|
+
typer.Option("--from-addr", help="Sender email address."),
|
|
180
|
+
] = None,
|
|
181
|
+
to_addr: Annotated[
|
|
182
|
+
str | None,
|
|
183
|
+
typer.Option("--to-addr", help="Recipient email address."),
|
|
184
|
+
] = None,
|
|
185
|
+
smtp_host: Annotated[
|
|
186
|
+
str | None,
|
|
187
|
+
typer.Option("--smtp-host", help="SMTP server hostname."),
|
|
188
|
+
] = None,
|
|
189
|
+
smtp_port: Annotated[
|
|
190
|
+
int | None,
|
|
191
|
+
typer.Option("--smtp-port", help="SMTP server port."),
|
|
192
|
+
] = None,
|
|
193
|
+
email_username: Annotated[
|
|
194
|
+
str | None,
|
|
195
|
+
typer.Option("--email-username", help="SMTP login username."),
|
|
196
|
+
] = None,
|
|
197
|
+
email_password: Annotated[
|
|
198
|
+
str | None,
|
|
199
|
+
typer.Option("--email-password", help="SMTP login password."),
|
|
200
|
+
] = None,
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Run the full digest pipeline: fetch, summarise, and email."""
|
|
203
|
+
_flag_kwargs = {
|
|
204
|
+
"miniflux_url": miniflux_url,
|
|
205
|
+
"miniflux_api_key": miniflux_api_key,
|
|
206
|
+
"model": model,
|
|
207
|
+
"top_n": top_n,
|
|
208
|
+
"from_addr": from_addr,
|
|
209
|
+
"to_addr": to_addr,
|
|
210
|
+
"smtp_host": smtp_host,
|
|
211
|
+
"smtp_port": smtp_port,
|
|
212
|
+
"email_username": email_username,
|
|
213
|
+
"email_password": email_password,
|
|
214
|
+
}
|
|
215
|
+
try:
|
|
216
|
+
settings = load_settings(config_path=config)
|
|
217
|
+
except FileNotFoundError:
|
|
218
|
+
settings = _build_settings_from_flags(**_flag_kwargs)
|
|
219
|
+
except KeyError as e:
|
|
220
|
+
typer.echo(f"Error: missing environment variable {e.args[0]}")
|
|
221
|
+
raise typer.Exit(code=1)
|
|
222
|
+
else:
|
|
223
|
+
settings = apply_overrides(settings=settings, **_flag_kwargs)
|
|
224
|
+
run_pipeline(settings=settings, dry_run=dry_run)
|