Implement document title generation and UI improvements
Browse files- Add title generation functionality using selected LLM
- Generate title automatically during summarization process
- Display generated title in UI between diarization and summary sections
- Use generated title for naming exported transcript and summary files
- Separate LLM selection from summary section for better UX
- Update frontend to handle title events from API
- Add CSS styling for title display
- Update export models and services to include title information
- Sanitize titles for safe filename usage
Features:
- Automatic title generation from transcript content
- Title displayed prominently in results
- Exported files named with generated titles
- Improved UI organization with separated LLM controls
- frontend/app.js +11 -1
- frontend/index.html +9 -1
- frontend/styles.css +19 -0
- src/server/models/export.py +2 -0
- src/server/models/summarization.py +1 -0
- src/server/services/export_service.py +17 -2
- src/server/services/summarization_service.py +8 -1
- src/summarization.py +56 -0
frontend/app.js
CHANGED
|
@@ -5,6 +5,7 @@ const state = {
|
|
| 5 |
diarizedUtterances: null,
|
| 6 |
diarizationStats: null,
|
| 7 |
summary: '',
|
|
|
|
| 8 |
audioUrl: null,
|
| 9 |
sourcePath: null,
|
| 10 |
uploadedFile: null,
|
|
@@ -34,6 +35,7 @@ const elements = {
|
|
| 34 |
transcriptTemplate: document.getElementById('utterance-template'),
|
| 35 |
utteranceCount: document.getElementById('utterance-count'),
|
| 36 |
summaryOutput: document.getElementById('summary-output'),
|
|
|
|
| 37 |
diarizationPanel: document.getElementById('diarization-summary'),
|
| 38 |
diarizationMetrics: document.getElementById('diarization-metrics'),
|
| 39 |
speakerBreakdown: document.getElementById('speaker-breakdown'),
|
|
@@ -492,11 +494,14 @@ async function handleSummaryGeneration() {
|
|
| 492 |
state.summarizing = true;
|
| 493 |
setStatus('Generating summary...', 'info');
|
| 494 |
elements.summaryOutput.textContent = '';
|
|
|
|
|
|
|
| 495 |
|
| 496 |
const payload = {
|
| 497 |
transcript: state.utterances.map((u) => u.text).join('\n'),
|
| 498 |
llm_model: elements.llmSelect.value,
|
| 499 |
prompt: elements.promptInput.value || 'Summarize the transcript below.',
|
|
|
|
| 500 |
};
|
| 501 |
|
| 502 |
try {
|
|
@@ -521,7 +526,10 @@ async function handleSummaryGeneration() {
|
|
| 521 |
for (const line of lines) {
|
| 522 |
if (!line.trim()) continue;
|
| 523 |
const event = JSON.parse(line);
|
| 524 |
-
if (event.type === '
|
|
|
|
|
|
|
|
|
|
| 525 |
elements.summaryOutput.innerHTML = renderMarkdown(event.content);
|
| 526 |
}
|
| 527 |
}
|
|
@@ -542,6 +550,7 @@ async function handleExportTranscript() {
|
|
| 542 |
format: elements.transcriptFormat.value,
|
| 543 |
include_timestamps: elements.includeTimestamps.checked,
|
| 544 |
utterances: state.utterances,
|
|
|
|
| 545 |
};
|
| 546 |
await downloadFile('/api/export/transcript', payload, 'transcript');
|
| 547 |
}
|
|
@@ -552,6 +561,7 @@ async function handleExportSummary() {
|
|
| 552 |
format: elements.summaryFormat.value,
|
| 553 |
summary: elements.summaryOutput.textContent,
|
| 554 |
metadata: {},
|
|
|
|
| 555 |
};
|
| 556 |
await downloadFile('/api/export/summary', payload, 'summary');
|
| 557 |
}
|
|
|
|
| 5 |
diarizedUtterances: null,
|
| 6 |
diarizationStats: null,
|
| 7 |
summary: '',
|
| 8 |
+
title: '',
|
| 9 |
audioUrl: null,
|
| 10 |
sourcePath: null,
|
| 11 |
uploadedFile: null,
|
|
|
|
| 35 |
transcriptTemplate: document.getElementById('utterance-template'),
|
| 36 |
utteranceCount: document.getElementById('utterance-count'),
|
| 37 |
summaryOutput: document.getElementById('summary-output'),
|
| 38 |
+
titleOutput: document.getElementById('title-output'),
|
| 39 |
diarizationPanel: document.getElementById('diarization-summary'),
|
| 40 |
diarizationMetrics: document.getElementById('diarization-metrics'),
|
| 41 |
speakerBreakdown: document.getElementById('speaker-breakdown'),
|
|
|
|
| 494 |
state.summarizing = true;
|
| 495 |
setStatus('Generating summary...', 'info');
|
| 496 |
elements.summaryOutput.textContent = '';
|
| 497 |
+
elements.titleOutput.textContent = '';
|
| 498 |
+
state.title = '';
|
| 499 |
|
| 500 |
const payload = {
|
| 501 |
transcript: state.utterances.map((u) => u.text).join('\n'),
|
| 502 |
llm_model: elements.llmSelect.value,
|
| 503 |
prompt: elements.promptInput.value || 'Summarize the transcript below.',
|
| 504 |
+
generate_title: true,
|
| 505 |
};
|
| 506 |
|
| 507 |
try {
|
|
|
|
| 526 |
for (const line of lines) {
|
| 527 |
if (!line.trim()) continue;
|
| 528 |
const event = JSON.parse(line);
|
| 529 |
+
if (event.type === 'title' && event.content) {
|
| 530 |
+
state.title = event.content;
|
| 531 |
+
elements.titleOutput.textContent = event.content;
|
| 532 |
+
} else if (event.type === 'partial' && event.content) {
|
| 533 |
elements.summaryOutput.innerHTML = renderMarkdown(event.content);
|
| 534 |
}
|
| 535 |
}
|
|
|
|
| 550 |
format: elements.transcriptFormat.value,
|
| 551 |
include_timestamps: elements.includeTimestamps.checked,
|
| 552 |
utterances: state.utterances,
|
| 553 |
+
title: state.title || null,
|
| 554 |
};
|
| 555 |
await downloadFile('/api/export/transcript', payload, 'transcript');
|
| 556 |
}
|
|
|
|
| 561 |
format: elements.summaryFormat.value,
|
| 562 |
summary: elements.summaryOutput.textContent,
|
| 563 |
metadata: {},
|
| 564 |
+
title: state.title || null,
|
| 565 |
};
|
| 566 |
await downloadFile('/api/export/summary', payload, 'summary');
|
| 567 |
}
|
frontend/index.html
CHANGED
|
@@ -61,10 +61,13 @@
|
|
| 61 |
</section>
|
| 62 |
|
| 63 |
<section class="panel">
|
| 64 |
-
<h2>
|
| 65 |
<label for="llm-select">LLM Model</label>
|
| 66 |
<select id="llm-select"></select>
|
|
|
|
| 67 |
|
|
|
|
|
|
|
| 68 |
<label for="prompt-input">Custom Prompt</label>
|
| 69 |
<textarea id="prompt-input" rows="4">Summarize the transcript below.</textarea>
|
| 70 |
</section>
|
|
@@ -145,6 +148,11 @@
|
|
| 145 |
<div id="speaker-breakdown"></div>
|
| 146 |
</section>
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
<section class="panel">
|
| 149 |
<h2>Summary</h2>
|
| 150 |
<div id="summary-output" class="summary"></div>
|
|
|
|
| 61 |
</section>
|
| 62 |
|
| 63 |
<section class="panel">
|
| 64 |
+
<h2>Language Model</h2>
|
| 65 |
<label for="llm-select">LLM Model</label>
|
| 66 |
<select id="llm-select"></select>
|
| 67 |
+
</section>
|
| 68 |
|
| 69 |
+
<section class="panel">
|
| 70 |
+
<h2>Summarization</h2>
|
| 71 |
<label for="prompt-input">Custom Prompt</label>
|
| 72 |
<textarea id="prompt-input" rows="4">Summarize the transcript below.</textarea>
|
| 73 |
</section>
|
|
|
|
| 148 |
<div id="speaker-breakdown"></div>
|
| 149 |
</section>
|
| 150 |
|
| 151 |
+
<section class="panel">
|
| 152 |
+
<h2>Document Title</h2>
|
| 153 |
+
<div id="title-output" class="title-display"></div>
|
| 154 |
+
</section>
|
| 155 |
+
|
| 156 |
<section class="panel">
|
| 157 |
<h2>Summary</h2>
|
| 158 |
<div id="summary-output" class="summary"></div>
|
frontend/styles.css
CHANGED
|
@@ -472,6 +472,25 @@ button:hover {
|
|
| 472 |
color: #93c5fd;
|
| 473 |
}
|
| 474 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
.export-grid {
|
| 476 |
display: grid;
|
| 477 |
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
|
|
| 472 |
color: #93c5fd;
|
| 473 |
}
|
| 474 |
|
| 475 |
+
.title-display {
|
| 476 |
+
min-height: 40px;
|
| 477 |
+
background: rgba(15, 23, 42, 0.5);
|
| 478 |
+
border-radius: 12px;
|
| 479 |
+
padding: 1rem;
|
| 480 |
+
border: 1px solid rgba(148, 163, 184, 0.15);
|
| 481 |
+
font-size: 1.25rem;
|
| 482 |
+
font-weight: 600;
|
| 483 |
+
color: #f1f5f9;
|
| 484 |
+
text-align: center;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.title-display:empty::before {
|
| 488 |
+
content: "No title generated";
|
| 489 |
+
color: #64748b;
|
| 490 |
+
font-weight: 400;
|
| 491 |
+
font-style: italic;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
.export-grid {
|
| 495 |
display: grid;
|
| 496 |
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
src/server/models/export.py
CHANGED
|
@@ -16,9 +16,11 @@ class TranscriptExportRequest(BaseModel):
|
|
| 16 |
format: str
|
| 17 |
utterances: List[UtterancePayload]
|
| 18 |
include_timestamps: bool = True
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
class SummaryExportRequest(BaseModel):
|
| 22 |
format: str
|
| 23 |
summary: str
|
| 24 |
metadata: Optional[Dict[str, str]] = None
|
|
|
|
|
|
| 16 |
format: str
|
| 17 |
utterances: List[UtterancePayload]
|
| 18 |
include_timestamps: bool = True
|
| 19 |
+
title: Optional[str] = None
|
| 20 |
|
| 21 |
|
| 22 |
class SummaryExportRequest(BaseModel):
|
| 23 |
format: str
|
| 24 |
summary: str
|
| 25 |
metadata: Optional[Dict[str, str]] = None
|
| 26 |
+
title: Optional[str] = None
|
src/server/models/summarization.py
CHANGED
|
@@ -7,3 +7,4 @@ class SummaryRequest(BaseModel):
|
|
| 7 |
transcript: str = Field(..., min_length=1)
|
| 8 |
llm_model: str
|
| 9 |
prompt: str = Field("Summarize the transcript below.")
|
|
|
|
|
|
| 7 |
transcript: str = Field(..., min_length=1)
|
| 8 |
llm_model: str
|
| 9 |
prompt: str = Field("Summarize the transcript below.")
|
| 10 |
+
generate_title: bool = Field(default=True)
|
src/server/services/export_service.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from datetime import datetime
|
| 4 |
from typing import Tuple
|
|
|
|
| 5 |
|
| 6 |
from src.export_utils import (
|
| 7 |
SUBTITLE_FORMATS,
|
|
@@ -13,6 +14,18 @@ from src.export_utils import (
|
|
| 13 |
from ..models.export import SummaryExportRequest, TranscriptExportRequest
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def _build_utterance_tuples(payload: TranscriptExportRequest):
|
| 17 |
utterances = [(u.start, u.end, u.text) for u in payload.utterances]
|
| 18 |
has_speakers = any(u.speaker is not None for u in payload.utterances)
|
|
@@ -45,7 +58,8 @@ def generate_transcript_export(payload: TranscriptExportRequest) -> Tuple[str, s
|
|
| 45 |
raise ValueError(f"Unsupported transcript export format: {payload.format}")
|
| 46 |
|
| 47 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 48 |
-
|
|
|
|
| 49 |
return content, filename, fmt["mime_type"]
|
| 50 |
|
| 51 |
|
|
@@ -56,5 +70,6 @@ def generate_summary_export(payload: SummaryExportRequest) -> Tuple[str, str, st
|
|
| 56 |
fmt = SUMMARY_FORMATS[payload.format]
|
| 57 |
content = fmt["function"](payload.summary, payload.metadata)
|
| 58 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 59 |
-
|
|
|
|
| 60 |
return content, filename, fmt["mime_type"]
|
|
|
|
| 2 |
|
| 3 |
from datetime import datetime
|
| 4 |
from typing import Tuple
|
| 5 |
+
import re
|
| 6 |
|
| 7 |
from src.export_utils import (
|
| 8 |
SUBTITLE_FORMATS,
|
|
|
|
| 14 |
from ..models.export import SummaryExportRequest, TranscriptExportRequest
|
| 15 |
|
| 16 |
|
| 17 |
+
def _sanitize_filename(title: str) -> str:
|
| 18 |
+
"""Sanitize title for use in filename"""
|
| 19 |
+
if not title:
|
| 20 |
+
return ""
|
| 21 |
+
# Remove or replace invalid filename characters
|
| 22 |
+
sanitized = re.sub(r'[<>:"/\\|?*]', '', title)
|
| 23 |
+
# Replace spaces and other characters with underscores
|
| 24 |
+
sanitized = re.sub(r'[^\w\-_\.]', '_', sanitized)
|
| 25 |
+
# Limit length
|
| 26 |
+
return sanitized[:50] if sanitized else ""
|
| 27 |
+
|
| 28 |
+
|
| 29 |
def _build_utterance_tuples(payload: TranscriptExportRequest):
|
| 30 |
utterances = [(u.start, u.end, u.text) for u in payload.utterances]
|
| 31 |
has_speakers = any(u.speaker is not None for u in payload.utterances)
|
|
|
|
| 58 |
raise ValueError(f"Unsupported transcript export format: {payload.format}")
|
| 59 |
|
| 60 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 61 |
+
title_part = f"_{_sanitize_filename(payload.title)}" if payload.title else ""
|
| 62 |
+
filename = f"transcript{title_part}_{timestamp}{fmt['extension']}"
|
| 63 |
return content, filename, fmt["mime_type"]
|
| 64 |
|
| 65 |
|
|
|
|
| 70 |
fmt = SUMMARY_FORMATS[payload.format]
|
| 71 |
content = fmt["function"](payload.summary, payload.metadata)
|
| 72 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 73 |
+
title_part = f"_{_sanitize_filename(payload.title)}" if payload.title else ""
|
| 74 |
+
filename = f"summary{title_part}_{timestamp}{fmt['extension']}"
|
| 75 |
return content, filename, fmt["mime_type"]
|
src/server/services/summarization_service.py
CHANGED
|
@@ -4,13 +4,20 @@ from typing import Dict, Iterable
|
|
| 4 |
|
| 5 |
from fastapi import HTTPException
|
| 6 |
|
| 7 |
-
from src.summarization import summarize_transcript
|
| 8 |
|
| 9 |
from ..models.summarization import SummaryRequest
|
| 10 |
|
| 11 |
|
| 12 |
def iter_summary_events(payload: SummaryRequest) -> Iterable[Dict[str, str]]:
|
| 13 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
generator = summarize_transcript(
|
| 15 |
transcript=payload.transcript,
|
| 16 |
selected_gguf_model=payload.llm_model,
|
|
|
|
| 4 |
|
| 5 |
from fastapi import HTTPException
|
| 6 |
|
| 7 |
+
from src.summarization import summarize_transcript, generate_title
|
| 8 |
|
| 9 |
from ..models.summarization import SummaryRequest
|
| 10 |
|
| 11 |
|
| 12 |
def iter_summary_events(payload: SummaryRequest) -> Iterable[Dict[str, str]]:
|
| 13 |
try:
|
| 14 |
+
# Generate title if requested
|
| 15 |
+
title = None
|
| 16 |
+
if payload.generate_title:
|
| 17 |
+
title = generate_title(payload.transcript, payload.llm_model)
|
| 18 |
+
yield {"type": "title", "content": title}
|
| 19 |
+
|
| 20 |
+
# Generate summary
|
| 21 |
generator = summarize_transcript(
|
| 22 |
transcript=payload.transcript,
|
| 23 |
selected_gguf_model=payload.llm_model,
|
src/summarization.py
CHANGED
|
@@ -204,6 +204,62 @@ def summarize_transcript_langchain(transcript: str, selected_gguf_model: str, pr
|
|
| 204 |
yield f"[Error during summarization: {str(e)}]"
|
| 205 |
|
| 206 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
# Alias pour maintenir la compatibilité
|
| 208 |
summarize_transcript = summarize_transcript_langchain
|
| 209 |
|
|
|
|
| 204 |
yield f"[Error during summarization: {str(e)}]"
|
| 205 |
|
| 206 |
|
| 207 |
+
def create_title_prompt() -> PromptTemplate:
|
| 208 |
+
"""Prompt for generating a document title"""
|
| 209 |
+
template = """Generate a concise, descriptive title for this transcript. The title should capture the main topic or theme.
|
| 210 |
+
|
| 211 |
+
Transcript:
|
| 212 |
+
{text}
|
| 213 |
+
|
| 214 |
+
Title:"""
|
| 215 |
+
return PromptTemplate(template=template, input_variables=["text"])
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def generate_title(transcript: str, selected_gguf_model: str) -> str:
|
| 219 |
+
"""
|
| 220 |
+
Generate a title for the transcript using the selected LLM.
|
| 221 |
+
Returns a concise title that captures the main topic.
|
| 222 |
+
"""
|
| 223 |
+
if not transcript or not transcript.strip():
|
| 224 |
+
return "Untitled Document"
|
| 225 |
+
|
| 226 |
+
try:
|
| 227 |
+
# Get the LLM
|
| 228 |
+
llm = get_llm(selected_gguf_model)
|
| 229 |
+
title_prompt = create_title_prompt()
|
| 230 |
+
|
| 231 |
+
# Use first 2000 tokens for title generation to avoid excessive context
|
| 232 |
+
tokens = llm.tokenize(transcript.encode('utf-8'))
|
| 233 |
+
if len(tokens) > 2000:
|
| 234 |
+
# Truncate to first 2000 tokens and decode back to text
|
| 235 |
+
truncated_tokens = tokens[:2000]
|
| 236 |
+
truncated_text = llm.detokenize(truncated_tokens).decode('utf-8')
|
| 237 |
+
else:
|
| 238 |
+
truncated_text = transcript
|
| 239 |
+
|
| 240 |
+
# Format the prompt
|
| 241 |
+
formatted_prompt = title_prompt.format(text=truncated_text)
|
| 242 |
+
|
| 243 |
+
# Generate title
|
| 244 |
+
response = llm.create_chat_completion(
|
| 245 |
+
messages=[
|
| 246 |
+
{"role": "system", "content": "You are an expert at creating concise, descriptive titles for documents and transcripts."},
|
| 247 |
+
{"role": "user", "content": formatted_prompt}
|
| 248 |
+
],
|
| 249 |
+
stream=False,
|
| 250 |
+
max_tokens=50, # Limit title length
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
title = response['choices'][0]['message']['content'].strip()
|
| 254 |
+
# Clean up the title (remove quotes, extra whitespace)
|
| 255 |
+
title = title.strip('"\'').strip()
|
| 256 |
+
return title if title else "Untitled Document"
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
print(f"Error generating title: {e}")
|
| 260 |
+
return "Untitled Document"
|
| 261 |
+
|
| 262 |
+
|
| 263 |
# Alias pour maintenir la compatibilité
|
| 264 |
summarize_transcript = summarize_transcript_langchain
|
| 265 |
|