Luigi commited on
Commit
d8028fb
·
1 Parent(s): 299bf2b

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 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 === 'partial' && event.content) {
 
 
 
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>Summarization</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
- filename = f"transcript_{timestamp}{fmt['extension']}"
 
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
- filename = f"summary_{timestamp}{fmt['extension']}"
 
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