From b63f49241d6e97f53f5b4cd84026bc1384880de3 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 15 Sep 2026 14:24:36 -0500 Subject: [PATCH] Summarize tone: re-summarize in a chosen voice (backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /reprocess?job=summarize&tone= persists the voice on the job row (queue carries only ids, so a sweep re-enqueue keeps it), the LLM prompt appends 'write every field in a tone — the tone colors the wording, never the facts', and the resulting summary records its tone (SummaryOut.tone). Plain re-summarize clears a previous tone. Migration tone0000000001 (summaries.tone, processing_jobs.tone). 78 passed, 1 skipped; ruff clean. --- .../versions/tone0000000001_summary_tone.py | 36 +++++++++++++++++++ backend/shonar/api/schemas_recordings.py | 1 + backend/shonar/api/v1/recordings.py | 11 ++++-- backend/shonar/db/models.py | 7 ++++ backend/shonar/services/ai/__init__.py | 3 +- backend/shonar/services/ai/_llm.py | 13 +++++-- backend/shonar/services/ai/ollama.py | 6 ++-- backend/shonar/services/ai/openai_compat.py | 6 ++-- backend/shonar/services/processing.py | 8 +++-- backend/tests/test_ai_pipeline.py | 2 +- backend/tests/test_transcript_edits.py | 2 +- 11 files changed, 82 insertions(+), 13 deletions(-) create mode 100644 backend/migrations/versions/tone0000000001_summary_tone.py diff --git a/backend/migrations/versions/tone0000000001_summary_tone.py b/backend/migrations/versions/tone0000000001_summary_tone.py new file mode 100644 index 0000000..78bc225 --- /dev/null +++ b/backend/migrations/versions/tone0000000001_summary_tone.py @@ -0,0 +1,36 @@ +"""summary tone: summarize-in-a-different-voice support + +Revision ID: tone0000000001 +Revises: m8models000001 +Create Date: 2026-09-15 + +- summaries.tone (voice the summary was written in; NULL = neutral) +- processing_jobs.tone (pending request; survives sweep re-enqueue) +""" +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "tone0000000001" +down_revision: str | None = "m8models000001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "summaries", + sa.Column("tone", sa.String(length=64), nullable=True), + ) + op.add_column( + "processing_jobs", + sa.Column("tone", sa.String(length=64), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("processing_jobs", "tone") + op.drop_column("summaries", "tone") diff --git a/backend/shonar/api/schemas_recordings.py b/backend/shonar/api/schemas_recordings.py index 0eea6cc..d1ca5f9 100644 --- a/backend/shonar/api/schemas_recordings.py +++ b/backend/shonar/api/schemas_recordings.py @@ -110,6 +110,7 @@ class SummaryOut(ORMModel): version: int provider: str model: str | None + tone: str | None = None content: dict edited_by_user: bool created_at: datetime diff --git a/backend/shonar/api/v1/recordings.py b/backend/shonar/api/v1/recordings.py index af61955..0ac74c3 100644 --- a/backend/shonar/api/v1/recordings.py +++ b/backend/shonar/api/v1/recordings.py @@ -366,13 +366,15 @@ async def reprocess_recording( session: SessionDep, job: str = Query(default="summarize", pattern="^(transcribe|summarize)$"), model: str | None = Query(default=None, max_length=64), + tone: str | None = Query(default=None, max_length=64), ): """Force one pipeline stage to run again (Summarize / Re-transcribe). Unlike the enqueue-on-finalize path, this ignores prior success: a summary the user wants regenerated (better model, new prompt) is a deliberate request. Running jobs are left alone (409 instead of a - duplicate). + duplicate). ``tone`` (summarize only) restyles the summary's voice, + e.g. "sarcastic" — facts stay faithful to the transcript. """ from shonar.db.models import JobStatus, ProcessingStatus from shonar.db.models import JobType as JT @@ -380,6 +382,7 @@ async def reprocess_recording( rec = await _owned_recording(session, user, recording_id) job_type = JT(job) + tone = (tone.strip() or None) if (job_type is JT.summarize and tone) else None if job_type is JT.summarize and await proc.latest_transcript_text( session, rec.id ) is None: @@ -398,7 +401,8 @@ async def reprocess_recording( ): raise HTTPException(409, "That stage is already running.") if existing is None: - session.add(ProcessingJob(recording_id=rec.id, job_type=job_type)) + session.add(ProcessingJob(recording_id=rec.id, job_type=job_type, + tone=tone)) else: existing.status = JobStatus.queued existing.attempt = 0 @@ -407,6 +411,9 @@ async def reprocess_recording( existing.progress = None existing.started_at = None existing.finished_at = None + # Tone rides the job row (the queue carries only ids): a plain + # re-summarize must clear a previous tone, not inherit it. + existing.tone = tone if job_type is JT.transcribe: if model is not None: # A re-transcribe may switch models; the saved per-recording diff --git a/backend/shonar/db/models.py b/backend/shonar/db/models.py index b5e0bef..5dfed7d 100644 --- a/backend/shonar/db/models.py +++ b/backend/shonar/db/models.py @@ -338,6 +338,9 @@ class Summary(Base, PublicIdMixin): superseded_at: Mapped[datetime | None] = mapped_column(UTCDT()) provider: Mapped[str] = mapped_column(String(80), nullable=False, default="manual") model: Mapped[str | None] = mapped_column(String(120)) + # Voice the LLM was asked to summarize in (e.g. "sarcastic"). NULL = + # neutral house voice. Display-only: the content JSON shape is unchanged. + tone: Mapped[str | None] = mapped_column(String(64)) content: Mapped[dict] = mapped_column(JSONType, nullable=False, default=dict) edited_by_user: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) @@ -417,6 +420,10 @@ class ProcessingJob(Base, PublicIdMixin): stage: Mapped[str | None] = mapped_column(String(32)) # 0-100 work estimate within the current stage, when known. progress: Mapped[int | None] = mapped_column() + # Summarize jobs only: the voice the LLM was asked for (persisted so a + # sweep re-enqueue, which carries only (job_type, recording_id), reruns + # the same request). NULL = neutral. + tone: Mapped[str | None] = mapped_column(String(64)) started_at: Mapped[datetime | None] = mapped_column(UTCDT()) finished_at: Mapped[datetime | None] = mapped_column(UTCDT()) # Opaque arq task handle for observability. diff --git a/backend/shonar/services/ai/__init__.py b/backend/shonar/services/ai/__init__.py index 700254c..c40114b 100644 --- a/backend/shonar/services/ai/__init__.py +++ b/backend/shonar/services/ai/__init__.py @@ -112,7 +112,8 @@ class SummaryResult: class LlmProvider(Protocol): name: str - async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult: ... + async def summarize(self, transcript: str, *, title: str | None = None, + tone: str | None = None) -> SummaryResult: ... def get_transcription_provider(settings: Settings) -> TranscriptionProvider | None: diff --git a/backend/shonar/services/ai/_llm.py b/backend/shonar/services/ai/_llm.py index 6427a41..611dc65 100644 --- a/backend/shonar/services/ai/_llm.py +++ b/backend/shonar/services/ai/_llm.py @@ -21,12 +21,21 @@ SYSTEM_PROMPT = ( MAX_TRANSCRIPT_CHARS = 12_000 -def build_user_message(transcript: str, title: str | None) -> str: +def build_user_message(transcript: str, title: str | None, + tone: str | None = None) -> str: text = transcript[:MAX_TRANSCRIPT_CHARS] if len(transcript) > MAX_TRANSCRIPT_CHARS: text += f"\n\n[truncated from {len(transcript)} chars]" head = f'Title: "{title}"\n\n' if title else "" - return head + "Transcript:\n" + text + msg = head + "Transcript:\n" + text + if tone: + # Same JSON contract and same fidelity rules — only the voice changes. + msg += ( + f"\n\nWrite every field of the JSON in a {tone} tone of voice. " + "Stay faithful to the transcript: the tone colors the wording, " + "never the facts." + ) + return msg def parse_summary(data: object, model: str) -> SummaryResult: diff --git a/backend/shonar/services/ai/ollama.py b/backend/shonar/services/ai/ollama.py index dd293a5..45da6cb 100644 --- a/backend/shonar/services/ai/ollama.py +++ b/backend/shonar/services/ai/ollama.py @@ -38,7 +38,8 @@ class OllamaProvider: async with httpx.AsyncClient(timeout=self.timeout_s) as client: yield client - async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult: + async def summarize(self, transcript: str, *, title: str | None = None, + tone: str | None = None) -> SummaryResult: payload = { "model": self.model, "stream": False, @@ -51,7 +52,8 @@ class OllamaProvider: "options": {"num_ctx": 8192}, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": build_user_message(transcript, title)}, + {"role": "user", + "content": build_user_message(transcript, title, tone)}, ], } try: diff --git a/backend/shonar/services/ai/openai_compat.py b/backend/shonar/services/ai/openai_compat.py index 883b567..a27dc3b 100644 --- a/backend/shonar/services/ai/openai_compat.py +++ b/backend/shonar/services/ai/openai_compat.py @@ -42,7 +42,8 @@ class OpenAICompatProvider: async with httpx.AsyncClient(timeout=self.timeout_s) as client: yield client - async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult: + async def summarize(self, transcript: str, *, title: str | None = None, + tone: str | None = None) -> SummaryResult: headers = ( {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} ) @@ -52,7 +53,8 @@ class OpenAICompatProvider: "response_format": {"type": "json_object"}, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": build_user_message(transcript, title)}, + {"role": "user", + "content": build_user_message(transcript, title, tone)}, ], } try: diff --git a/backend/shonar/services/processing.py b/backend/shonar/services/processing.py index 27a5789..cbf5ec4 100644 --- a/backend/shonar/services/processing.py +++ b/backend/shonar/services/processing.py @@ -437,12 +437,14 @@ async def run_summarize(ctx: dict, recording_id: str) -> None: rec.processing_status = ProcessingStatus.processing await session.commit() # visible before the long LLM call try: - result = await provider.summarize(text, title=rec.title) + result = await provider.summarize(text, title=rec.title, + tone=job.tone) except AIError as e: await _fail(session, rec, job, str(e), ctx, e) await session.commit() return - await store_summary(session, rec, result.to_dict(), provider.name, result.model) + await store_summary(session, rec, result.to_dict(), provider.name, + result.model, tone=job.tone) job.status = JobStatus.succeeded job.stage = None job.progress = 100 @@ -506,6 +508,7 @@ async def store_summary( content: dict, provider_name: str, model: str, + tone: str | None = None, ) -> None: existing = ( await session.scalars( @@ -531,6 +534,7 @@ async def store_summary( version=(max_version or 0) + 1, provider=provider_name, model=model, + tone=tone, content=content, edited_by_user=False, ) diff --git a/backend/tests/test_ai_pipeline.py b/backend/tests/test_ai_pipeline.py index 52e4755..89f50a5 100644 --- a/backend/tests/test_ai_pipeline.py +++ b/backend/tests/test_ai_pipeline.py @@ -95,7 +95,7 @@ class FakeLlm: self.fail = fail self.seen = [] - async def summarize(self, transcript, *, title=None): + async def summarize(self, transcript, *, title=None, tone=None): self.seen.append(transcript) if self.fail is not None: raise self.fail diff --git a/backend/tests/test_transcript_edits.py b/backend/tests/test_transcript_edits.py index afa043b..6502516 100644 --- a/backend/tests/test_transcript_edits.py +++ b/backend/tests/test_transcript_edits.py @@ -131,7 +131,7 @@ async def test_user_edit_survives_auto_pipeline(client, monkeypatch): class FakeLlm: name = "fake-llm" - async def summarize(self, transcript, *, title=None): + async def summarize(self, transcript, *, title=None, tone=None): return SummaryResult(short="s", detailed="d", key_points=(), decisions=(), action_items=(), questions=(), model="fake-llm-1")