commit 93d419ec0765d499ad28e463bbc2b034bdbcbc39
parent 808e4a594e22817556eb798cef3e7831dc158cd1
Author: Pablo Murad <pblmrd@gmail.com>
Date: Sat, 9 May 2026 22:21:42 -0300
remodelation
Diffstat:
15 files changed, 739 insertions(+), 48 deletions(-)
diff --git a/.env.example b/.env.example
@@ -34,3 +34,23 @@ OPENAI_API_KEY=
# Esforco de raciocinio para modelos da familia gpt-5/o-series.
# Valores validos: minimal, low, medium, high. Default: medium.
# OPENAI_REASONING_EFFORT=medium
+
+# ---------------------------------------------------------------------------
+# Preset global e sumarizacao hierarquica (textos longos)
+# ---------------------------------------------------------------------------
+
+# Preset padrao quando nao se envia quality_preset na API/Web: economico | equilibrado | maximo.
+# Mapeia defaults de chat, transcricao e reasoning (sobrescritos por OPENAI_* quando definidos).
+# LAZIER_QUALITY_PRESET=equilibrado
+
+# Sumario map-reduce com overlap para transcricoes acima do limiar (caracteres).
+# LAZIER_SUMMARY_HIERARCHICAL=true
+# LAZIER_SUMMARY_DIRECT_MAX_CHARS=32000
+# LAZIER_SUMMARY_MAP_CHUNK_CHARS=14000
+# LAZIER_SUMMARY_CHUNK_OVERLAP_CHARS=1400
+
+# ---------------------------------------------------------------------------
+# Spike STT / diarizacao (opcional, sem segundo fornecedor ligado por defeito)
+# ---------------------------------------------------------------------------
+
+# LAZIER_ALT_STT_ENABLED=false
diff --git a/README.md b/README.md
@@ -34,6 +34,12 @@ Defaults seguros já vêm configurados; sobrescreva apenas se quiser mudar.
| `OPENAI_ENABLE_SMART_SUMMARY` | `true` | Liga/desliga o sumário estruturado (TL;DR, pontos-chave, decisões, ações, tópicos, citações, perguntas em aberto). Quando `false`, mantém o sumário textual legado. |
| `OPENAI_ENABLE_CHAPTERS` | `true` | Liga/desliga geração de capítulos com timestamps em áudio/vídeo. |
| `OPENAI_REASONING_EFFORT` | `medium` | Esforço de raciocínio para modelos da família `gpt-5`/`o-series`. Valores: `minimal`, `low`, `medium`, `high`. |
+| `LAZIER_QUALITY_PRESET` | `equilibrado` | Preset global `economico` / `equilibrado` / `maximo` → defaults de chat, transcrição e `reasoning` (sobrescritos por `OPENAI_*` quando definidos). Na Web/API pode enviar-se `quality_preset` por pedido. |
+| `LAZIER_SUMMARY_HIERARCHICAL` | `true` | Acima de `LAZIER_SUMMARY_DIRECT_MAX_CHARS`, o sumário inteligente usa map-reduce com chunks e overlap em vez de um único passe sobre o texto completo. |
+| `LAZIER_SUMMARY_DIRECT_MAX_CHARS` | `32000` | Limiar (caracteres) para ativar sumarização hierárquica. |
+| `LAZIER_SUMMARY_MAP_CHUNK_CHARS` | `14000` | Tamanho alvo de cada chunk no map. |
+| `LAZIER_SUMMARY_CHUNK_OVERLAP_CHARS` | `1400` | Sobreposição entre chunks consecutivos. |
+| `LAZIER_ALT_STT_ENABLED` | `false` | Reserva para spike de segundo STT ou diarização; incluída para documentar critérios go/no-go sem ligar fornecedor alternativo por defeito. |
Opcional: `YOUTUBE_PO_TOKEN` para melhor suporte a alguns vídeos do YouTube ([guia](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide)).
@@ -96,6 +102,7 @@ lazier transcribe "https://www.youtube.com/watch?v=VIDEO_ID"
lazier summarize document.pdf
lazier summarize "https://example.com/artigo" --format md
lazier summarize aula.mp3 --gpt-model gpt-5 --reasoning high
+lazier summarize documento.pdf --quality-preset economico
# Desligar features novas explicitamente
lazier summarize aula.mp3 --no-smart --no-chapters
@@ -114,6 +121,7 @@ Flags principais:
- `--smart/--no-smart` força ligar/desligar o sumário estruturado.
- `--chapters/--no-chapters` força ligar/desligar capítulos com timestamps.
- `--reasoning {minimal,low,medium,high}` ajusta o esforço de raciocínio para modelos `gpt-5`/`o-series`.
+- `--quality-preset {economico,equilibrado,maximo}` aplica o pacote de modelos e reasoning desse preset só nesse comando (equivalente ao campo da WebGUI).
### WebGUI
diff --git a/lazier/api/routes.py b/lazier/api/routes.py
@@ -4,10 +4,12 @@ Rotas da API FastAPI.
import logging
import os
+import tempfile
import uuid
+import zipfile
from datetime import datetime
from pathlib import Path
-from typing import List, Optional
+from typing import List, Optional, Tuple
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
@@ -15,7 +17,7 @@ from pydantic import BaseModel
from ..audio_processor import extract_audio_from_video
from ..core.formats import export
-from ..core.jobs import build_job_artifact_path, get_job_store
+from ..core.jobs import build_job_artifact_path, get_job_store, slugify_source_name
from ..core.processing import process_source
from ..core.supported_sites import SUPPORTED_VIDEO_SITES
from ..core.playlist import is_playlist_url
@@ -40,6 +42,7 @@ class ProcessRequest(BaseModel):
summarize: Optional[bool] = None
chat_model: Optional[str] = None
transcribe_model: Optional[str] = None
+ quality_preset: Optional[str] = None
smart: Optional[bool] = None
chapters: Optional[bool] = None
@@ -96,16 +99,19 @@ def _progress_updater(job_id: str):
def _build_overrides(
*,
- chat_model: Optional[str],
- transcribe_model: Optional[str],
- smart: Optional[bool],
- chapters: Optional[bool],
+ chat_model: Optional[str] = None,
+ transcribe_model: Optional[str] = None,
+ smart: Optional[bool] = None,
+ chapters: Optional[bool] = None,
+ quality_preset: Optional[str] = None,
) -> dict:
overrides: dict = {}
if chat_model:
overrides["chat_model"] = chat_model
if transcribe_model:
overrides["transcribe_model"] = transcribe_model
+ if quality_preset:
+ overrides["quality_preset"] = quality_preset.strip().lower()
if smart is not None:
overrides["smart"] = smart
if chapters is not None:
@@ -169,6 +175,8 @@ def _process_job(job_id: str) -> None:
gpt_model=overrides.get("chat_model"),
use_smart_summary=overrides.get("smart"),
use_chapters=overrides.get("chapters"),
+ quality_preset=overrides.get("quality_preset"),
+ trace_job_id=job_id,
run_id=job_id,
source_name=job.get("source_name"),
created_at=job.get("created_at"),
@@ -254,6 +262,28 @@ def _ensure_download_file(job: dict, artifact_kind: str) -> Optional[str]:
return None
+def _require_distinct_transcription_and_summary_paths(job: dict) -> Tuple[str, str]:
+ """Resolve paths for bundle ZIP or raise HTTPException."""
+
+ tx_path = _ensure_download_file(job, "transcription")
+ sm_path = _ensure_download_file(job, "summary")
+ if not tx_path or not sm_path:
+ raise HTTPException(
+ status_code=404,
+ detail="Pacote indisponivel: e necessario transcricao e sumario.",
+ )
+ if Path(tx_path).resolve() == Path(sm_path).resolve():
+ raise HTTPException(
+ status_code=400,
+ detail="Transcricao e sumario referem-se ao mesmo ficheiro; pacote ZIP indisponivel.",
+ )
+ return (tx_path, sm_path)
+
+
+def _unlink_quiet(path: str) -> None:
+ Path(path).unlink(missing_ok=True)
+
+
@router.post("/upload")
async def upload_files(
background_tasks: BackgroundTasks,
@@ -264,6 +294,7 @@ async def upload_files(
summarize: Optional[bool] = Form(None),
chat_model: Optional[str] = Form(None),
transcribe_model: Optional[str] = Form(None),
+ quality_preset: Optional[str] = Form(None),
smart: Optional[bool] = Form(None),
chapters: Optional[bool] = Form(None),
):
@@ -275,6 +306,7 @@ async def upload_files(
transcribe_model=transcribe_model,
smart=smart,
chapters=chapters,
+ quality_preset=quality_preset,
)
jobs = []
@@ -331,6 +363,7 @@ async def process_url(request: ProcessRequest, background_tasks: BackgroundTasks
transcribe_model=request.transcribe_model,
smart=request.smart,
chapters=request.chapters,
+ quality_preset=request.quality_preset,
)
job = _create_job(
@@ -443,6 +476,33 @@ async def download_result(job_id: str):
return FileResponse(download_path, media_type="application/octet-stream", filename=filename)
+@router.get("/jobs/{job_id}/download-bundle")
+async def download_bundle(job_id: str, background_tasks: BackgroundTasks):
+ """Download ZIP com transcricao e sumario em ficheiros separados."""
+
+ job = get_job_store().get_job(job_id)
+ if not job:
+ raise HTTPException(status_code=404, detail="Job nao encontrado")
+
+ tx_path, sm_path = _require_distinct_transcription_and_summary_paths(job)
+
+ fd, tmp_name = tempfile.mkstemp(suffix=".zip")
+ os.close(fd)
+ tmp_path = Path(tmp_name)
+ try:
+ with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ zf.write(tx_path, arcname=Path(tx_path).name)
+ zf.write(sm_path, arcname=Path(sm_path).name)
+ except Exception:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+ slug = slugify_source_name(job.get("source_name"))
+ zip_filename = f"{slug}-{job_id[:8]}-tudo.zip"
+ background_tasks.add_task(_unlink_quiet, str(tmp_path))
+ return FileResponse(str(tmp_path), media_type="application/zip", filename=zip_filename)
+
+
@router.get("/history")
async def get_history():
"""Retorna historico persistido de jobs."""
diff --git a/lazier/cli.py b/lazier/cli.py
@@ -50,6 +50,7 @@ def _run_mode(
smart: Optional[bool] = None,
chapters: Optional[bool] = None,
reasoning: Optional[str] = None,
+ quality_preset: Optional[str] = None,
):
if reasoning:
os.environ["OPENAI_REASONING_EFFORT"] = reasoning
@@ -90,6 +91,7 @@ def _run_mode(
gpt_model=gpt_model,
use_smart_summary=smart,
use_chapters=chapters,
+ quality_preset=quality_preset,
output_path=output,
run_id=str(uuid.uuid4()),
source_name=Path(input_path).name if not input_path.startswith(("http://", "https://")) else input_path,
@@ -164,6 +166,13 @@ def _model_options(func):
default=None,
help="Modelo de transcricao (default: OPENAI_TRANSCRIBE_MODEL ou gpt-4o-mini-transcribe)",
)(func)
+ func = click.option(
+ "--quality-preset",
+ "quality_preset",
+ type=click.Choice(["economico", "equilibrado", "maximo"]),
+ default=None,
+ help="Preset custo/qualidade para este comando (modelos + reasoning). Sobrescreve LAZIER_QUALITY_PRESET.",
+ )(func)
return func
@@ -181,6 +190,7 @@ def transcribe(
smart_flag: Optional[bool],
chapters_flag: Optional[bool],
reasoning: Optional[str],
+ quality_preset: Optional[str],
):
"""Transcreve ou converte o conteudo para portugues."""
_run_mode(
@@ -193,6 +203,7 @@ def transcribe(
smart=smart_flag,
chapters=chapters_flag,
reasoning=reasoning,
+ quality_preset=quality_preset,
)
@@ -210,6 +221,7 @@ def summarize(
smart_flag: Optional[bool],
chapters_flag: Optional[bool],
reasoning: Optional[str],
+ quality_preset: Optional[str],
):
"""Gera um sumario em portugues do conteudo informado."""
_run_mode(
@@ -222,6 +234,7 @@ def summarize(
smart=smart_flag,
chapters=chapters_flag,
reasoning=reasoning,
+ quality_preset=quality_preset,
)
@@ -237,7 +250,10 @@ def config():
f"Transcricao (timestamps): [cyan]{cfg.transcribe_timestamps_model}[/cyan]\n"
f"Reasoning effort: [cyan]{cfg.reasoning_effort}[/cyan]\n"
f"Smart summary: [cyan]{'on' if cfg.enable_smart_summary else 'off'}[/cyan]\n"
- f"Capitulos: [cyan]{'on' if cfg.enable_chapters else 'off'}[/cyan]",
+ f"Capitulos: [cyan]{'on' if cfg.enable_chapters else 'off'}[/cyan]\n"
+ f"Preset qualidade: [cyan]{cfg.quality_preset}[/cyan]\n"
+ f"Sumario hierarquico: [cyan]{'on' if cfg.hierarchical_summary else 'off'}[/cyan]\n"
+ f"STT alternativo (flag): [cyan]{'on' if cfg.alt_stt_enabled else 'off'}[/cyan]",
title="Lazier - Config Atual",
)
)
diff --git a/lazier/core/chapters.py b/lazier/core/chapters.py
@@ -19,7 +19,7 @@ try:
except ImportError: # pragma: no cover
OpenAI = None
-from .config import get_model_config
+from .config import VALID_REASONING_EFFORTS, get_model_config
CHAPTERS_JSON_SCHEMA: Dict[str, Any] = {
@@ -89,6 +89,7 @@ def build_chapters(
*,
model: Optional[str] = None,
content_type: Optional[str] = None,
+ reasoning_effort: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Gera lista de capitulos a partir dos segments. Sempre retorna lista.
@@ -144,7 +145,10 @@ def build_chapters(
"response_format": {"type": "json_schema", "json_schema": CHAPTERS_JSON_SCHEMA},
}
if config.supports_reasoning(chosen_model):
- kwargs["reasoning_effort"] = config.reasoning_effort
+ effort = reasoning_effort if reasoning_effort is not None else config.reasoning_effort
+ if effort not in VALID_REASONING_EFFORTS:
+ effort = config.reasoning_effort
+ kwargs["reasoning_effort"] = effort
else:
kwargs["temperature"] = 0.2
diff --git a/lazier/core/config.py b/lazier/core/config.py
@@ -10,7 +10,7 @@ from __future__ import annotations
import os
from dataclasses import dataclass, asdict
-from typing import Optional
+from typing import Dict, Optional
try:
from dotenv import load_dotenv
@@ -34,6 +34,38 @@ DEFAULT_REASONING_EFFORT = "medium"
VALID_REASONING_EFFORTS = {"minimal", "low", "medium", "high"}
+VALID_QUALITY_PRESETS = frozenset({"economico", "equilibrado", "maximo"})
+
+# Presets: custo vs qualidade (sobrescritos por OPENAI_* quando definidos).
+QUALITY_PRESETS: Dict[str, Dict[str, str]] = {
+ "economico": {
+ "chat_model": "gpt-5-nano",
+ "transcribe_model": "gpt-4o-mini-transcribe",
+ "reasoning_effort": "minimal",
+ },
+ "equilibrado": {
+ "chat_model": DEFAULT_CHAT_MODEL,
+ "transcribe_model": DEFAULT_TRANSCRIBE_MODEL,
+ "reasoning_effort": DEFAULT_REASONING_EFFORT,
+ },
+ "maximo": {
+ "chat_model": "gpt-5",
+ "transcribe_model": "gpt-4o-transcribe",
+ "reasoning_effort": "high",
+ },
+}
+
+
+def resolve_quality_preset(name: Optional[str]) -> str:
+ key = (name or "equilibrado").strip().lower()
+ return key if key in VALID_QUALITY_PRESETS else "equilibrado"
+
+
+def get_preset_model_defaults(preset: Optional[str]) -> Dict[str, str]:
+ """Defaults de modelo/raciocinio para um preset (API por pedido)."""
+
+ return dict(QUALITY_PRESETS[resolve_quality_preset(preset)])
+
def _env_bool(key: str, default: bool) -> bool:
raw = os.getenv(key)
@@ -49,6 +81,16 @@ def _env_str(key: str, default: str) -> str:
return raw.strip()
+def _env_int(key: str, default: int, *, min_value: int = 1) -> int:
+ raw = os.getenv(key)
+ if raw is None or not str(raw).strip():
+ return max(min_value, default)
+ try:
+ return max(min_value, int(raw.strip()))
+ except ValueError:
+ return max(min_value, default)
+
+
@dataclass(frozen=True)
class ModelConfig:
"""Snapshot imutavel de configuracao de modelos."""
@@ -59,6 +101,12 @@ class ModelConfig:
enable_chapters: bool = True
enable_smart_summary: bool = True
reasoning_effort: str = DEFAULT_REASONING_EFFORT
+ quality_preset: str = "equilibrado"
+ hierarchical_summary: bool = True
+ summary_direct_max_chars: int = 32_000
+ summary_map_chunk_chars: int = 14_000
+ summary_chunk_overlap_chars: int = 1_400
+ alt_stt_enabled: bool = False
def supports_reasoning(self, model: Optional[str] = None) -> bool:
"""Indica se devemos enviar `reasoning_effort` para um modelo."""
@@ -83,19 +131,28 @@ def get_model_config(refresh: bool = False) -> ModelConfig:
if _cached_config is not None and not refresh:
return _cached_config
- reasoning = _env_str("OPENAI_REASONING_EFFORT", DEFAULT_REASONING_EFFORT).lower()
+ preset_key = resolve_quality_preset(_env_str("LAZIER_QUALITY_PRESET", "equilibrado"))
+ pd = QUALITY_PRESETS[preset_key]
+
+ reasoning = _env_str("OPENAI_REASONING_EFFORT", pd["reasoning_effort"]).lower()
if reasoning not in VALID_REASONING_EFFORTS:
- reasoning = DEFAULT_REASONING_EFFORT
+ reasoning = pd["reasoning_effort"]
_cached_config = ModelConfig(
- chat_model=_env_str("OPENAI_CHAT_MODEL", DEFAULT_CHAT_MODEL),
- transcribe_model=_env_str("OPENAI_TRANSCRIBE_MODEL", DEFAULT_TRANSCRIBE_MODEL),
+ chat_model=_env_str("OPENAI_CHAT_MODEL", pd["chat_model"]),
+ transcribe_model=_env_str("OPENAI_TRANSCRIBE_MODEL", pd["transcribe_model"]),
transcribe_timestamps_model=_env_str(
"OPENAI_TRANSCRIBE_TIMESTAMPS_MODEL", DEFAULT_TRANSCRIBE_TIMESTAMPS_MODEL
),
enable_chapters=_env_bool("OPENAI_ENABLE_CHAPTERS", True),
enable_smart_summary=_env_bool("OPENAI_ENABLE_SMART_SUMMARY", True),
reasoning_effort=reasoning,
+ quality_preset=preset_key,
+ hierarchical_summary=_env_bool("LAZIER_SUMMARY_HIERARCHICAL", True),
+ summary_direct_max_chars=_env_int("LAZIER_SUMMARY_DIRECT_MAX_CHARS", 32_000, min_value=2_000),
+ summary_map_chunk_chars=_env_int("LAZIER_SUMMARY_MAP_CHUNK_CHARS", 14_000, min_value=2_000),
+ summary_chunk_overlap_chars=_env_int("LAZIER_SUMMARY_CHUNK_OVERLAP_CHARS", 1_400, min_value=0),
+ alt_stt_enabled=_env_bool("LAZIER_ALT_STT_ENABLED", False),
)
return _cached_config
diff --git a/lazier/core/long_summary.py b/lazier/core/long_summary.py
@@ -0,0 +1,48 @@
+"""
+Chunking com overlap para sumarizacao hierarquica (map-reduce).
+
+Evita perda de contexto nos limites entre chunks quando o texto excede o limiar
+configurado em ModelConfig.
+"""
+
+from __future__ import annotations
+
+from typing import List
+
+
+def chunk_text_with_overlap(text: str, chunk_size: int, overlap: int) -> List[str]:
+ """Divide texto em janelas sobrepostas.
+
+ Args:
+ text: texto completo.
+ chunk_size: tamanho maximo de cada chunk (caracteres).
+ overlap: caracteres repetidos entre chunk consecutivos.
+
+ Returns:
+ Lista de chunks nao vazios; um unico elemento se text cabe em chunk_size.
+ """
+
+ if not text or not text.strip():
+ return []
+
+ stripped = text.strip()
+ if chunk_size <= 0:
+ return [stripped]
+
+ safe_overlap = max(0, min(overlap, max(0, chunk_size // 2)))
+ step = max(1, chunk_size - safe_overlap)
+
+ if len(stripped) <= chunk_size:
+ return [stripped]
+
+ chunks: List[str] = []
+ pos = 0
+ n = len(stripped)
+ while pos < n:
+ end = min(pos + chunk_size, n)
+ chunks.append(stripped[pos:end])
+ if end >= n:
+ break
+ pos += step
+
+ return chunks
diff --git a/lazier/core/processing.py b/lazier/core/processing.py
@@ -14,14 +14,16 @@ Inclui as etapas de:
from __future__ import annotations
+import logging
import os
+import time
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from .cache import calculate_file_hash, calculate_string_hash, calculate_url_hash, get_cache_manager
from .chapters import build_chapters
-from .config import get_model_config
+from .config import VALID_REASONING_EFFORTS, get_model_config, get_preset_model_defaults
from .content_type import detect_content_type
from .exceptions import MusicContentError
from .jobs import build_job_artifact_path, get_outputs_root
@@ -42,6 +44,8 @@ ProgressCallback = Optional[Callable[[int, str, Optional[str]], None]]
MEDIA_INPUT_TYPES = {"audio", "video", "youtube"}
+_pipeline_log = logging.getLogger("lazier.pipeline")
+
def _notify(callback: ProgressCallback, progress: int, status: str, message: Optional[str] = None) -> None:
if callback:
@@ -69,19 +73,42 @@ def _ensure_mode(mode: str) -> str:
return mode
+def _pipeline_trace(job_id: Optional[str], step: str, **fields: Any) -> None:
+ if not job_id:
+ return
+ payload = {"job_id": job_id, "step": step, **fields}
+ _pipeline_log.info("pipeline_trace %s", payload)
+
+
def _resolve_runtime(
model: Optional[str],
gpt_model: Optional[str],
use_smart_summary: Optional[bool],
use_chapters: Optional[bool],
+ *,
+ quality_preset: Optional[str] = None,
) -> Dict[str, Any]:
config = get_model_config()
+ if quality_preset:
+ pd = get_preset_model_defaults(quality_preset)
+ transcribe_model = model or pd["transcribe_model"]
+ chat_model = gpt_model or pd["chat_model"]
+ reasoning_effort = pd["reasoning_effort"]
+ if reasoning_effort not in VALID_REASONING_EFFORTS:
+ reasoning_effort = config.reasoning_effort
+ else:
+ transcribe_model = model or config.transcribe_model
+ chat_model = gpt_model or config.chat_model
+ reasoning_effort = config.reasoning_effort
+
return {
- "transcribe_model": model or config.transcribe_model,
+ "transcribe_model": transcribe_model,
"transcribe_timestamps_model": config.transcribe_timestamps_model,
- "chat_model": gpt_model or config.chat_model,
+ "chat_model": chat_model,
"smart_summary": config.enable_smart_summary if use_smart_summary is None else use_smart_summary,
"chapters_enabled": config.enable_chapters if use_chapters is None else use_chapters,
+ "reasoning_effort": reasoning_effort,
+ "quality_preset": quality_preset or config.quality_preset,
}
@@ -118,9 +145,11 @@ def _transcribe_media(
progress_callback: ProgressCallback,
progress_start: int = 30,
progress_end: int = 70,
+ trace_job_id: Optional[str] = None,
) -> Tuple[str, List[Dict[str, Any]]]:
"""Transcreve audio retornando (texto_pt, segments). Usa cache + render PT."""
+ t_tx = time.perf_counter()
cache = _get_cache_manager_safe()
file_hash = calculate_file_hash(audio_file)
chapters_enabled = runtime["chapters_enabled"]
@@ -128,6 +157,11 @@ def _transcribe_media(
cached = cache.get(cache_prefix, file_hash) if cache else None
if cached and cached.get("transcription"):
_notify(progress_callback, progress_end, "processing", "Transcricao encontrada no cache")
+ _pipeline_trace(
+ trace_job_id,
+ "transcription_cache_hit",
+ elapsed_ms=int((time.perf_counter() - t_tx) * 1000),
+ )
return cached["transcription"], cached.get("segments", []) or []
midpoint = progress_start + ((progress_end - progress_start) // 2)
@@ -147,7 +181,11 @@ def _transcribe_media(
raw_text = transcribe_audio(audio_file, language=None, model=runtime["transcribe_model"])
_notify(progress_callback, midpoint, "processing", "Convertendo conteudo para portugues...")
- portuguese_text = render_text_in_portuguese(raw_text, model=runtime["chat_model"])
+ portuguese_text = render_text_in_portuguese(
+ raw_text,
+ model=runtime["chat_model"],
+ reasoning_effort=runtime["reasoning_effort"],
+ )
if cache:
cache.set(
@@ -162,6 +200,13 @@ def _transcribe_media(
)
_notify(progress_callback, progress_end, "processing", "Transcricao concluida")
+ _pipeline_trace(
+ trace_job_id,
+ "transcription_done",
+ elapsed_ms=int((time.perf_counter() - t_tx) * 1000),
+ transcribe_model=runtime["transcribe_model"],
+ timestamps=chapters_enabled,
+ )
return portuguese_text, segments
@@ -189,6 +234,7 @@ def _build_summary(
progress_callback: ProgressCallback,
progress_start: int = 75,
progress_end: int = 88,
+ trace_job_id: Optional[str] = None,
) -> Optional[str]:
"""Gera sumario (estruturado ou textual) com cache. Atualiza metadata."""
@@ -211,11 +257,13 @@ def _build_summary(
if smart_enabled:
_notify(progress_callback, progress_start, "processing", "Gerando sumario inteligente...")
+ t_sm = time.perf_counter()
try:
smart = summarize_smart(
text,
model=runtime["chat_model"],
content_type=metadata.get("content_type"),
+ reasoning_effort=runtime["reasoning_effort"],
)
metadata["smart_summary"] = smart
summary_text = format_smart_summary_as_text(smart)
@@ -229,13 +277,33 @@ def _build_summary(
"timestamp": datetime.now().isoformat(),
},
)
+ _pipeline_trace(
+ trace_job_id,
+ "summary_smart_done",
+ elapsed_ms=int((time.perf_counter() - t_sm) * 1000),
+ model=runtime["chat_model"],
+ chars=len(text),
+ )
_notify(progress_callback, progress_end, "processing", "Sumario inteligente concluido")
return summary_text
except Exception as exc:
print(f"Aviso: smart summary falhou ({exc}); caindo para sumario legado.")
_notify(progress_callback, progress_start, "processing", "Gerando sumario em portugues...")
- summary = summarize_text(text, model=runtime["chat_model"], language="pt-BR")
+ t_legacy = time.perf_counter()
+ summary = summarize_text(
+ text,
+ model=runtime["chat_model"],
+ language="pt-BR",
+ reasoning_effort=runtime["reasoning_effort"],
+ )
+ _pipeline_trace(
+ trace_job_id,
+ "summary_legacy_done",
+ elapsed_ms=int((time.perf_counter() - t_legacy) * 1000),
+ model=runtime["chat_model"],
+ chars=len(text),
+ )
if cache:
cache.set(
"summary",
@@ -260,6 +328,7 @@ def _build_chapters_if_possible(
segments,
model=runtime["chat_model"],
content_type=metadata.get("content_type"),
+ reasoning_effort=runtime["reasoning_effort"],
)
if chapters:
metadata["chapters"] = chapters
@@ -275,6 +344,8 @@ def process_source(
gpt_model: Optional[str] = None,
use_smart_summary: Optional[bool] = None,
use_chapters: Optional[bool] = None,
+ quality_preset: Optional[str] = None,
+ trace_job_id: Optional[str] = None,
output_path: Optional[str] = None,
output_root: Optional[Path] = None,
run_id: Optional[str] = None,
@@ -287,8 +358,14 @@ def process_source(
_ensure_api_key()
mode = _ensure_mode(mode)
- runtime = _resolve_runtime(model, gpt_model, use_smart_summary, use_chapters)
-
+ runtime = _resolve_runtime(
+ model,
+ gpt_model,
+ use_smart_summary,
+ use_chapters,
+ quality_preset=quality_preset,
+ )
+ t_pipeline = time.perf_counter()
_notify(progress_callback, 5, "processing", "Validando entrada...")
is_valid, input_type, error_msg = validate_input(source)
if not is_valid:
@@ -303,6 +380,19 @@ def process_source(
try:
output_root = output_root or get_outputs_root()
+ run_id = run_id or datetime.now().strftime("%Y%m%d%H%M%S%f")
+ trace_id = trace_job_id or run_id
+ _pipeline_trace(
+ trace_id,
+ "pipeline_start",
+ mode=mode,
+ quality_preset=runtime["quality_preset"],
+ chat_model=runtime["chat_model"],
+ transcribe_model=runtime["transcribe_model"],
+ reasoning_effort=runtime["reasoning_effort"],
+ smart_summary=runtime["smart_summary"],
+ chapters_enabled=runtime["chapters_enabled"],
+ )
if input_type == "youtube":
url_hash = calculate_url_hash(source) if cache else ""
@@ -326,6 +416,7 @@ def process_source(
runtime=runtime,
metadata=metadata,
progress_callback=progress_callback,
+ trace_job_id=trace_id,
)
elif input_type == "web":
@@ -351,6 +442,7 @@ def process_source(
runtime=runtime,
metadata=metadata,
progress_callback=progress_callback,
+ trace_job_id=trace_id,
)
except MusicContentError:
raise
@@ -371,7 +463,9 @@ def process_source(
"webpage_url": source,
}
portuguese_text = render_text_in_portuguese(
- content_data["content"], model=runtime["chat_model"]
+ content_data["content"],
+ model=runtime["chat_model"],
+ reasoning_effort=runtime["reasoning_effort"],
)
_notify(progress_callback, 70, "processing", "Texto convertido para portugues")
@@ -385,6 +479,7 @@ def process_source(
runtime=runtime,
metadata=metadata,
progress_callback=progress_callback,
+ trace_job_id=trace_id,
)
elif input_type in {"pdf", "text"}:
@@ -398,7 +493,9 @@ def process_source(
"file_path": source,
}
portuguese_text = render_text_in_portuguese(
- content_data["content"], model=runtime["chat_model"]
+ content_data["content"],
+ model=runtime["chat_model"],
+ reasoning_effort=runtime["reasoning_effort"],
)
_notify(progress_callback, 70, "processing", "Conteudo convertido para portugues")
@@ -424,6 +521,7 @@ def process_source(
metadata=metadata,
runtime=runtime,
progress_callback=progress_callback,
+ trace_job_id=trace_id,
)
elif mode == "summarize" and summary and runtime["smart_summary"] and not metadata.get("smart_summary"):
# Cache antigo guardou apenas summary textual; reaproveitamos como tldr.
@@ -479,7 +577,6 @@ def process_source(
},
)
- run_id = run_id or datetime.now().strftime("%Y%m%d%H%M%S%f")
resolved_source_name = source_name or metadata.get("title") or source
if output_path:
@@ -523,6 +620,14 @@ def process_source(
"summary_path": exported_path if mode == "summarize" else None,
"output_dir": output_dir,
}
+ _pipeline_trace(
+ trace_id,
+ "pipeline_complete",
+ elapsed_ms=int((time.perf_counter() - t_pipeline) * 1000),
+ input_type=input_type,
+ mode=mode,
+ has_summary=bool(summary),
+ )
_notify(progress_callback, 100, "completed", "Processamento concluido")
return result
finally:
diff --git a/lazier/summarizer.py b/lazier/summarizer.py
@@ -36,7 +36,7 @@ except ImportError: # pragma: no cover - pydantic indisponivel
return None
from .web.extractor import extract_pdf_content, extract_text_file_content, extract_web_content
-from .core.config import get_model_config
+from .core.config import VALID_REASONING_EFFORTS, get_model_config
load_dotenv()
@@ -181,6 +181,7 @@ def _chat_completions_kwargs(
messages: List[Dict[str, Any]],
response_format: Optional[Dict[str, Any]] = None,
temperature: Optional[float] = None,
+ reasoning_effort: Optional[str] = None,
) -> Dict[str, Any]:
"""Monta kwargs respeitando peculiaridades por familia de modelo."""
@@ -188,8 +189,11 @@ def _chat_completions_kwargs(
kwargs: Dict[str, Any] = {"model": model, "messages": messages}
if response_format is not None:
kwargs["response_format"] = response_format
+ effort = (reasoning_effort if reasoning_effort is not None else config.reasoning_effort).lower()
+ if effort not in VALID_REASONING_EFFORTS:
+ effort = config.reasoning_effort
if config.supports_reasoning(model):
- kwargs["reasoning_effort"] = config.reasoning_effort
+ kwargs["reasoning_effort"] = effort
elif temperature is not None:
# Modelos que nao sao da familia gpt-5/o aceitam temperature normalmente.
kwargs["temperature"] = temperature
@@ -200,7 +204,11 @@ def _chat_completions_kwargs(
# Conversao para PT-BR (legacy mantido)
# ---------------------------------------------------------------------------
-def render_text_in_portuguese(text: str, model: Optional[str] = None) -> str:
+def render_text_in_portuguese(
+ text: str,
+ model: Optional[str] = None,
+ reasoning_effort: Optional[str] = None,
+) -> str:
"""Converte qualquer texto para portugues do Brasil preservando detalhes."""
if not text or not text.strip():
@@ -211,18 +219,18 @@ def render_text_in_portuguese(text: str, model: Optional[str] = None) -> str:
max_chars = DEFAULT_CHUNK_CHAR_LIMIT
if len(text) <= max_chars:
- return _render_portuguese_chunk(text, chosen_model)
+ return _render_portuguese_chunk(text, chosen_model, reasoning_effort=reasoning_effort)
chunks = _split_text_into_chunks(text, max_chars)
rendered_chunks: List[str] = []
print(f"Texto longo detectado ({len(text)} caracteres). Convertendo {len(chunks)} partes para portugues...")
for i, chunk in enumerate(chunks):
print(f"Convertendo parte {i + 1}/{len(chunks)}...")
- rendered_chunks.append(_render_portuguese_chunk(chunk, chosen_model))
+ rendered_chunks.append(_render_portuguese_chunk(chunk, chosen_model, reasoning_effort=reasoning_effort))
return "\n\n".join(chunk.strip() for chunk in rendered_chunks if chunk.strip())
-def _render_portuguese_chunk(text: str, model: str) -> str:
+def _render_portuguese_chunk(text: str, model: str, reasoning_effort: Optional[str] = None) -> str:
client = _ensure_client()
prompt = (
"Converta o texto a seguir para portugues do Brasil.\n\n"
@@ -245,6 +253,7 @@ def _render_portuguese_chunk(text: str, model: str) -> str:
{"role": "user", "content": prompt + text},
],
temperature=0.1,
+ reasoning_effort=reasoning_effort,
)
response = client.chat.completions.create(**kwargs)
return (response.choices[0].message.content or "").strip()
@@ -256,7 +265,12 @@ def _render_portuguese_chunk(text: str, model: str) -> str:
# Sumario textual legado
# ---------------------------------------------------------------------------
-def summarize_text(text: str, model: Optional[str] = None, language: str = "pt-BR") -> str:
+def summarize_text(
+ text: str,
+ model: Optional[str] = None,
+ language: str = "pt-BR",
+ reasoning_effort: Optional[str] = None,
+) -> str:
"""Sumariza texto em formato livre (caminho legado)."""
if not text or not text.strip():
@@ -264,10 +278,51 @@ def summarize_text(text: str, model: Optional[str] = None, language: str = "pt-B
config = get_model_config()
chosen_model = model or config.chat_model
+
+ use_hier = config.hierarchical_summary and len(text) > config.summary_direct_max_chars
+ if use_hier:
+ from .core.long_summary import chunk_text_with_overlap
+
+ chunks = chunk_text_with_overlap(
+ text.strip(),
+ config.summary_map_chunk_chars,
+ config.summary_chunk_overlap_chars,
+ )
+ if not chunks:
+ return "Texto vazio - nao e possivel gerar sumario."
+ if len(chunks) == 1:
+ return _summarize_chunk(chunks[0], chosen_model, language, reasoning_effort=reasoning_effort)
+ chunk_summaries: List[str] = []
+ print(
+ f"Sumario legado hierarquico: {len(text)} caracteres -> {len(chunks)} chunks "
+ f"(overlap {config.summary_chunk_overlap_chars})."
+ )
+ for i, chunk in enumerate(chunks):
+ try:
+ print(f"Sumarizando parte {i + 1}/{len(chunks)}...")
+ chunk_summaries.append(
+ _summarize_chunk(chunk, chosen_model, language, reasoning_effort=reasoning_effort)
+ )
+ except Exception as exc:
+ print(f"Erro ao sumarizar parte {i + 1}: {exc}")
+ chunk_summaries.append(f"[Erro nesta parte: {exc}]")
+
+ valid_summaries = [s for s in chunk_summaries if not s.startswith("[Erro")]
+ if not valid_summaries:
+ return "Falha ao gerar sumario: todas as partes falharam."
+ combined = "\n\n".join(valid_summaries)
+ return _summarize_chunk(
+ combined,
+ chosen_model,
+ language,
+ is_final=True,
+ reasoning_effort=reasoning_effort,
+ )
+
max_chars = 400_000
if len(text) <= max_chars:
- return _summarize_chunk(text, chosen_model, language)
+ return _summarize_chunk(text, chosen_model, language, reasoning_effort=reasoning_effort)
chunks = _split_text_into_chunks(text, max_chars)
chunk_summaries: List[str] = []
@@ -275,7 +330,7 @@ def summarize_text(text: str, model: Optional[str] = None, language: str = "pt-B
for i, chunk in enumerate(chunks):
try:
print(f"Sumarizando parte {i + 1}/{len(chunks)}...")
- chunk_summaries.append(_summarize_chunk(chunk, chosen_model, language))
+ chunk_summaries.append(_summarize_chunk(chunk, chosen_model, language, reasoning_effort=reasoning_effort))
except Exception as exc:
print(f"Erro ao sumarizar parte {i + 1}: {exc}")
chunk_summaries.append(f"[Erro nesta parte: {exc}]")
@@ -286,11 +341,23 @@ def summarize_text(text: str, model: Optional[str] = None, language: str = "pt-B
if len(valid_summaries) > 1:
print("Consolidando sumarios parciais...")
combined = "\n\n".join(valid_summaries)
- return _summarize_chunk(combined, chosen_model, language, is_final=True)
+ return _summarize_chunk(
+ combined,
+ chosen_model,
+ language,
+ is_final=True,
+ reasoning_effort=reasoning_effort,
+ )
return valid_summaries[0]
-def _summarize_chunk(text: str, model: str, language: str, is_final: bool = False) -> str:
+def _summarize_chunk(
+ text: str,
+ model: str,
+ language: str,
+ is_final: bool = False,
+ reasoning_effort: Optional[str] = None,
+) -> str:
client = _ensure_client()
if language.startswith("pt"):
@@ -328,6 +395,7 @@ def _summarize_chunk(text: str, model: str, language: str, is_final: bool = Fals
{"role": "user", "content": prompt + text},
],
temperature=0.2,
+ reasoning_effort=reasoning_effort,
)
response = client.chat.completions.create(**kwargs)
return (response.choices[0].message.content or "").strip()
@@ -345,6 +413,7 @@ def summarize_smart(
model: Optional[str] = None,
content_type: Optional[str] = None,
language: str = "pt-BR",
+ reasoning_effort: Optional[str] = None,
) -> Dict[str, Any]:
"""Gera sumario estruturado e devolve dicionario serializavel.
@@ -362,17 +431,80 @@ def summarize_smart(
config = get_model_config()
chosen_model = model or config.chat_model
+ use_hier = config.hierarchical_summary and len(text) > config.summary_direct_max_chars
+ if use_hier:
+ from .core.long_summary import chunk_text_with_overlap
+
+ chunks = chunk_text_with_overlap(
+ text.strip(),
+ config.summary_map_chunk_chars,
+ config.summary_chunk_overlap_chars,
+ )
+ if not chunks:
+ return SmartSummary(tldr="Texto vazio - nao e possivel gerar sumario.").model_dump()
+ print(
+ f"Sumario inteligente hierarquico: {len(text)} chars -> {len(chunks)} chunks "
+ f"(overlap {config.summary_chunk_overlap_chars})."
+ )
+ if len(chunks) == 1:
+ return _summarize_smart_chunk(
+ chunks[0],
+ chosen_model,
+ content_type,
+ language,
+ reasoning_effort=reasoning_effort,
+ ).model_dump()
+ parciais_h: List[SmartSummary] = []
+ for index, chunk in enumerate(chunks):
+ print(f"Sumario inteligente parte {index + 1}/{len(chunks)}...")
+ try:
+ parciais_h.append(
+ _summarize_smart_chunk(
+ chunk,
+ chosen_model,
+ content_type,
+ language,
+ reasoning_effort=reasoning_effort,
+ )
+ )
+ except Exception as exc:
+ print(f"Erro ao sumarizar parte {index + 1}: {exc}")
+ if not parciais_h:
+ return SmartSummary(tldr="Falha ao gerar sumario: todas as partes falharam.").model_dump()
+ if len(parciais_h) == 1:
+ return parciais_h[0].model_dump()
+ return _merge_smart_summaries(
+ parciais_h,
+ chosen_model,
+ content_type,
+ reasoning_effort=reasoning_effort,
+ ).model_dump()
+
chunks = _split_text_into_chunks(text, DEFAULT_CHUNK_CHAR_LIMIT) if len(text) > DEFAULT_CHUNK_CHAR_LIMIT else [text]
if len(chunks) == 1:
- return _summarize_smart_chunk(chunks[0], chosen_model, content_type, language).model_dump()
+ return _summarize_smart_chunk(
+ chunks[0],
+ chosen_model,
+ content_type,
+ language,
+ reasoning_effort=reasoning_effort,
+ ).model_dump()
print(f"Texto longo detectado ({len(text)} chars) - rodando sumario inteligente em {len(chunks)} partes.")
parciais: List[SmartSummary] = []
for index, chunk in enumerate(chunks):
print(f"Sumario inteligente parte {index + 1}/{len(chunks)}...")
try:
- parciais.append(_summarize_smart_chunk(chunk, chosen_model, content_type, language))
+ parciais.append(
+ _summarize_smart_chunk(
+ chunk,
+ chosen_model,
+ content_type,
+ language,
+ reasoning_effort=reasoning_effort,
+ )
+ )
except Exception as exc:
print(f"Erro ao sumarizar parte {index + 1}: {exc}")
@@ -382,7 +514,12 @@ def summarize_smart(
if len(parciais) == 1:
return parciais[0].model_dump()
- return _merge_smart_summaries(parciais, chosen_model, content_type).model_dump()
+ return _merge_smart_summaries(
+ parciais,
+ chosen_model,
+ content_type,
+ reasoning_effort=reasoning_effort,
+ ).model_dump()
def _build_smart_messages(
@@ -433,6 +570,8 @@ def _summarize_smart_chunk(
model: str,
content_type: Optional[str],
language: str,
+ *,
+ reasoning_effort: Optional[str] = None,
) -> SmartSummary:
client = _ensure_client()
messages = _build_smart_messages(text, content_type, language, is_merge=False)
@@ -443,6 +582,7 @@ def _summarize_smart_chunk(
messages=messages,
response_format={"type": "json_schema", "json_schema": SMART_SUMMARY_JSON_SCHEMA},
temperature=0.2,
+ reasoning_effort=reasoning_effort,
)
response = client.chat.completions.create(**kwargs)
raw = response.choices[0].message.content or "{}"
@@ -450,13 +590,15 @@ def _summarize_smart_chunk(
except Exception as exc:
# Fallback: pede JSON livre e tenta parsear.
print(f"Aviso: structured outputs falhou ({exc}); tentando json_object...")
- return _summarize_smart_fallback_json(client, model, messages)
+ return _summarize_smart_fallback_json(client, model, messages, reasoning_effort=reasoning_effort)
def _summarize_smart_fallback_json(
client: "OpenAI",
model: str,
messages: List[Dict[str, Any]],
+ *,
+ reasoning_effort: Optional[str] = None,
) -> SmartSummary:
fallback_messages = list(messages)
fallback_messages[0] = {
@@ -472,6 +614,7 @@ def _summarize_smart_fallback_json(
messages=fallback_messages,
response_format={"type": "json_object"},
temperature=0.2,
+ reasoning_effort=reasoning_effort,
)
response = client.chat.completions.create(**kwargs)
raw = response.choices[0].message.content or "{}"
@@ -485,6 +628,7 @@ def _summarize_smart_fallback_json(
messages=fallback_messages,
response_format=None,
temperature=0.2,
+ reasoning_effort=reasoning_effort,
)
response = client.chat.completions.create(**text_only_kwargs)
free_text = (response.choices[0].message.content or "").strip()
@@ -497,6 +641,8 @@ def _merge_smart_summaries(
parciais: List[SmartSummary],
model: str,
content_type: Optional[str],
+ *,
+ reasoning_effort: Optional[str] = None,
) -> SmartSummary:
client = _ensure_client()
serialized = json.dumps([p.model_dump() for p in parciais], ensure_ascii=False, indent=2)
@@ -508,6 +654,7 @@ def _merge_smart_summaries(
messages=messages,
response_format={"type": "json_schema", "json_schema": SMART_SUMMARY_JSON_SCHEMA},
temperature=0.2,
+ reasoning_effort=reasoning_effort,
)
response = client.chat.completions.create(**kwargs)
raw = response.choices[0].message.content or "{}"
diff --git a/lazier/web/templates/index.html b/lazier/web/templates/index.html
@@ -347,6 +347,15 @@
<label for="formatSelect">Formato</label>
<select id="formatSelect"><option value="docx">DOCX</option><option value="txt">TXT</option><option value="md">Markdown</option><option value="json">JSON</option><option value="pdf">PDF</option></select>
</div>
+ <div class="field">
+ <label for="presetSelect">Qualidade</label>
+ <select id="presetSelect">
+ <option value="">Predefinição (.env)</option>
+ <option value="economico">Económico</option>
+ <option value="equilibrado">Equilibrado</option>
+ <option value="maximo">Máximo</option>
+ </select>
+ </div>
<button class="btn" id="processBtn" onclick="processFiles()">Processar</button>
</div>
</div>
@@ -444,12 +453,15 @@
async function processFiles() {
const button = document.getElementById('processBtn');
const format = document.getElementById('formatSelect').value;
+ const preset = document.getElementById('presetSelect').value;
const url = document.getElementById('urlInput').value.trim();
button.disabled = true;
button.textContent = 'A processar…';
try {
if (url) {
- const response = await fetch('/api/process', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ url, format, mode: processingMode }), credentials:'include' });
+ const payload = { url, format, mode: processingMode };
+ if (preset) payload.quality_preset = preset;
+ const response = await fetch('/api/process', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload), credentials:'include' });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Falha ao processar URL');
addJob(data.job_id, url, processingMode);
@@ -459,6 +471,7 @@
selectedFiles.forEach((file) => formData.append('files', file));
formData.append('format', format);
formData.append('mode', processingMode);
+ if (preset) formData.append('quality_preset', preset);
const response = await fetch('/api/upload', { method:'POST', body:formData, credentials:'include' });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Falha ao enviar ficheiros');
@@ -483,11 +496,45 @@
function renderActions(job) {
if (job.status !== 'completed' && job.status !== 'interrupted') return '';
+ const id = job.id;
let html = '<div class="actions">';
- if (job.result_path) html += `<a class="action" href="/api/jobs/${job.id}/download">Principal</a>`;
- if (job.has_transcription) html += `<a class="action" href="/api/jobs/${job.id}/transcription">Transcrição</a>`;
- if (job.has_summary) html += `<a class="action" href="/api/jobs/${job.id}/summary">Sumário</a>`;
- html += `<button type="button" class="action" onclick="viewJobDetails('${job.id}')">Visualizar</button></div>`;
+ const hrefTx = `/api/jobs/${id}/transcription`;
+ const hrefSum = `/api/jobs/${id}/summary`;
+ const hrefDl = `/api/jobs/${id}/download`;
+ const hrefBundle = `/api/jobs/${id}/download-bundle`;
+
+ if (job.has_transcription && job.has_summary) {
+ html += `<a class="action" href="${hrefBundle}">Tudo</a>`;
+ }
+
+ if (job.mode === 'transcribe') {
+ if (job.has_transcription || job.result_path) {
+ html += `<a class="action" href="${hrefTx}">Transcrição</a>`;
+ }
+ if (job.has_summary) {
+ html += `<a class="action" href="${hrefSum}">Sumário</a>`;
+ }
+ } else if (job.mode === 'summarize') {
+ if (job.has_summary || job.result_path) {
+ html += `<a class="action" href="${hrefSum}">Sumário</a>`;
+ }
+ if (job.has_transcription) {
+ html += `<a class="action" href="${hrefTx}">Transcrição completa</a>`;
+ }
+ } else {
+ if (job.has_summary && !job.has_transcription) {
+ html += `<a class="action" href="${hrefSum}">Sumário</a>`;
+ } else if (job.has_transcription && !job.has_summary) {
+ html += `<a class="action" href="${hrefTx}">Transcrição</a>`;
+ } else if (job.has_transcription && job.has_summary) {
+ html += `<a class="action" href="${hrefTx}">Transcrição completa</a>`;
+ html += `<a class="action" href="${hrefSum}">Sumário</a>`;
+ } else if (job.result_path) {
+ html += `<a class="action" href="${hrefDl}">Download</a>`;
+ }
+ }
+
+ html += `<button type="button" class="action" onclick="viewJobDetails('${id}')">Visualizar</button></div>`;
return html;
}
@@ -570,8 +617,14 @@
if (!response.ok) throw new Error(data.detail || 'Erro ao carregar detalhes');
document.getElementById('previewTitle').textContent = data.metadata?.title || `Job ${jobId}`;
let content = '';
- if (data.summary) { content += '<h3>Sumário</h3>' + renderPreview(data.summary, data.format) + '<hr>'; }
- if (data.transcription) { content += '<h3>Transcrição</h3>' + renderPreview(data.transcription, data.format); }
+ const summarizeFirst = data.mode === 'summarize';
+ if (summarizeFirst) {
+ if (data.summary) { content += '<h3>Sumário</h3>' + renderPreview(data.summary, data.format) + '<hr>'; }
+ if (data.transcription) { content += '<h3>Transcrição</h3>' + renderPreview(data.transcription, data.format); }
+ } else {
+ if (data.transcription) { content += '<h3>Transcrição</h3>' + renderPreview(data.transcription, data.format) + '<hr>'; }
+ if (data.summary) { content += '<h3>Sumário</h3>' + renderPreview(data.summary, data.format); }
+ }
document.getElementById('previewContent').innerHTML = content || '<div class="empty">Sem conteúdo persistido.</div>';
document.getElementById('previewModal').classList.add('open');
} catch (error) {
diff --git a/tests/fixtures/golden_pt_br/reuniao_curta.txt b/tests/fixtures/golden_pt_br/reuniao_curta.txt
@@ -0,0 +1,14 @@
+Ata sintética — produto interno (PT-BR)
+
+Participantes: Ana (PM), Bruno (engenharia), Carla (design).
+
+Decisões:
+1. Adiar o lançamento da feature X para a sprint seguinte por risco de regressão no checkout.
+2. Priorizar correção do bug #442 (timeout em uploads > 20 MB).
+
+Action items:
+- Bruno: preparar patch até quarta-feira.
+- Carla: validar fluxo com usuários internos na sexta-feira.
+- Ana: comunicar stakeholders sobre o adiamento.
+
+Perguntas em aberto: definir SLA para retries do serviço de pagamentos.
diff --git a/tests/test_api.py b/tests/test_api.py
@@ -1,8 +1,10 @@
import importlib
+import io
import os
import shutil
import unittest
import uuid
+import zipfile
from pathlib import Path
from unittest.mock import patch
@@ -22,8 +24,10 @@ class ApiTests(unittest.TestCase):
os.environ["LAZIER_UPLOAD_DIR"] = str(self.temp_dir / "uploads")
os.environ["LAZIER_OUTPUT_DIR"] = str(self.temp_dir / "outputs")
+ import lazier.api.routes as routes_module
import lazier.api.main as main_module
+ importlib.reload(routes_module)
self.main_module = importlib.reload(main_module)
self.client = TestClient(self.main_module.create_app())
@@ -75,6 +79,47 @@ class ApiTests(unittest.TestCase):
download = self.client.get(f"/api/jobs/{job_id}/download")
self.assertEqual(download.status_code, 200)
+ def test_download_bundle_returns_zip_with_two_members(self):
+ output_dir = Path(os.environ["LAZIER_OUTPUT_DIR"]) / "2026" / "04" / "01" / "bundle-job"
+ output_dir.mkdir(parents=True, exist_ok=True)
+ tx_path = output_dir / "transcricao.txt"
+ sm_path = output_dir / "sumario.txt"
+ tx_path.write_text("Linha da transcricao", encoding="utf-8")
+ sm_path.write_text("Linha do sumario", encoding="utf-8")
+
+ with patch(
+ "lazier.api.routes.process_source",
+ return_value={
+ "mode": "summarize",
+ "input_type": "audio",
+ "source_name": "episode.mp3",
+ "metadata": {"title": "Episode"},
+ "transcription": "Linha da transcricao",
+ "summary": "Linha do sumario",
+ "result_path": str(sm_path),
+ "transcription_path": str(tx_path),
+ "summary_path": str(sm_path),
+ },
+ ):
+ response = self.client.post(
+ "/api/upload",
+ files={"files": ("episode.mp3", b"fake-audio", "audio/mpeg")},
+ data={"format": "txt", "mode": "summarize"},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ job_id = response.json()["job_ids"][0]
+
+ bundle = self.client.get(f"/api/jobs/{job_id}/download-bundle")
+ self.assertEqual(bundle.status_code, 200)
+ self.assertIn("zip", bundle.headers.get("content-type", "").lower())
+
+ with zipfile.ZipFile(io.BytesIO(bundle.content)) as zf:
+ names = sorted(zf.namelist())
+ self.assertEqual(len(names), 2)
+ self.assertIn("transcricao.txt", names)
+ self.assertIn("sumario.txt", names)
+
def test_process_passes_overrides_to_pipeline(self):
output_dir = Path(os.environ["LAZIER_OUTPUT_DIR"]) / "2026" / "05" / "03" / "smart-job"
output_dir.mkdir(parents=True, exist_ok=True)
@@ -125,6 +170,7 @@ class ApiTests(unittest.TestCase):
"mode": "summarize",
"chat_model": "gpt-5",
"transcribe_model": "gpt-4o-transcribe",
+ "quality_preset": "economico",
"smart": True,
"chapters": True,
},
@@ -136,6 +182,8 @@ class ApiTests(unittest.TestCase):
kwargs = mock_process.call_args.kwargs
self.assertEqual(kwargs.get("gpt_model"), "gpt-5")
self.assertEqual(kwargs.get("model"), "gpt-4o-transcribe")
+ self.assertEqual(kwargs.get("quality_preset"), "economico")
+ self.assertEqual(kwargs.get("trace_job_id"), job_id)
self.assertTrue(kwargs.get("use_smart_summary"))
self.assertTrue(kwargs.get("use_chapters"))
@@ -202,7 +250,13 @@ class ApiTests(unittest.TestCase):
"summary_path": None,
}
- with patch("lazier.api.routes.process_source", return_value=fake_result):
+ extracted = self.temp_dir / "extracted-from-mpeg.wav"
+ extracted.write_bytes(b"x")
+
+ with patch("lazier.api.routes.process_source", return_value=fake_result), patch(
+ "lazier.api.routes.extract_audio_from_video",
+ return_value=str(extracted),
+ ):
r1 = self.client.post(
"/api/upload",
files={"files": ("clip.mpeg", b"fake", "video/mpeg")},
diff --git a/tests/test_golden_pt_br_smoke.py b/tests/test_golden_pt_br_smoke.py
@@ -0,0 +1,23 @@
+"""Smoke tests com texto PT-BR fixo (golden minimo, sem chamadas live a OpenAI)."""
+
+from __future__ import annotations
+
+import unittest
+from pathlib import Path
+
+
+FIXTURE = Path(__file__).resolve().parent / "fixtures" / "golden_pt_br" / "reuniao_curta.txt"
+
+
+class GoldenPtBrSmokeTests(unittest.TestCase):
+ def test_fixture_exists_and_has_meeting_markers(self):
+ self.assertTrue(FIXTURE.is_file(), f"Fixture em falta: {FIXTURE}")
+ text = FIXTURE.read_text(encoding="utf-8")
+ lowered = text.lower()
+ self.assertIn("decis", lowered)
+ self.assertIn("action", lowered)
+ self.assertIn("bruno", lowered)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_long_summary.py b/tests/test_long_summary.py
@@ -0,0 +1,36 @@
+"""Testes do chunking com overlap para sumarizacao hierarquica."""
+
+from __future__ import annotations
+
+import unittest
+
+from lazier.core.long_summary import chunk_text_with_overlap
+
+
+class LongSummaryChunkTests(unittest.TestCase):
+ def test_single_chunk_when_text_fits(self):
+ text = "abc " * 10
+ chunks = chunk_text_with_overlap(text, chunk_size=500, overlap=50)
+ self.assertEqual(len(chunks), 1)
+ self.assertEqual(chunks[0].strip(), text.strip())
+
+ def test_overlap_creates_multiple_windows(self):
+ text = "".join(str(i % 10) for i in range(200))
+ chunks = chunk_text_with_overlap(text, chunk_size=80, overlap=20)
+ self.assertGreater(len(chunks), 1)
+ joined = "".join(chunks)
+ self.assertTrue(set(joined) <= set("0123456789"))
+
+ def test_overlap_capped_at_half_chunk(self):
+ text = "x" * 100
+ chunks = chunk_text_with_overlap(text, chunk_size=30, overlap=25)
+ # overlap efetivo <= 15 -> step >= 15, varios chunks
+ self.assertGreaterEqual(len(chunks), 3)
+
+ def test_empty_text_returns_empty_list(self):
+ self.assertEqual(chunk_text_with_overlap("", 100, 10), [])
+ self.assertEqual(chunk_text_with_overlap(" ", 100, 10), [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_smart_summary.py b/tests/test_smart_summary.py
@@ -11,6 +11,18 @@ from unittest.mock import MagicMock, patch
from lazier.core.config import reset_model_config_cache
+def _fake_summary_config():
+ cfg = MagicMock()
+ cfg.hierarchical_summary = True
+ cfg.summary_direct_max_chars = 120
+ cfg.summary_map_chunk_chars = 60
+ cfg.summary_chunk_overlap_chars = 10
+ cfg.chat_model = "gpt-5-mini"
+ cfg.reasoning_effort = "medium"
+ cfg.supports_reasoning = MagicMock(return_value=True)
+ return cfg
+
+
class _FakeChoice:
def __init__(self, content: str) -> None:
self.message = SimpleNamespace(content=content)
@@ -119,7 +131,7 @@ class SummarizeSmartTests(unittest.TestCase):
chunk_responses = [parcial_a, parcial_b]
- def _chunk_side_effect(_text, _model, _content_type, _language):
+ def _chunk_side_effect(_text, _model, _content_type, _language, reasoning_effort=None):
return chunk_responses.pop(0) if chunk_responses else parcial_b
long_text = ("paragrafo " * 50 + "\n\n") * 3
@@ -148,6 +160,40 @@ class SummarizeSmartTests(unittest.TestCase):
self.assertIn("Texto vazio", result["tldr"])
self.assertEqual(result["key_points"], [])
+ def test_smart_summary_hierarchical_triggers_merge(self):
+ """Com limiar baixo, usa chunks com overlap e merge final."""
+ from lazier import summarizer
+ from lazier.summarizer import summarize_smart, SmartSummary
+
+ parcial_a = SmartSummary(tldr="A", key_points=["1"])
+ parcial_b = SmartSummary(tldr="B", key_points=["2"])
+ merged = SmartSummary(tldr="Hier", key_points=["1", "2"])
+
+ chunk_responses = [parcial_a, parcial_b]
+
+ def _chunk_side_effect(_text, _model, _content_type, _language, reasoning_effort=None):
+ return chunk_responses.pop(0) if chunk_responses else parcial_b
+
+ long_text = ("Bloco de texto em portugues. " * 20).strip()
+ with patch.object(
+ summarizer,
+ "_summarize_smart_chunk",
+ side_effect=_chunk_side_effect,
+ ) as mock_chunk, patch.object(
+ summarizer,
+ "_merge_smart_summaries",
+ return_value=merged,
+ ) as mock_merge, patch.object(
+ summarizer,
+ "get_model_config",
+ return_value=_fake_summary_config(),
+ ):
+ result = summarize_smart(long_text)
+
+ self.assertEqual(result["tldr"], "Hier")
+ mock_merge.assert_called_once()
+ self.assertGreaterEqual(mock_chunk.call_count, 2)
+
if __name__ == "__main__":
unittest.main()