feat(email): add rich scheduled report delivery
Signed-off-by: zeus229 <69399886+zeus229@users.noreply.github.com>
This commit is contained in:
1 parent
f21aa13d90
commit
b3b1da052f
24 files changed
+491
-12
No files matched your search
@@ -65,4 +65,6 @@ ddgs>=6.0.0
|
||||
# Shadow Account HTML/PDF Report
|
||||
jinja2>=3.1.0
|
||||
matplotlib>=3.7.0
|
||||
mistune>=3.0.0
|
||||
nh3>=0.2.18
|
||||
weasyprint>=60.0
|
||||
@@ -10,7 +10,7 @@ import re
|
||||
import sys as _sys
|
||||
import time
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Literal, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -115,13 +115,16 @@ def _read_scheduled_briefing(session_id: str) -> Optional[tuple[str, str]]:
|
||||
return None
|
||||
|
||||
|
||||
async def _send_scheduled_briefing(channel: str, target: Optional[str], text: str):
|
||||
async def _send_scheduled_briefing(
|
||||
channel: str, target: Optional[str], text: str, delivery_format: Optional[str] = None
|
||||
):
|
||||
"""Deliver one briefing through the configured IM channel.
|
||||
|
||||
Args:
|
||||
channel: Channel id as configured in the channel runtime.
|
||||
target: Address within that channel, or ``None`` for its default.
|
||||
text: The briefing to deliver.
|
||||
delivery_format: Optional presentation hint for channels that support it.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the channel runtime is unavailable or has no such
|
||||
@@ -139,8 +142,9 @@ async def _send_scheduled_briefing(channel: str, target: Optional[str], text: st
|
||||
raise RuntimeError(f"channel {channel!r} is not configured")
|
||||
if not target:
|
||||
raise RuntimeError(f"channel {channel!r} has no delivery target configured")
|
||||
metadata = {"delivery_format": delivery_format} if delivery_format else {}
|
||||
return await adapter.send_with_receipt(
|
||||
OutboundMessage(channel=channel, chat_id=target, content=text)
|
||||
OutboundMessage(channel=channel, chat_id=target, content=text, metadata=metadata)
|
||||
)
|
||||
|
||||
|
||||
@@ -226,6 +230,9 @@ class CreateScheduledRunRequest(BaseModel):
|
||||
delivery_target_ref: Optional[str] = Field(
|
||||
None, description="Opaque operator-configured target ref; preferred over raw target ids"
|
||||
)
|
||||
delivery_format: Optional[Literal["html", "pdf"]] = Field(
|
||||
None, description="Email report presentation: HTML body or PDF attachment"
|
||||
)
|
||||
end_at: Optional[int] = Field(
|
||||
None, description="Epoch-ms boundary after which no further run is dispatched"
|
||||
)
|
||||
@@ -316,6 +323,7 @@ class ScheduledRunResponse(BaseModel):
|
||||
delivery_target: Optional[str] = None
|
||||
delivery_target_ref: Optional[str] = None
|
||||
delivery_target_label: Optional[str] = None
|
||||
delivery_format: Optional[str] = None
|
||||
delivery_status: str = "none"
|
||||
delivery_error: Optional[str] = None
|
||||
delivery_updated_at: Optional[int] = None
|
||||
@@ -471,6 +479,12 @@ def register_scheduled_routes(
|
||||
delivery_target = resolved_target.target
|
||||
delivery_target_label = resolved_target.label
|
||||
|
||||
if request.delivery_format is not None and delivery_channel != "email":
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="delivery_format is supported only for email delivery",
|
||||
)
|
||||
|
||||
job = ScheduledResearchJob(
|
||||
id=request.id or str(uuid.uuid4()),
|
||||
title=request.title or "",
|
||||
@@ -486,6 +500,7 @@ def register_scheduled_routes(
|
||||
delivery_target=delivery_target,
|
||||
delivery_target_ref=request.delivery_target_ref,
|
||||
delivery_target_label=delivery_target_label,
|
||||
delivery_format=request.delivery_format,
|
||||
)
|
||||
_get_scheduled_research_store().upsert(job)
|
||||
return _job_to_response(job)
|
||||
|
||||
@@ -18,6 +18,8 @@ from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from src.channels.rich_text import render_email_html
|
||||
|
||||
import logging; logger = logging.getLogger(__name__)
|
||||
from pydantic import Field
|
||||
|
||||
@@ -305,11 +307,33 @@ class EmailChannel(BaseChannel):
|
||||
fallback = "\n".join(failed_attachments)
|
||||
content = f"{content.rstrip()}\n\n{fallback}" if content.strip() else fallback
|
||||
|
||||
delivery_format = (msg.metadata or {}).get("delivery_format")
|
||||
if delivery_format not in {"html", "pdf"}:
|
||||
delivery_format = None
|
||||
|
||||
email_msg = EmailMessage()
|
||||
email_msg["From"] = self.config.from_address or self.config.smtp_username or self.config.imap_username
|
||||
email_msg["To"] = to_addr
|
||||
email_msg["Subject"] = subject
|
||||
email_msg.set_content(content)
|
||||
|
||||
generated_pdf: bytes | None = None
|
||||
if delivery_format == "pdf":
|
||||
# Keep the report out of the message body when PDF delivery is
|
||||
# requested; a render failure must not silently send it as plain text.
|
||||
notice = "Report attached as PDF."
|
||||
email_msg.set_content(notice)
|
||||
email_msg.add_alternative(render_email_html(notice), subtype="html")
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
generated_pdf = HTML(string=render_email_html(content)).write_pdf()
|
||||
except Exception:
|
||||
self.logger.exception("Failed to render required PDF attachment")
|
||||
raise
|
||||
else:
|
||||
email_msg.set_content(content)
|
||||
if delivery_format == "html":
|
||||
email_msg.add_alternative(render_email_html(content), subtype="html")
|
||||
|
||||
for data, maintype, subtype, filename in attachments:
|
||||
email_msg.add_attachment(
|
||||
@@ -318,6 +342,13 @@ class EmailChannel(BaseChannel):
|
||||
subtype=subtype,
|
||||
filename=filename,
|
||||
)
|
||||
if generated_pdf is not None:
|
||||
email_msg.add_attachment(
|
||||
generated_pdf,
|
||||
maintype="application",
|
||||
subtype="pdf",
|
||||
filename="vibe-trading-report.pdf",
|
||||
)
|
||||
|
||||
in_reply_to = self._last_message_id_by_chat.get(to_addr)
|
||||
if in_reply_to:
|
||||
|
||||
@@ -33,6 +33,7 @@ _INTERNAL = frozenset(
|
||||
"pairing",
|
||||
"qq_probe",
|
||||
"registry",
|
||||
"rich_text",
|
||||
"runtime",
|
||||
"targets",
|
||||
"token_probe",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Safe rich-text rendering for outbound email reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import nh3
|
||||
from mistune import create_markdown
|
||||
|
||||
_MARKDOWN = create_markdown(escape=True, plugins=["table", "strikethrough", "url"])
|
||||
|
||||
_ALLOWED_TAGS = {
|
||||
"p", "a", "strong", "em", "del", "code", "pre", "blockquote",
|
||||
"ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"hr", "br", "table", "thead", "tbody", "tr", "th", "td",
|
||||
}
|
||||
_ALLOWED_ATTRIBUTES: dict[str, set[str]] = {
|
||||
"a": {"href"},
|
||||
"code": {"class"},
|
||||
"ol": {"start"},
|
||||
}
|
||||
_CLEANER = nh3.Cleaner(
|
||||
tags=_ALLOWED_TAGS,
|
||||
attributes=_ALLOWED_ATTRIBUTES,
|
||||
url_schemes={"https", "http", "mailto"},
|
||||
strip_comments=True,
|
||||
link_rel="noopener noreferrer",
|
||||
)
|
||||
|
||||
_EMAIL_CSS = """
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
||||
color: #1f2937; line-height: 1.55; max-width: 860px; margin: 0 auto; padding: 24px; }
|
||||
h1, h2, h3 { color: #111827; line-height: 1.25; }
|
||||
h1 { font-size: 26px; } h2 { font-size: 21px; margin-top: 28px; }
|
||||
h3 { font-size: 17px; margin-top: 22px; }
|
||||
a { color: #2563eb; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 8px 10px; text-align: left; }
|
||||
th { background: #f3f4f6; }
|
||||
blockquote { border-left: 4px solid #d1d5db; margin-left: 0; padding-left: 14px; color: #4b5563; }
|
||||
code { background: #f3f4f6; padding: 2px 4px; border-radius: 4px; }
|
||||
pre { background: #f3f4f6; padding: 12px; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
"""
|
||||
|
||||
|
||||
def render_markdown_fragment(text: str) -> str:
|
||||
"""Render Markdown to a conservative, sanitized HTML fragment."""
|
||||
return _CLEANER.clean(_MARKDOWN(text or "")).strip()
|
||||
|
||||
|
||||
def render_email_html(text: str) -> str:
|
||||
"""Render Markdown as a standalone HTML document suitable for email/PDF."""
|
||||
body = render_markdown_fragment(text)
|
||||
return (
|
||||
'<!doctype html><html><head><meta charset="utf-8">'
|
||||
f"<style>{_EMAIL_CSS}</style></head><body>{body}</body></html>"
|
||||
)
|
||||
@@ -53,10 +53,8 @@ NowFn = Callable[[], int]
|
||||
DispatchCallback = Callable[[ScheduledResearchJob], Awaitable[Optional[str]]]
|
||||
#: session_id -> (terminal status, briefing text), or None while in flight.
|
||||
BriefingReader = Callable[[str], Optional[tuple[str, str]]]
|
||||
#: (channel, target, text) -> delivered.
|
||||
ChannelSender = Callable[
|
||||
[str, Optional[str], str], Awaitable[DeliveryReceipt | None]
|
||||
]
|
||||
#: (channel, target, text[, delivery_format]) -> delivered.
|
||||
ChannelSender = Callable[..., Awaitable[DeliveryReceipt | None]]
|
||||
|
||||
_TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
# Search by day, not by minute, so an impossible date (e.g. Feb 31) fails fast
|
||||
@@ -649,9 +647,17 @@ class ScheduledResearchExecutor:
|
||||
self._store.upsert(current, validate=False)
|
||||
|
||||
try:
|
||||
receipt = await self._channel_sender(
|
||||
job.delivery_channel, job.delivery_target, text
|
||||
)
|
||||
if job.delivery_format is None:
|
||||
receipt = await self._channel_sender(
|
||||
job.delivery_channel, job.delivery_target, text
|
||||
)
|
||||
else:
|
||||
receipt = await self._channel_sender(
|
||||
job.delivery_channel,
|
||||
job.delivery_target,
|
||||
text,
|
||||
job.delivery_format,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
|
||||
@@ -380,6 +380,7 @@ class ScheduledResearchJob:
|
||||
delivery_target: Optional[str] = None
|
||||
delivery_target_ref: Optional[str] = None
|
||||
delivery_target_label: Optional[str] = None
|
||||
delivery_format: Optional[str] = None
|
||||
delivery: DeliveryRecord = field(default_factory=DeliveryRecord)
|
||||
last_verdict: Optional[VerdictRecord] = None
|
||||
|
||||
@@ -411,6 +412,7 @@ class ScheduledResearchJob:
|
||||
"delivery_target": self.delivery_target,
|
||||
"delivery_target_ref": self.delivery_target_ref,
|
||||
"delivery_target_label": self.delivery_target_label,
|
||||
"delivery_format": self.delivery_format,
|
||||
"delivery": self.delivery.to_dict(),
|
||||
"last_verdict": self.last_verdict.to_dict() if self.last_verdict else None,
|
||||
}
|
||||
@@ -488,6 +490,9 @@ class ScheduledResearchJob:
|
||||
delivery_target = data.get("delivery_target")
|
||||
delivery_target_ref = data.get("delivery_target_ref")
|
||||
delivery_target_label = data.get("delivery_target_label")
|
||||
delivery_format = data.get("delivery_format")
|
||||
if delivery_format not in {None, "html", "pdf"}:
|
||||
raise ValueError("'delivery_format' must be 'html', 'pdf', or null")
|
||||
for name, value in (
|
||||
("delivery_channel", delivery_channel),
|
||||
("delivery_target", delivery_target),
|
||||
@@ -517,6 +522,7 @@ class ScheduledResearchJob:
|
||||
delivery_target=delivery_target,
|
||||
delivery_target_ref=delivery_target_ref,
|
||||
delivery_target_label=delivery_target_label,
|
||||
delivery_format=delivery_format,
|
||||
delivery=DeliveryRecord.from_dict(data.get("delivery")),
|
||||
last_verdict=_verdict_record_or_none(data.get("last_verdict"), job_id),
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.channels.bus.events import OutboundMessage
|
||||
from src.channels.bus.queue import MessageBus
|
||||
from src.channels.email import EmailChannel
|
||||
from src.channels.rich_text import render_email_html
|
||||
|
||||
|
||||
def _channel() -> EmailChannel:
|
||||
return EmailChannel(
|
||||
{
|
||||
"consent_granted": True,
|
||||
"smtp_host": "smtp.example.test",
|
||||
"smtp_username": "bot@example.test",
|
||||
"smtp_password": "secret",
|
||||
"from_address": "bot@example.test",
|
||||
},
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
|
||||
def test_rich_email_renderer_preserves_markdown_and_sanitizes_html():
|
||||
rendered = render_email_html(
|
||||
"# Portfolio\n\n**Return:** 1.2%\n\n| Asset | Weight |\n|---|---:|\n| ABC | 10% |\n"
|
||||
"<script>alert(1)</script>\n[jump](javascript:alert(1))"
|
||||
)
|
||||
|
||||
assert "<h1>Portfolio</h1>" in rendered
|
||||
assert "<strong>Return:</strong>" in rendered
|
||||
assert "<table>" in rendered
|
||||
assert "<script>" not in rendered
|
||||
assert "javascript:" not in rendered
|
||||
|
||||
|
||||
def test_default_delivery_preserves_plain_body(monkeypatch):
|
||||
channel = _channel()
|
||||
sent = []
|
||||
monkeypatch.setattr(channel, "_smtp_send", lambda message: sent.append(message))
|
||||
|
||||
asyncio.run(
|
||||
channel.send(
|
||||
OutboundMessage(
|
||||
channel="email",
|
||||
chat_id="reader@example.test",
|
||||
content="Legacy plain body",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert sent[0].get_content_type() == "text/plain"
|
||||
assert sent[0].get_content().strip() == "Legacy plain body"
|
||||
|
||||
|
||||
def test_per_message_html_keeps_plain_fallback(monkeypatch):
|
||||
channel = _channel()
|
||||
sent = []
|
||||
monkeypatch.setattr(channel, "_smtp_send", lambda message: sent.append(message))
|
||||
|
||||
asyncio.run(
|
||||
channel.send(
|
||||
OutboundMessage(
|
||||
channel="email",
|
||||
chat_id="reader@example.test",
|
||||
content="# Scheduled report\n\n**Return:** 1.2%",
|
||||
metadata={"delivery_format": "html"},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
message = sent[0]
|
||||
assert message.get_body(preferencelist=("plain",)).get_content().startswith("# Scheduled report")
|
||||
assert "<h1>Scheduled report</h1>" in message.get_body(preferencelist=("html",)).get_content()
|
||||
assert list(message.iter_attachments()) == []
|
||||
|
||||
|
||||
def test_per_message_pdf_keeps_report_out_of_body(monkeypatch):
|
||||
channel = _channel()
|
||||
sent = []
|
||||
monkeypatch.setattr(channel, "_smtp_send", lambda message: sent.append(message))
|
||||
|
||||
class FakeHTML:
|
||||
def __init__(self, *, string: str):
|
||||
self.string = string
|
||||
|
||||
def write_pdf(self) -> bytes:
|
||||
assert "Portfolio value: 123456" in self.string
|
||||
return b"%PDF-1.7\n%%EOF\n"
|
||||
|
||||
monkeypatch.setitem(sys.modules, "weasyprint", SimpleNamespace(HTML=FakeHTML))
|
||||
|
||||
asyncio.run(
|
||||
channel.send(
|
||||
OutboundMessage(
|
||||
channel="email",
|
||||
chat_id="reader@example.test",
|
||||
content="# Sensitive report\n\nPortfolio value: 123456",
|
||||
metadata={"delivery_format": "pdf"},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
message = sent[0]
|
||||
plain_body = message.get_body(preferencelist=("plain",)).get_content()
|
||||
html_body = message.get_body(preferencelist=("html",)).get_content()
|
||||
assert "Report attached as PDF." in plain_body
|
||||
assert "123456" not in plain_body
|
||||
assert "123456" not in html_body
|
||||
pdf_parts = [
|
||||
part for part in message.iter_attachments()
|
||||
if part.get_content_type() == "application/pdf"
|
||||
]
|
||||
assert len(pdf_parts) == 1
|
||||
assert pdf_parts[0].get_filename() == "vibe-trading-report.pdf"
|
||||
assert pdf_parts[0].get_payload(decode=True).startswith(b"%PDF")
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import api_server
|
||||
from src.api import scheduled_routes
|
||||
from src.scheduled_research.store import ScheduledResearchJobStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
store = ScheduledResearchJobStore(path=tmp_path / "scheduled_jobs.json")
|
||||
monkeypatch.setattr(scheduled_routes, "_scheduled_research_store", store)
|
||||
monkeypatch.delenv("API_AUTH_KEY", raising=False)
|
||||
monkeypatch.setattr(api_server, "_API_KEY", "")
|
||||
return TestClient(api_server.app, client=("127.0.0.1", 50000))
|
||||
|
||||
|
||||
def test_create_email_job_persists_report_format(client: TestClient):
|
||||
response = client.post(
|
||||
"/scheduled-runs",
|
||||
json={
|
||||
"id": "email-html",
|
||||
"prompt": "prepare the daily report",
|
||||
"schedule": "60000",
|
||||
"delivery_channel": "email",
|
||||
"delivery_target": "reader@example.test",
|
||||
"delivery_format": "html",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["delivery_format"] == "html"
|
||||
|
||||
listed = client.get("/scheduled-runs").json()
|
||||
assert listed[0]["delivery_format"] == "html"
|
||||
|
||||
|
||||
def test_create_rejects_report_format_for_non_email_channel(client: TestClient):
|
||||
response = client.post(
|
||||
"/scheduled-runs",
|
||||
json={
|
||||
"id": "telegram-pdf",
|
||||
"prompt": "prepare the daily report",
|
||||
"schedule": "60000",
|
||||
"delivery_channel": "telegram",
|
||||
"delivery_target": "123",
|
||||
"delivery_format": "pdf",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "only for email" in response.json()["detail"]
|
||||
@@ -1906,6 +1906,10 @@ mini-racer==0.14.1 \
|
||||
--hash=sha256:ad4b5c15993caab571fac3d67e0d55157b358bd439e629e0fef26c45a12c8bab \
|
||||
--hash=sha256:cdf3a088e1363f16a695288f882abf76b3705b8e1df21418208b87ed010037a4
|
||||
# via akshare
|
||||
mistune==3.3.4 \
|
||||
--hash=sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe \
|
||||
--hash=sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a
|
||||
# via -r agent/requirements.txt
|
||||
more-itertools==11.1.0 \
|
||||
--hash=sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d \
|
||||
--hash=sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192
|
||||
@@ -2098,6 +2102,35 @@ numba==0.66.0 \
|
||||
--hash=sha256:fb601841d9e02e6237bb6522e36d0741614be3cfe2b482a6f00a41b5ba209443 \
|
||||
--hash=sha256:fc6629becb21a867d85401ec89f426dd24c484a4193ade8a38309debfd1529ca
|
||||
# via smartmoneyconcepts
|
||||
nh3==0.3.7 \
|
||||
--hash=sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877 \
|
||||
--hash=sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62 \
|
||||
--hash=sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102 \
|
||||
--hash=sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086 \
|
||||
--hash=sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32 \
|
||||
--hash=sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8 \
|
||||
--hash=sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d \
|
||||
--hash=sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc \
|
||||
--hash=sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af \
|
||||
--hash=sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5 \
|
||||
--hash=sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59 \
|
||||
--hash=sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42 \
|
||||
--hash=sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848 \
|
||||
--hash=sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a \
|
||||
--hash=sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946 \
|
||||
--hash=sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba \
|
||||
--hash=sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac \
|
||||
--hash=sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174 \
|
||||
--hash=sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493 \
|
||||
--hash=sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa \
|
||||
--hash=sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd \
|
||||
--hash=sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563 \
|
||||
--hash=sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506 \
|
||||
--hash=sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b \
|
||||
--hash=sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a \
|
||||
--hash=sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479 \
|
||||
--hash=sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac
|
||||
# via -r agent/requirements.txt
|
||||
numpy==2.4.6 \
|
||||
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
|
||||
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
|
||||
|
||||
@@ -185,6 +185,13 @@
|
||||
"deliveryChannelPlaceholder": "telegram أو feishu … (اتركه فارغًا لإبقاء النتائج داخل التطبيق)",
|
||||
"deliveryTargetLabel": "وجهة القناة",
|
||||
"deliveryTargetPlaceholder": "معرّف محادثة / مجموعة / مستخدم",
|
||||
"deliveryFormatLabel": "Email report format",
|
||||
"deliveryFormatDefault": "Channel default (plain text)",
|
||||
"deliveryFormatHtml": "HTML email",
|
||||
"deliveryFormatPdf": "PDF attachment",
|
||||
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
|
||||
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
|
||||
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
|
||||
"deliveryHint": "مُعطّل افتراضيًا. يُرسل الملخص **بعد انتهاء** التشغيل، لا عند جدولته.",
|
||||
"deliveryTargetRequired": "حدّد وجهة لهذه القناة أو أفرغ حقل القناة.",
|
||||
"delivery_none": "سيُرسل إلى {{channel}} بعد التشغيل القادم",
|
||||
|
||||
@@ -190,6 +190,13 @@
|
||||
"deliveryChannelPlaceholder": "telegram, feishu … (leer lassen, dann bleibt alles in der App)",
|
||||
"deliveryTargetLabel": "Ziel im Kanal",
|
||||
"deliveryTargetPlaceholder": "Chat- / Gruppen- / Benutzer-ID",
|
||||
"deliveryFormatLabel": "Email report format",
|
||||
"deliveryFormatDefault": "Channel default (plain text)",
|
||||
"deliveryFormatHtml": "HTML email",
|
||||
"deliveryFormatPdf": "PDF attachment",
|
||||
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
|
||||
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
|
||||
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
|
||||
"deliveryHint": "Standardmäßig aus. Das Briefing wird **nach dem Lauf** gesendet, nicht bei der Planung.",
|
||||
"deliveryTargetRequired": "Geben Sie ein Ziel für diesen Kanal an, oder leeren Sie das Kanalfeld.",
|
||||
"delivery_none": "Wird nach dem nächsten Lauf an {{channel}} gesendet",
|
||||
|
||||
@@ -190,6 +190,13 @@
|
||||
"deliveryChannelPlaceholder": "telegram, feishu, … (leave empty to keep results in the app)",
|
||||
"deliveryTargetLabel": "Channel target",
|
||||
"deliveryTargetPlaceholder": "chat / group / user id",
|
||||
"deliveryFormatLabel": "Email report format",
|
||||
"deliveryFormatDefault": "Channel default (plain text)",
|
||||
"deliveryFormatHtml": "HTML email",
|
||||
"deliveryFormatPdf": "PDF attachment",
|
||||
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
|
||||
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
|
||||
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
|
||||
"deliveryHint": "Off by default. A briefing is pushed once the run finishes, not when it is scheduled.",
|
||||
"deliveryTargetRequired": "Choose a target for that channel, or clear the channel.",
|
||||
"delivery_none": "Delivering to {{channel}} after the next run",
|
||||
|
||||
@@ -190,6 +190,13 @@
|
||||
"deliveryChannelPlaceholder": "telegram, feishu… (vacío para que los resultados queden en la app)",
|
||||
"deliveryTargetLabel": "Destino en el canal",
|
||||
"deliveryTargetPlaceholder": "id de chat / grupo / usuario",
|
||||
"deliveryFormatLabel": "Formato del informe por email",
|
||||
"deliveryFormatDefault": "Predeterminado del canal (texto plano)",
|
||||
"deliveryFormatHtml": "Email HTML",
|
||||
"deliveryFormatPdf": "PDF adjunto",
|
||||
"deliveryFormatDefaultHint": "Mantiene el comportamiento de texto plano existente.",
|
||||
"deliveryFormatHtmlHint": "Muestra el informe completo como HTML sanitizado en el cuerpo del email.",
|
||||
"deliveryFormatPdfHint": "Adjunta el informe completo como PDF y deja solo un aviso breve en el cuerpo.",
|
||||
"deliveryHint": "Desactivado por defecto. El informe se envía **cuando la ejecución termina**, no cuando se programa.",
|
||||
"deliveryTargetRequired": "Indica un destino para ese canal, o deja el canal vacío.",
|
||||
"delivery_none": "Se enviará a {{channel}} tras la próxima ejecución",
|
||||
|
||||
@@ -190,6 +190,13 @@
|
||||
"deliveryChannelPlaceholder": "telegram, feishu, … (kosongkan agar hasil tetap di aplikasi)",
|
||||
"deliveryTargetLabel": "Target channel",
|
||||
"deliveryTargetPlaceholder": "ID chat / grup / pengguna",
|
||||
"deliveryFormatLabel": "Email report format",
|
||||
"deliveryFormatDefault": "Channel default (plain text)",
|
||||
"deliveryFormatHtml": "HTML email",
|
||||
"deliveryFormatPdf": "PDF attachment",
|
||||
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
|
||||
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
|
||||
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
|
||||
"deliveryHint": "Nonaktif secara default. Ringkasan dikirim setelah run selesai, bukan saat dijadwalkan.",
|
||||
"deliveryTargetRequired": "Pilih target untuk channel tersebut, atau kosongkan channel.",
|
||||
"delivery_none": "Akan dikirim ke {{channel}} setelah run berikutnya",
|
||||
|
||||
@@ -185,6 +185,13 @@
|
||||
"deliveryChannelPlaceholder": "telegram、feishu など(空欄ならアプリ内のみ)",
|
||||
"deliveryTargetLabel": "チャンネル送信先",
|
||||
"deliveryTargetPlaceholder": "チャット / グループ / ユーザー ID",
|
||||
"deliveryFormatLabel": "Email report format",
|
||||
"deliveryFormatDefault": "Channel default (plain text)",
|
||||
"deliveryFormatHtml": "HTML email",
|
||||
"deliveryFormatPdf": "PDF attachment",
|
||||
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
|
||||
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
|
||||
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
|
||||
"deliveryHint": "既定はオフです。ブリーフィングは実行の**完了後**に配信され、スケジュール時ではありません。",
|
||||
"deliveryTargetRequired": "そのチャンネルの送信先を指定するか、チャンネルを空にしてください。",
|
||||
"delivery_none": "次回の実行完了後に {{channel}} へ配信します",
|
||||
|
||||
@@ -185,6 +185,13 @@
|
||||
"deliveryChannelPlaceholder": "telegram, feishu … (비우면 앱 안에만 남습니다)",
|
||||
"deliveryTargetLabel": "채널 대상",
|
||||
"deliveryTargetPlaceholder": "채팅 / 그룹 / 사용자 ID",
|
||||
"deliveryFormatLabel": "Email report format",
|
||||
"deliveryFormatDefault": "Channel default (plain text)",
|
||||
"deliveryFormatHtml": "HTML email",
|
||||
"deliveryFormatPdf": "PDF attachment",
|
||||
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
|
||||
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
|
||||
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
|
||||
"deliveryHint": "기본은 꺼짐입니다. 브리핑은 예약 시점이 아니라 실행이 **끝난 뒤** 전달됩니다.",
|
||||
"deliveryTargetRequired": "해당 채널의 대상을 지정하거나 채널을 비워 주세요.",
|
||||
"delivery_none": "다음 실행이 끝나면 {{channel}}(으)로 전달합니다",
|
||||
|
||||
@@ -190,6 +190,13 @@
|
||||
"deliveryChannelPlaceholder": "telegram, feishu,… (deixe em branco para manter os resultados no aplicativo)",
|
||||
"deliveryTargetLabel": "Segmentação do canal",
|
||||
"deliveryTargetPlaceholder": "chat / grupo / ID de usuário",
|
||||
"deliveryFormatLabel": "Email report format",
|
||||
"deliveryFormatDefault": "Channel default (plain text)",
|
||||
"deliveryFormatHtml": "HTML email",
|
||||
"deliveryFormatPdf": "PDF attachment",
|
||||
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
|
||||
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
|
||||
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
|
||||
"deliveryHint": "Desativado por padrão. Um briefing é enviado assim que a execução termina, não quando está agendada.",
|
||||
"deliveryTargetRequired": "Escolha um alvo para esse canal ou limpe o canal.",
|
||||
"delivery_none": "Entregando para {{channel}} após a próxima execução",
|
||||
|
||||
@@ -185,6 +185,13 @@
|
||||
"deliveryChannelPlaceholder": "telegram、feishu…(留空则结果只留在应用内)",
|
||||
"deliveryTargetLabel": "频道目标",
|
||||
"deliveryTargetPlaceholder": "会话 / 群 / 用户 id",
|
||||
"deliveryFormatLabel": "Email report format",
|
||||
"deliveryFormatDefault": "Channel default (plain text)",
|
||||
"deliveryFormatHtml": "HTML email",
|
||||
"deliveryFormatPdf": "PDF attachment",
|
||||
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
|
||||
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
|
||||
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
|
||||
"deliveryHint": "默认关闭。简报在运行**结束后**推送,而不是在排定时推送。",
|
||||
"deliveryTargetRequired": "请为该频道填写目标,或清空频道。",
|
||||
"delivery_none": "下次运行结束后推送到 {{channel}}",
|
||||
|
||||
@@ -673,6 +673,7 @@ export interface ScheduledRun {
|
||||
// app, which is what every monitor created before this did.
|
||||
delivery_channel: string | null;
|
||||
delivery_target: string | null;
|
||||
delivery_format: "html" | "pdf" | null;
|
||||
delivery_target_ref: string | null;
|
||||
delivery_target_label: string | null;
|
||||
delivery_status: string;
|
||||
@@ -695,6 +696,7 @@ export interface CreateScheduledRunRequest {
|
||||
config?: Record<string, unknown>;
|
||||
delivery_channel?: string | null;
|
||||
delivery_target?: string | null;
|
||||
delivery_format?: "html" | "pdf" | null;
|
||||
delivery_target_ref?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const POLL_MS = 15_000;
|
||||
|
||||
type DaysChoice = "every" | "weekdays";
|
||||
type ComposerMode = "time" | "advanced";
|
||||
type EmailDeliveryFormat = "" | "html" | "pdf";
|
||||
|
||||
function browserTimezone(): string {
|
||||
try {
|
||||
@@ -91,6 +92,7 @@ export function Scheduled() {
|
||||
// it means the briefing stays in the app.
|
||||
const [deliveryChannel, setDeliveryChannel] = useState("");
|
||||
const [deliveryTarget, setDeliveryTarget] = useState("");
|
||||
const [deliveryFormat, setDeliveryFormat] = useState<EmailDeliveryFormat>("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [composerError, setComposerError] = useState<string | null>(null);
|
||||
|
||||
@@ -175,8 +177,10 @@ export function Scheduled() {
|
||||
timezone,
|
||||
delivery_channel: channel || null,
|
||||
delivery_target: channel ? target : null,
|
||||
delivery_format: channel === "email" && deliveryFormat ? deliveryFormat : null,
|
||||
});
|
||||
setPrompt("");
|
||||
setDeliveryFormat("");
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
setComposerError(error instanceof ApiError ? error.message : String(error));
|
||||
@@ -392,7 +396,11 @@ export function Scheduled() {
|
||||
<input
|
||||
id="scheduled-delivery-channel"
|
||||
value={deliveryChannel}
|
||||
onChange={(e) => setDeliveryChannel(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setDeliveryChannel(value);
|
||||
if (value.trim() !== "email") setDeliveryFormat("");
|
||||
}}
|
||||
placeholder={t("scheduled.deliveryChannelPlaceholder")}
|
||||
className={fieldClass}
|
||||
/>
|
||||
@@ -410,6 +418,30 @@ export function Scheduled() {
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
{deliveryChannel.trim() === "email" && (
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label htmlFor="scheduled-delivery-format" className={labelClass}>
|
||||
{t("scheduled.deliveryFormatLabel")}
|
||||
</label>
|
||||
<select
|
||||
id="scheduled-delivery-format"
|
||||
value={deliveryFormat}
|
||||
onChange={(e) => setDeliveryFormat(e.target.value as EmailDeliveryFormat)}
|
||||
className={fieldClass}
|
||||
>
|
||||
<option value="">{t("scheduled.deliveryFormatDefault")}</option>
|
||||
<option value="html">{t("scheduled.deliveryFormatHtml")}</option>
|
||||
<option value="pdf">{t("scheduled.deliveryFormatPdf")}</option>
|
||||
</select>
|
||||
<p className={hintClass}>
|
||||
{deliveryFormat === "html"
|
||||
? t("scheduled.deliveryFormatHtmlHint")
|
||||
: deliveryFormat === "pdf"
|
||||
? t("scheduled.deliveryFormatPdfHint")
|
||||
: t("scheduled.deliveryFormatDefaultHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{composerError && (
|
||||
|
||||
@@ -38,6 +38,7 @@ function run(overrides: Partial<ScheduledRun> = {}): ScheduledRun {
|
||||
timezone: "Pacific/Auckland",
|
||||
delivery_channel: null,
|
||||
delivery_target: null,
|
||||
delivery_format: null,
|
||||
delivery_status: "none",
|
||||
delivery_error: null,
|
||||
delivery_updated_at: null,
|
||||
@@ -192,6 +193,29 @@ describe("briefing delivery", () => {
|
||||
expect(body.delivery_target).toBe("chat-9");
|
||||
});
|
||||
|
||||
it("offers HTML and PDF presentation only for email delivery", async () => {
|
||||
render(<Scheduled />);
|
||||
fireEvent.change(screen.getByLabelText(/prompt/i), {
|
||||
target: { value: "daily report" },
|
||||
});
|
||||
expect(screen.queryByLabelText(/email report format/i)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/deliver to channel/i), {
|
||||
target: { value: "email" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/channel target/i), {
|
||||
target: { value: "reader@example.test" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/email report format/i), {
|
||||
target: { value: "pdf" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /schedule|create/i }));
|
||||
|
||||
await waitFor(() => expect(mocked.createScheduledRun).toHaveBeenCalled());
|
||||
const body = mocked.createScheduledRun.mock.calls[0][0];
|
||||
expect(body.delivery_format).toBe("pdf");
|
||||
});
|
||||
|
||||
it("shows a monitor's delivery state, and shows nothing when it has none", async () => {
|
||||
mocked.listScheduledRuns.mockResolvedValue([
|
||||
run({ id: "with", delivery_channel: "telegram", delivery_status: "sent" }),
|
||||
|
||||
@@ -71,6 +71,8 @@ dependencies = [
|
||||
"ddgs>=6.0.0",
|
||||
"jinja2>=3.1.0",
|
||||
"matplotlib>=3.7.0",
|
||||
"mistune>=3.0.0",
|
||||
"nh3>=0.2.18",
|
||||
"weasyprint>=60.0",
|
||||
"prompt_toolkit>=3.0.0",
|
||||
]
|
||||
|
||||
@@ -1991,6 +1991,10 @@ mini-racer==0.14.1 ; sys_platform != 'linux' \
|
||||
--hash=sha256:ad4b5c15993caab571fac3d67e0d55157b358bd439e629e0fef26c45a12c8bab \
|
||||
--hash=sha256:cdf3a088e1363f16a695288f882abf76b3705b8e1df21418208b87ed010037a4
|
||||
# via akshare
|
||||
mistune==3.3.4 \
|
||||
--hash=sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe \
|
||||
--hash=sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a
|
||||
# via -r agent/requirements.txt
|
||||
more-itertools==11.1.0 \
|
||||
--hash=sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d \
|
||||
--hash=sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192
|
||||
@@ -2156,6 +2160,35 @@ narwhals==2.25.0 \
|
||||
--hash=sha256:1f0f403e8c7e4463cde9bfe78b12fdd809e3ae3dda6d9b2f802934fb9c7a6a8f \
|
||||
--hash=sha256:62c036c810662bf7820b7737077176313bc59350eeeefb808510f388c743e4b2
|
||||
# via scikit-learn
|
||||
nh3==0.3.7 \
|
||||
--hash=sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877 \
|
||||
--hash=sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62 \
|
||||
--hash=sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102 \
|
||||
--hash=sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086 \
|
||||
--hash=sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32 \
|
||||
--hash=sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8 \
|
||||
--hash=sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d \
|
||||
--hash=sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc \
|
||||
--hash=sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af \
|
||||
--hash=sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5 \
|
||||
--hash=sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59 \
|
||||
--hash=sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42 \
|
||||
--hash=sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848 \
|
||||
--hash=sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a \
|
||||
--hash=sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946 \
|
||||
--hash=sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba \
|
||||
--hash=sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac \
|
||||
--hash=sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174 \
|
||||
--hash=sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493 \
|
||||
--hash=sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa \
|
||||
--hash=sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd \
|
||||
--hash=sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563 \
|
||||
--hash=sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506 \
|
||||
--hash=sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b \
|
||||
--hash=sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a \
|
||||
--hash=sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479 \
|
||||
--hash=sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac
|
||||
# via -r agent/requirements.txt
|
||||
numpy==2.4.6 \
|
||||
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
|
||||
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
|
||||
|
||||
Reference in new issue
Block a user