Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ in the bot would read it.
| `STURNUS_WHISPER_DEFAULT_LANGUAGE` | `de` | no | Language reported when faster-whisper's own detection comes up empty. This is the floor under the per-guild `transcription_language` (section 4.1), not the usual setting to reach for — it is consulted only for a guild that asked for detection (`transcription_language auto`) and got nothing back. It still matters more than a fallback usually does: the first transcription for a speaker in such a session pins that speaker's language, and every later job for them reuses it. |
| `STURNUS_MODEL_CACHE_DIR` | unset | no | Where model weights are cached. When set, the worker exports it as `HF_HOME` before loading the model, so the download lands on a persistent volume; left unset, every cold start re-downloads several gigabytes of weights. |
| `STURNUS_WORK_DIR` | `/tmp` | no | Scratch directory the encrypted recording is downloaded and decrypted into before transcription. It must be large enough for the biggest single recording — the chart sizes the corresponding volume with `worker.tmpSizeLimit`. |
| `STURNUS_CONSOLE_ORIGIN` | `https://sturnus.onelitefeather.dev` | no | Where a protocol stored in the object store is served from. A guild that publishes to a `markdown` or `html` export target gets an artefact in the audio bucket, and the URL recorded for it — the one the bot posts into the channel — is this origin plus `/api/sessions/{id}/documents/{target_id}`, answered by `sturnus-api` under the same participant rule as the session's own page. Deliberately **not** a presigned S3 URL, which would keep working for anybody it was forwarded to and could not be revoked. It must be the same value `sturnus-api` runs with, or every such link points at a host that does not answer — and because the link is posted into Discord, it outlives any later correction. |
| `STURNUS_MAX_JOB_ATTEMPTS` | `3` | no | How many failed attempts a job gets before `JobQueue.fail` marks it `dead`. See section 5 for what a `dead` job means for the rest of its session. |
| `STURNUS_JOB_LEASE_SECONDS` | `1800.0` | no | How long a claimed job may stay `running` before `JobQueue.claim` reclaims it for another worker. It is generous on purpose: it must exceed the longest plausible transcription, or a still-running job gets picked up a second time. |
| `STURNUS_HEALTH_PORT` | `8080` | no | Port the `/healthz`, `/readyz`, `/metrics`, `/version` HTTP endpoints listen on. `/metrics` answers **`501 Not Implemented`**: metrics are *pushed* over OTLP, not scraped — see section 7. |
Expand Down
114 changes: 92 additions & 22 deletions src/sturnus/application/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,27 @@ def _build_environment() -> SandboxedEnvironment:
return env


def _build_html_environment() -> SandboxedEnvironment:
"""The same engine with the opposite escaping, for an HTML destination.

Two things here are the whole point of having a second environment
rather than a second template on the first one.

`autoescape=True`: every value drawn from the transcript is escaped as
HTML on its way into the output, so a display name of
`<script>alert(1)</script>` reaches the page as text.

**No `md` filter.** It is not merely unused here -- it is absent, so a
template that reaches for it fails to render rather than quietly
emitting `escape_markdown`'s backslashes into a document where nothing
ever removes them again. The registry
(`sturnus.application.export_formats`) is what pairs each template with
the environment that is right for it; this is the half of that pairing
that cannot be got wrong silently.
"""
return SandboxedEnvironment(autoescape=True, trim_blocks=True, lstrip_blocks=True)


class _BlockContext(TypedDict):
time: str
speaker: SpeakerIdentity
Expand Down Expand Up @@ -112,21 +133,19 @@ def label(self) -> str:
return self.name if self.name else str(self.channel_id)


def render_transcript(
def transcript_context(
transcript: Transcript,
template_source: str,
tz: tzinfo,
channel: ChannelRef | None = None,
) -> str:
"""Renders `transcript` through `template_source`, localised to `tz`.

The timezone is a parameter rather than a constant: a protocol read by
people in one place should carry local times, and hardcoding UTC would
make every timestamp subtly wrong for its readers -- wrong in a way that
does not look wrong, since any hour reads as a plausible meeting time.

`channel` is optional so a caller that cannot resolve it still gets a
protocol; the template simply omits the heading.
) -> dict[str, object]:
"""Everything a document template may read, localised to `tz`.

Extracted from `render_transcript` when a second output *shape* -- HTML
-- arrived. The two differ in exactly one thing, the escaping, and
nothing else about a protocol changes with its destination: the same
participants, the same blocks, the same local times. Building the
context once is what keeps that true, rather than leaving a second copy
to drift into rendering a different meeting.
"""
blocks: list[_BlockContext] = [
{
Expand All @@ -138,19 +157,70 @@ def render_transcript(
]
local_start = transcript.session_started_at.astimezone(tz)
duration = transcript.session_ended_at - transcript.session_started_at
template = _build_environment().from_string(template_source)
return template.render(
participants=transcript.participants,
blocks=blocks,
channel=channel,
return {
"participants": transcript.participants,
"blocks": blocks,
"channel": channel,
# Outline renders `mention://date/<YYYY-MM-DD>` as a date chip
# (MentionType.Date). Date only: the version deployed here parses no
# time component, so an ISO datetime would degrade to a plain link.
date_iso=local_start.strftime("%Y-%m-%d"),
date_label=local_start.strftime("%d.%m.%Y"),
started=local_start.strftime("%H:%M"),
duration_minutes=max(1, round(duration.total_seconds() / 60)),
)
"date_iso": local_start.strftime("%Y-%m-%d"),
"date_label": local_start.strftime("%d.%m.%Y"),
"started": local_start.strftime("%H:%M"),
"duration_minutes": max(1, round(duration.total_seconds() / 60)),
# Only a standalone document needs its own title inside its body;
# the Outline template does not read this, because Outline stores
# the title as a field of its own. It is in the context rather than
# a parameter of the HTML renderer alone so there is one definition
# of what a protocol is called (`document_title`) and one context
# every template is rendered against.
"title": document_title(transcript, tz),
}


def render_transcript(
transcript: Transcript,
template_source: str,
tz: tzinfo,
channel: ChannelRef | None = None,
) -> str:
"""Renders `transcript` through `template_source` as Markdown.

The timezone is a parameter rather than a constant: a protocol read by
people in one place should carry local times, and hardcoding UTC would
make every timestamp subtly wrong for its readers -- wrong in a way that
does not look wrong, since any hour reads as a plausible meeting time.

`channel` is optional so a caller that cannot resolve it still gets a
protocol; the template simply omits the heading.

**Markdown, and only Markdown.** The environment escapes through the
`md` filter and autoescapes nothing, so handing this function an HTML
template would produce a page that renders a hostile display name as
markup. `render_html` is the other half of that pair; which one a
destination gets is `sturnus.application.export_formats`' decision.
"""
template = _build_environment().from_string(template_source)
return template.render(**transcript_context(transcript, tz, channel))


def render_html(
transcript: Transcript,
template_source: str,
tz: tzinfo,
channel: ChannelRef | None = None,
) -> str:
"""The same protocol, rendered as HTML with HTML escaping.

Deliberately not `render_transcript` with a different template.
`escape_markdown` answers `<script>` with a backslash in front of
nothing -- every character of the tag survives -- so a Markdown-escaped
HTML document is an XSS sink that looks escaped. The escaping is a
property of the *environment*, and that is what differs here; see
`_build_html_environment`.
"""
template = _build_html_environment().from_string(template_source)
return template.render(**transcript_context(transcript, tz, channel))


def document_title(transcript: Transcript, tz: tzinfo) -> str:
Expand Down
Loading
Loading