lazier

personal summarizer
Log | Files | Refs | README

commit 93d419ec0765d499ad28e463bbc2b034bdbcbc39
parent 808e4a594e22817556eb798cef3e7831dc158cd1
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Sat,  9 May 2026 22:21:42 -0300

remodelation

Diffstat:
M.env.example | 20++++++++++++++++++++
MREADME.md | 8++++++++
Mlazier/api/routes.py | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mlazier/cli.py | 18+++++++++++++++++-
Mlazier/core/chapters.py | 8++++++--
Mlazier/core/config.py | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Alazier/core/long_summary.py | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mlazier/core/processing.py | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mlazier/summarizer.py | 177++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mlazier/web/templates/index.html | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Atests/fixtures/golden_pt_br/reuniao_curta.txt | 14++++++++++++++
Mtests/test_api.py | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atests/test_golden_pt_br_smoke.py | 23+++++++++++++++++++++++
Atests/test_long_summary.py | 36++++++++++++++++++++++++++++++++++++
Mtests/test_smart_summary.py | 48+++++++++++++++++++++++++++++++++++++++++++++++-
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()