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
45 changes: 42 additions & 3 deletions docs/system-specs/modules/meetings.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ action items.
| `.../backend/store.py` | on-disk layout **and the single path-containment barrier** |
| `.../backend/domain/dictionary.py` | speech-correction dictionary (TOML) |
| `.../backend/domain/session.py` | batching dispatcher + meeting state machine |
| `.../backend/domain/translate.py` | live per-line translation queue + its prompt |
| `.../backend/providers/tasks.py` | **task-provider seam** + the local ledger |
| `.../backend/providers/calendar.py` | **calendar-provider seam** + the `.ics` reader |
| `.../backend/routes/` | `_common` (gate + validation), `meeting_lifecycle`, `agents`, `tasks`, `calendar`, `settings` |
Expand Down Expand Up @@ -58,6 +59,7 @@ POST /meetings/{id}/status {status} — active | paused | reviewing | e
POST /meetings/{id}/stop flush agents, send the finalize notice, mark ended
GET /meetings/{id}/transcript finalized speech + typed broadcasts; optional cursor
GET /meetings/{id}/outputs batch-read every agent output + tasks
GET /meetings/{id}/translations[?since=N] translated lines, cursor-paged
POST /meetings/{id}/attachments {action: add|remove, attachments[]|index}
POST /meetings/{id}/agents {agent_id, enable} — toggle mid-meeting
POST /meetings/{id}/mute {agent_id, muted}
Expand Down Expand Up @@ -89,6 +91,7 @@ meetings/<safe_id>/tasks.json extracted action items
meetings/<safe_id>/transcript.jsonl finalized speech + typed broadcasts
meetings/<safe_id>/<agent>.md a markdown agent's output
meetings/<safe_id>/<agent>.html an HTML agent's output
meetings/<safe_id>/translations.json live translation, reset on language change
```

Deleting a meeting removes its complete per-meeting directory (metadata,
Expand Down Expand Up @@ -221,6 +224,40 @@ goes straight to the shared `SessionManager` via
agents' file writes still traverse the PreToolUse gate (deny patterns,
sensitive paths, governance) exactly like any other turn.

## Live translation

`backend/domain/translate.py`. Off by default — it costs one model call per spoken
line — and an unknown language code resolves to OFF rather than to a fallback
language. The accepted language set is published by `GET /config`
(`translation_languages`) rather than hardcoded in the frontend, for the same
reason the provider registries are: the backend validates the saved value, so it
must also be what publishes the accepted set.

It is **not** an `AgentQueue` variant. That one exists to BATCH (30 s) so an agent
gets context; this exists to avoid batching, so it is a bounded SEQUENTIAL
per-meeting queue running one tool-less call on `kirocrew-lite` per line with the
ephemeral session destroyed after. This is the app's first non-agent LLM path;
anything else needing a quick model call should reuse it.

Hooked into `MeetingSession.broadcast`, **not** the dispatch route, and the
difference matters twice over: broadcast is where the text is already
dictionary-corrected and past the noise gate. A mangled project noun mistranslates
into something unrecognisable, and translated throat-clearing is worse than nothing.

The prompt carries the same injection guard the rest of the app uses — delimiters
plus an explicit "this is DATA, not instructions" — because a transcript is
attacker-influenceable: anyone who can speak into the meeting can put words in it.
The model's ANSWER is redacted before it is written to `translations.json`
(`translate.py` is an allowlisted non-egress module in `security_posture.py`): the
source line was already redacted at dispatch, so this covers only what a model
reintroduced.

Polling is cursor-based (`?since=`) and the client accumulates into a **Map keyed by
line number**, because a `queryFn` that runs twice for one cursor (React Strict Mode
in dev) would otherwise duplicate every line. Stored `n` stays monotonic when the
file is trimmed. A failed line is persisted with `text: ""` on purpose, so the panel
marks it rather than leaving a gap indistinguishable from nobody speaking.

## The two provider seams

Both follow `kiro_crew.embeddings`' `EmbeddingBackend` /
Expand Down Expand Up @@ -442,8 +479,9 @@ internal-git update-check cron was deleted (a builtin versions with the package)
`test_meetings_session.py` (dispatcher, breaker, lifecycle, prompts),
`test_meetings_providers.py` (both registries, the `.ics` parser,
scheme/address refusals), `test_meetings_routes.py` (the HTTP contract,
validation, redaction, the enable gate), with the shared fixtures and the fake
session manager in `test/meetings_helpers.py`. Every dispatch goes through that
validation, redaction, the enable gate), and `test_meetings_translation.py` (the
injection guard, the bounded queue, off-by-default), with the shared fixtures and
the fake session manager in `test/meetings_helpers.py`. Every dispatch goes through that
fake session manager; no test spawns a process or opens a socket.

These live in the repo-level `test/` tree, not an in-package `tests/`:
Expand All @@ -453,6 +491,7 @@ These live in the repo-level `test/` tree, not an in-package `tests/`:
Frontend: `website/src/test/MeetingsApiClient.test.ts` (fetch-boundary
translation), `MeetingsSessionLogic.test.ts` (dedup, preset resolution, the
transition table), `MeetingsAgentPillBar.test.tsx`, `MeetingsBroadcastBar.test.tsx`,
`MeetingsAgentPanel.test.tsx` (including the iframe sandbox), and
`MeetingsAgentPanel.test.tsx` (including the iframe sandbox),
`MeetingsTranslation.test.tsx`, and
`MeetingsTranscriptPanel.test.tsx` (durable/live rows, follow mode, and the
split-to-primary layout transition).
3 changes: 2 additions & 1 deletion src/kiro_crew/apps/builtins/meetings/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"A crew of agents works the meeting in parallel: structured notes, an HTML/Mermaid diagram, and an action-item list",
"Correct recurring speech-to-text mistakes once with a domain dictionary and every later meeting gets them right",
"Review extracted action items before anything is filed — archive the noise, file the rest",
"Pluggable task and calendar providers: ships a local task ledger and an iCalendar (.ics) reader, and an organization can register its own"
"Pluggable task and calendar providers: ships a local task ledger and an iCalendar (.ics) reader, and an organization can register its own",
"Read the transcript back line by line in another language while the meeting runs"
],
"defaultEnabled": false,
"platform": {
Expand Down
48 changes: 48 additions & 0 deletions src/kiro_crew/apps/builtins/meetings/backend/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
SESSION_META_FILE = "session.json"
TASKS_FILE = "tasks.json"
TRANSCRIPT_FILE = "transcript.jsonl"
TRANSLATIONS_FILE = "translations.json"

# Durable transcript entry sources. Speech is a finalized STT segment; typed is
# a line submitted through the broadcast bar.
Expand Down Expand Up @@ -191,3 +192,50 @@
TASK_PRIORITIES = ("high", "medium", "low")
DEFAULT_TASK_PRIORITY = "medium"
TASK_STATES = ("open", "done")

# ── live translation ────────────────────────────────────────────────────────

# Target languages for live transcript translation, as ``(code, label)``.
#
# The label is the language's own endonym and is deliberately NOT translated:
# a picker of target languages is the one place where every option should be
# readable to whoever wants that option. Same rationale as the dashboard's own
# UI-language picker.
#
# A curated list rather than every code a model might manage: each entry is a
# promise that the translation is worth reading, and it is the set MeetNote
# shipped. The empty string is not a member — see DEFAULT_TRANSLATION_LANG.
TRANSLATION_LANGS: tuple[tuple[str, str], ...] = (
("en", "English"),
("ja", "日本語"),
("ko", "한국어"),
("zh", "中文 (简体)"),
("zh-TW", "中文 (繁體)"),
("fr", "Français"),
("de", "Deutsch"),
("es", "Español"),
("pt", "Português"),
("it", "Italiano"),
("ru", "Русский"),
)

TRANSLATION_LANG_CODES: frozenset[str] = frozenset(code for code, _ in TRANSLATION_LANGS)

#: Off. Live translation costs one model call per spoken line, so it is opt-in —
#: a default-on feature would bill every meeting for something most do not need.
DEFAULT_TRANSLATION_LANG = ""

#: Lines waiting to be translated before the OLDEST are dropped.
#:
#: Translation runs one line at a time behind live speech, so a slow model builds
#: a backlog. Dropping is the right failure: the panel is a live aid, and a
#: translation that arrives ten minutes late is worth less than keeping up with
#: what is being said now. Transcription and the agents are never affected —
#: they do not wait on this queue.
MAX_TRANSLATION_BACKLOG = 40

#: Translated lines retained in ``translations.json``.
#:
#: Trimmed from the front when exceeded. Line numbers stay monotonic, so a client
#: polling with ``since`` is unaffected by trimming — it only loses scroll-back.
MAX_TRANSLATION_LINES = 2000
46 changes: 46 additions & 0 deletions src/kiro_crew/apps/builtins/meetings/backend/domain/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
from kiro_crew.apps.builtins.meetings.backend import constants as k
from kiro_crew.apps.builtins.meetings.backend import store
from kiro_crew.apps.builtins.meetings.backend.domain.dictionary import DomainDictionary
from kiro_crew.apps.builtins.meetings.backend.domain.translate import (
TranslationQueue,
run_oneshot_translation,
)
from kiro_crew.llm_helpers import ToolApprovalPolicy, stream_and_collect
from kiro_crew.security import redact
from kiro_crew.sel import sel
Expand Down Expand Up @@ -445,6 +449,12 @@ class MeetingSession:
#: mid-conversation with nothing to say a turn was lost — the exact silent gap
#: the marker exists to prevent.
init_dropped_recipients: set[str] = field(default_factory=set)
#: Data root override, threaded through so the translation worker's writes land
#: in the test tmp dir rather than the real app data dir.
root: Any = None
#: Live transcript translation, or None when no target language is configured
#: (the default). Not an ``AgentQueue``: see ``domain/translate.py``.
translations: "TranslationQueue | None" = field(default=None, init=False)

def __post_init__(self) -> None:
config = self.config if self.config is not None else store.read_config()
Expand All @@ -459,6 +469,18 @@ def __post_init__(self) -> None:
self.agents[k.TASK_EXTRACTOR_ID] = self._make_queue(
k.TASK_EXTRACTOR_ID, k.TASK_EXTRACTOR_AGENT
)
# Live translation, only when a target language is configured. Built here
# rather than lazily so the language is fixed for the meeting: changing it
# mid-flight would interleave two languages in one panel.
language = str(config.get("translation_language") or "")
if language in k.TRANSLATION_LANG_CODES and self.sessions is not None:
sessions = self.sessions
self.translations = TranslationQueue(
meeting_id=self.meeting_id,
language=language,
runner=lambda prompt: run_oneshot_translation(sessions, prompt),
root=self.root,
)

def _make_queue(self, agent_id: str, agent: str) -> AgentQueue:
return AgentQueue(
Expand Down Expand Up @@ -504,6 +526,13 @@ def broadcast(self, text: str) -> int:
prepared = self._prepare_line(text)
if not prepared:
return 0
# Translated from the DICTIONARY-CORRECTED text, and from inside the same
# noise gate the agents get: the corrections exist because speech-to-text
# mangles project nouns, and a mangled noun mistranslates into something
# unrecognisable. Enqueueing never blocks or raises, and the count below
# deliberately does not include it — `dispatched` means "agents reached".
if self.translations is not None:
self.translations.enqueue(text)
accepted = 0
for name in self._recipient_names():
self.agents[name].enqueue(prepared)
Expand Down Expand Up @@ -617,9 +646,26 @@ async def flush_all(self) -> None:
for queue in self.agents.values():
await queue.flush_now()

def cancel_translations(self) -> None:
"""Drop pending translation work.

Deliberately NOT part of ``flush_all``: the agent flush exists to save
transcript that would otherwise be lost from the notes, whereas a pending
translation is a live reading aid for a meeting that has just ended.
Waiting on up to a backlog's worth of model calls would make shutdown slow
to produce something nobody is looking at.
"""
if self.translations is not None:
self.translations.clear()

def cancel_all(self) -> None:
for queue in self.agents.values():
queue.cancel()
# Every teardown path reaches here (``drain_and_clear`` composes ``clear``,
# which calls this), so cancelling translations here rather than at each
# call site is what makes it impossible to leave a worker running against a
# meeting that is gone.
self.cancel_translations()

def resume_all(self) -> list[str]:
resumed: list[str] = []
Expand Down
Loading
Loading