pm-assistant / modules /docs_tab.py
igorsteixeira
feat: gerador PPTX le links do Drive colados nas instrucoes
5413358
Raw
History Blame Contribute Delete
26.9 kB
import logging
import re
import streamlit as st
from modules.config import client_anthropic
from modules.utils import sanitizar, extrair_texto_arquivo, aprimorar_prompt
from modules.docgen import criar_docx, _gerar_com_continuacao
from modules.memoria import ler_arquivo_memoria
from modules.video_presentation import gerar_video_apresentacao
from modules.pptx_generator import (
gerar_pptx,
salvar_apresentacao_drive, listar_apresentacoes_drive, carregar_apresentacao_drive,
)
# Detecta URLs do Drive/Docs/Sheets/Slides em texto livre. Captura o ID
# em qualquer um dos 5 formatos comuns. Usado pra permitir que o usuario
# cole links no campo de instrucoes e o agente busque o conteudo automatico.
_RE_DRIVE_URLS = re.compile(
r'https?://(?:'
r'docs\.google\.com/document/d/([a-zA-Z0-9_-]+)'
r'|docs\.google\.com/spreadsheets/d/([a-zA-Z0-9_-]+)'
r'|docs\.google\.com/presentation/d/([a-zA-Z0-9_-]+)'
r'|drive\.google\.com/file/d/([a-zA-Z0-9_-]+)'
r'|drive\.google\.com/open\?id=([a-zA-Z0-9_-]+)'
r')'
)
def _carregar_arquivos_drive_de_urls(texto: str) -> tuple[str, list[str]]:
"""Detecta URLs de Drive/Docs/Sheets/Slides em texto livre, busca o
conteudo de cada arquivo via Drive API, e retorna (conteudo_concatenado,
lista_de_nomes). Limita cada arquivo a 15k chars pra nao estourar contexto.
"""
if not texto:
return "", []
ids_vistos: set[str] = set()
ids: list[str] = []
for match in _RE_DRIVE_URLS.finditer(texto):
for g in match.groups():
if g and g not in ids_vistos:
ids_vistos.add(g)
ids.append(g)
if not ids:
return "", []
from modules.drive_utils import conectar_drive, _ler_conteudo_drive_arquivo
svc = conectar_drive()
if not svc:
return "", []
partes: list[str] = []
nomes: list[str] = []
for file_id in ids:
try:
meta = svc.files().get(fileId=file_id, fields="id,name,mimeType").execute()
nome = meta.get("name", file_id)
mime = meta.get("mimeType", "")
conteudo = _ler_conteudo_drive_arquivo(svc, file_id, mime, nome)
if conteudo and conteudo.strip():
if len(conteudo) > 15000:
conteudo = conteudo[:15000] + "\n[...TRUNCADO...]"
partes.append(f"### Arquivo do Drive: {nome}\n{conteudo}")
nomes.append(nome)
else:
nomes.append(f"{nome} (sem conteudo extraivel)")
except Exception as e:
logging.warning(f"[Docs] Erro ao ler arquivo Drive {file_id}: {e}")
nomes.append(f"{file_id} (erro de acesso)")
return "\n\n".join(partes), nomes
# --------------- Enhancer UI helper ---------------
def _enhancer_ui(key_prefix: str) -> str | None:
"""Show enhancer approval UI. Returns final prompt or None if still waiting."""
estado_key = f"_enh_{key_prefix}_state"
original_key = f"_enh_{key_prefix}_orig"
aprimorado_key = f"_enh_{key_prefix}_enh"
estado = st.session_state.get(estado_key)
if estado != "awaiting":
return None
original = st.session_state.get(original_key, "")
aprimorado = st.session_state.get(aprimorado_key, "")
st.markdown("---")
with st.expander("Seu prompt original", expanded=False):
st.markdown(original)
st.markdown("**Prompt aprimorado:**")
st.info(aprimorado)
col1, col2, col3 = st.columns(3)
with col1:
if st.button("Usar aprimorado", type="primary", key=f"btn_enh_usar_{key_prefix}"):
st.session_state[estado_key] = None
st.session_state[f"_enh_{key_prefix}_run"] = aprimorado
st.rerun()
with col2:
if st.button("Regenerar", key=f"btn_enh_regen_{key_prefix}"):
with st.spinner("Regenerando..."):
st.session_state[aprimorado_key] = aprimorar_prompt(original)
st.rerun()
with col3:
if st.button("Usar original", key=f"btn_enh_orig_{key_prefix}"):
st.session_state[estado_key] = None
st.session_state[f"_enh_{key_prefix}_run"] = original
st.rerun()
return None
def _trigger_enhancer(texto: str, key_prefix: str):
"""Start the enhancer flow: enhance + set awaiting state."""
st.session_state[f"_enh_{key_prefix}_orig"] = texto
with st.spinner("Aprimorando seu prompt..."):
st.session_state[f"_enh_{key_prefix}_enh"] = aprimorar_prompt(texto)
st.session_state[f"_enh_{key_prefix}_state"] = "awaiting"
st.rerun()
def _pop_enhancer_result(key_prefix: str) -> str | None:
"""Pop and return the enhancer result if available."""
return st.session_state.pop(f"_enh_{key_prefix}_run", None)
# --------------- Gamma prompt ---------------
_GAMMA_PROMPT = """Voce e um especialista em criar apresentacoes executivas interativas para a plataforma Gamma.app.
Recebeu o conteudo extraido de uma apresentacao PowerPoint. Sua tarefa e transformar esse conteudo em um ROTEIRO COMPLETO para uma apresentacao Gamma.app animada e narrada.
CONTEUDO DO POWERPOINT:
{conteudo_ppt}
INSTRUCOES ADICIONAIS DO USUARIO:
{instrucoes}
CONTEXTO ADICIONAL:
{contexto}
Gere o roteiro seguindo EXATAMENTE este formato para cada slide/card do Gamma:
---
## Card [N]: [Titulo do Card]
**Visual:** [Descreva o layout ideal: hero image, split layout, grid, quote, etc.]
**Icones/Ilustracoes:** [Sugira icones ou ilustracoes no estilo sketch/hand-drawn que combinam com o tema]
**Texto principal:**
[Texto que aparece no card — curto, impactante, com palavras-chave em **destaque**]
**Narração (texto que o apresentador fala):**
[Texto mais longo e explicativo que complementa o que esta no card. Deve soar natural, como alguem apresentando para a diretoria. Use linguagem executiva mas acessivel.]
**Animacao sugerida:** [fade-in, slide-left, zoom, typewriter, etc.]
**Duracao estimada:** [X segundos]
---
REGRAS:
1. Cada slide do PPT pode virar 1 ou mais cards no Gamma (quebre slides densos em multiplos cards)
2. O primeiro card deve ser uma capa impactante com titulo e subtitulo
3. O ultimo card deve ser um encerramento com call-to-action ou agradecimento
4. Use frases curtas e impactantes nos cards — a narração complementa
5. Sugira destaques visuais (palavras com background colorido, como no exemplo)
6. Para tabelas/dados, sugira visualizacoes (graficos, progress bars, KPIs side-by-side)
7. Mantenha o tom profissional mas envolvente
8. Toda narração em portugues brasileiro
9. Inclua transicoes suaves entre cards
10. Para dados numericos, destaque os numeros grandes e as variacoes (%, +/-)
Ao final, inclua uma secao:
## Resumo para Gamma.app
- Total de cards: [N]
- Duracao estimada: [X minutos]
- Estilo visual sugerido: [ex: Sketch/Hand-drawn, Minimalista, Corporativo]
- Paleta de cores sugerida: [baseada no conteudo]
- Instrucoes para importar no Gamma: [passo a passo rapido]
Gere o roteiro COMPLETO agora."""
# --------------- PPTX generation logic ---------------
def _executar_geracao_pptx(instrucao_pptx: str, auto_ctx: bool):
"""Executa a geracao do PPTX (separado da UI para reuso)."""
contexto_auto = ""
if auto_ctx:
with st.spinner("Carregando contexto completo (OKRs, roadmap, metricas, iniciativas, PM OS)..."):
partes = []
try:
from modules.contexto import carregar_contexto_sob_demanda
ctx_base, _ctx_log = carregar_contexto_sob_demanda(
"okrs roadmap metricas iniciativas squad cora estrategia"
)
if ctx_base:
partes.append(f"## Contexto Base (Roadmap, Squad, Empresa)\n{ctx_base}")
except Exception as e:
logging.warning(f"[Docs] Erro ao carregar contexto base: {e}")
try:
from modules.pm_os import pm_os_carregar_contexto_outputs
ctx_pm_os = pm_os_carregar_contexto_outputs()
if ctx_pm_os:
partes.append(f"## Contexto PM OS (OKRs, PRDs, Outputs)\n{ctx_pm_os}")
except Exception as e:
logging.warning(f"[Docs] Erro ao carregar PM OS: {e}")
try:
from modules.pm_os import PM_OS_DRIVE, _pm_os_listar_com_svc
from modules.drive_utils import conectar_drive, _ler_conteudo_drive_arquivo
svc = conectar_drive()
if svc:
prds = _pm_os_listar_com_svc(svc, PM_OS_DRIVE.get("outputs_prds", ""))
prd_parts = []
for arq in prds:
if arq.get("mimeType") == "application/vnd.google-apps.folder":
continue
conteudo = _ler_conteudo_drive_arquivo(svc, arq["id"], arq["mimeType"], arq["name"])
if conteudo.strip():
prd_parts.append(f"### PRD: {arq['name']}\n{conteudo[:5000]}")
if prd_parts:
partes.append(f"## PRDs das Iniciativas\n" + "\n\n".join(prd_parts))
except Exception as e:
logging.warning(f"[Docs] Erro ao carregar PRDs: {e}")
try:
from modules.contexto_fraude import carregar_inteligencia_fraude
ctx_fraude = carregar_inteligencia_fraude()
if ctx_fraude:
partes.append(f"## Inteligencia de Fraude\n{ctx_fraude[:15000]}")
except Exception as e:
logging.warning(f"[Docs] Erro ao carregar contexto fraude: {e}")
contexto_auto = "\n\n---\n\n".join(partes)
# Contexto de documentos selecionados
mem_docs = st.session_state.get("memoria_arquivos") or []
docs_selecionados = st.session_state.get("pptx_docs_ctx", [])
for doc in mem_docs:
if doc["name"] in docs_selecionados:
contexto_auto += f"\n--- {doc['name']} ---\n{ler_arquivo_memoria(doc['id'])}\n"
# Carrega arquivos linkados no campo de instrucoes (Docs, Sheets, Slides,
# Drive files). Permite o usuario colar URL e o agente puxa o conteudo.
with st.spinner("Verificando links do Drive na instrucao..."):
drive_conteudo, drive_nomes = _carregar_arquivos_drive_de_urls(instrucao_pptx)
if drive_conteudo:
contexto_auto += "\n\n---\n\n## Arquivos do Drive linkados na instrucao\n\n" + drive_conteudo
if drive_nomes:
st.info("Arquivos do Drive carregados: " + ", ".join(drive_nomes))
progress_bar = st.progress(0)
status_text = st.empty()
def update_progress(step, total, msg):
progress_bar.progress(step / total)
status_text.info(f"**Passo {step}/{total}:** {msg}")
try:
pptx_bytes, n_slides, slides_html = gerar_pptx(
tema=instrucao_pptx,
contexto=contexto_auto,
progress_callback=update_progress,
)
progress_bar.empty()
status_text.empty()
st.session_state["_pptx_slides"] = slides_html
st.session_state["_pptx_bytes"] = pptx_bytes
nome_apres = instrucao_pptx.strip()[:50] or "apresentacao"
fid = salvar_apresentacao_drive(slides_html, nome_apres)
if fid:
st.session_state["_pptx_drive_id"] = fid
st.success(f"Apresentacao gerada com sucesso! {n_slides} slides."
+ (" Salva no Drive." if fid else ""))
st.download_button(
f"Baixar PPTX ({n_slides} slides)",
data=pptx_bytes,
file_name="apresentacao_cora.pptx",
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
key="dl_pptx",
)
except Exception as e:
progress_bar.empty()
status_text.empty()
st.error(f"Erro ao gerar apresentacao: {e}")
def _executar_edicao_slides(edit_instrucao: str, saved_slides: list):
"""Executa a edicao dos slides."""
from modules.pptx_generator import editar_slides_lote
progress_bar = st.progress(0)
status_text = st.empty()
def update_edit_progress(step, total, msg):
progress_bar.progress(step / total)
status_text.info(f"**Passo {step}/{total}:** {msg}")
try:
pptx_bytes, n_slides, slides_atualizados = editar_slides_lote(
slides=saved_slides,
instrucao=edit_instrucao,
progress_callback=update_edit_progress,
)
progress_bar.empty()
status_text.empty()
st.session_state["_pptx_slides"] = slides_atualizados
st.session_state["_pptx_bytes"] = pptx_bytes
salvar_apresentacao_drive(slides_atualizados,
st.session_state.get("pptx_instrucao", "apresentacao")[:50])
st.success(f"Edicao aplicada com sucesso! {n_slides} slides.")
st.download_button(
f"Baixar PPTX editado ({n_slides} slides)",
data=pptx_bytes,
file_name="apresentacao_cora.pptx",
mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
key="dl_pptx_edited",
)
except Exception as e:
progress_bar.empty()
status_text.empty()
st.error(f"Erro ao editar slides: {e}")
# --------------- Main render ---------------
def render_docs_tab():
"""Renderiza a aba Documentos."""
st.header("Gerador de Documentos")
enhancer_on = st.session_state.get("prompt_enhancer_ativo", False)
tipo_doc = st.selectbox("Tipo de documento:", [
"Apresentacao Executiva (PPTX)",
"Video Executivo (MP4)",
"Apresentacao Interativa (Gamma.app)",
"Roteiro de Apresentacao",
"Analise de Risco",
"Email Executivo",
"Documento Livre",
])
# --- Apresentacao Executiva PPTX ---
if tipo_doc == "Apresentacao Executiva (PPTX)":
st.markdown("""
Gera uma **apresentacao PowerPoint com visual rico** — igual ao video executivo.
**Como funciona:**
1. Descreva o tema (ex: "Racional de priorizacao das iniciativas por KR")
2. **Cole links do Drive/Docs/Sheets/Slides** direto no campo abaixo — o agente extrai o conteudo automaticamente
3. O agente carrega contexto automaticamente (OKRs, metricas, iniciativas) se a opcao estiver marcada
4. Claude Opus cria slides HTML com design visual rico (gradientes, cards, icones)
5. Playwright renderiza cada slide em imagem Full HD (1920x1080)
6. Monta o PPTX com imagens — visual identico ao video executivo
""")
auto_ctx = st.checkbox("Carregar contexto automaticamente (OKRs, roadmap, metricas)", value=True, key="pptx_auto_ctx")
instrucao_pptx = st.text_area("Tema e instrucoes da apresentacao:", height=150,
placeholder=(
"Ex: Racional de atingimento dos OKRs — como as iniciativas do roadmap impactam cada KR. "
"Tom executivo para o CPO.\n\n"
"Voce pode colar links do Drive aqui (Docs, Sheets, Slides, PDFs, DOCX...) que o agente "
"le o conteudo automaticamente. Ex: https://docs.google.com/document/d/ABC123/edit"
),
key="pptx_instrucao")
# Check if enhancer returned a result
enh_result = _pop_enhancer_result("pptx_gen")
if enh_result:
_executar_geracao_pptx(enh_result, auto_ctx)
# Show enhancer UI if awaiting
elif st.session_state.get("_enh_pptx_gen_state") == "awaiting":
_enhancer_ui("pptx_gen")
# Button
elif st.button("Gerar Apresentacao PPTX", type="primary", key="btn_pptx"):
if not instrucao_pptx.strip():
st.warning("Descreva o tema da apresentacao.")
elif enhancer_on:
_trigger_enhancer(instrucao_pptx, "pptx_gen")
else:
_executar_geracao_pptx(instrucao_pptx, auto_ctx)
# --- Carregar apresentacao salva do Drive ---
st.divider()
with st.expander("Carregar apresentacao salva"):
if st.button("Listar apresentacoes", key="btn_list_pptx"):
with st.spinner("Buscando no Drive..."):
st.session_state["_pptx_lista_drive"] = listar_apresentacoes_drive()
lista_drive = st.session_state.get("_pptx_lista_drive", [])
if lista_drive:
opcoes_drive = {}
opcoes_meta = {}
for a in lista_drive:
name = a["name"]
display = name.replace("pptx_", "").replace(".json", "")
is_json = name.endswith(".json")
tipo = "editavel" if is_json else "importavel"
label = f"{display} ({a.get('modifiedTime', '')[:10]}) [{tipo}]"
opcoes_drive[label] = a["id"]
opcoes_meta[label] = name
selecionada = st.selectbox("Apresentacao:", list(opcoes_drive.keys()), key="pptx_load_select")
is_pptx_import = not opcoes_meta.get(selecionada, "").endswith(".json")
if is_pptx_import:
st.caption("Este arquivo sera importado: Claude vai recriar os slides como HTML editavel. Pode levar ~1 min por slide.")
if st.button("Carregar", key="btn_load_pptx"):
spinner_msg = "Importando PPTX (Claude recriando slides)..." if is_pptx_import else "Carregando apresentacao..."
with st.spinner(spinner_msg):
slides_carregados = carregar_apresentacao_drive(
opcoes_drive[selecionada],
file_name=opcoes_meta.get(selecionada, ""),
)
if slides_carregados:
st.session_state["_pptx_slides"] = slides_carregados
if is_pptx_import:
nome_save = opcoes_meta.get(selecionada, "import").rsplit(".", 1)[0]
fid = salvar_apresentacao_drive(slides_carregados, nome_save)
if fid:
st.success(f"Importado e salvo! {len(slides_carregados)} slides. Edite abaixo.")
else:
st.success(f"Importado: {len(slides_carregados)} slides. Edite abaixo.")
else:
st.success(f"Apresentacao carregada: {len(slides_carregados)} slides. Edite abaixo.")
st.rerun()
else:
st.error("Erro ao carregar apresentacao.")
elif st.session_state.get("_pptx_lista_drive") is not None:
st.caption("Nenhuma apresentacao salva encontrada.")
# --- Edicao de slides ---
saved_slides = st.session_state.get("_pptx_slides")
if saved_slides:
st.divider()
st.subheader("Editar slides")
with st.expander(f"Ver slides atuais ({len(saved_slides)})"):
for i, s in enumerate(saved_slides):
st.caption(f"**Slide {i+1}:** {s.get('titulo', 'Sem titulo')}")
st.caption("Descreva as alteracoes referenciando os slides por numero. "
"Slides nao mencionados serao mantidos identicos.")
edit_instrucao = st.text_area("Alteracoes:", height=150,
placeholder="Ex:\nSlide 1: Trocar titulo para 'Novo Titulo'\n"
"Slide 3: Remover a parte sobre migração\n"
"Slide 5: Adicionar bullet sobre modelo de ML",
key="pptx_edit_instrucao")
# Check if enhancer returned a result for edit
enh_edit_result = _pop_enhancer_result("pptx_edit")
if enh_edit_result:
_executar_edicao_slides(enh_edit_result, saved_slides)
elif st.session_state.get("_enh_pptx_edit_state") == "awaiting":
_enhancer_ui("pptx_edit")
elif st.button("Aplicar edicoes", type="primary", key="btn_pptx_edit"):
if not edit_instrucao.strip():
st.warning("Descreva o que deseja alterar.")
elif enhancer_on:
_trigger_enhancer(edit_instrucao, "pptx_edit")
else:
_executar_edicao_slides(edit_instrucao, saved_slides)
# Documentos adicionais de contexto
mem_docs = st.session_state.get("memoria_arquivos") or []
if mem_docs:
st.multiselect("Documentos adicionais de contexto (opcional):",
[d["name"] for d in mem_docs], key="pptx_docs_ctx")
return # Nao renderizar o resto da aba
# --- Upload de PPT (para Video ou Gamma) ---
ppt_conteudo = ""
ppt_file_ref = None
needs_ppt = tipo_doc in ("Video Executivo (MP4)", "Apresentacao Interativa (Gamma.app)")
if tipo_doc == "Video Executivo (MP4)":
st.markdown("""
Transforme sua apresentacao (PowerPoint ou PDF) em um **video profissional narrado** —
com slides redesenhados na identidade visual Cora, narracao feminina em portugues,
animacoes e transicoes suaves.
**Como funciona:**
1. Faca upload do seu arquivo abaixo (PPTX ou PDF)
2. Adicione instrucoes especificas (opcional)
3. O agente redesenha os slides, gera narracao e monta o video MP4
""")
ppt_file = st.file_uploader("Upload da apresentação (.pptx ou .pdf):", type=["pptx", "pdf"], key="ppt_video")
if ppt_file:
ppt_file_ref = ppt_file
with st.spinner("Extraindo conteudo do PowerPoint..."):
ppt_conteudo = extrair_texto_arquivo(ppt_file)
if ppt_conteudo:
n_slides = ppt_conteudo.count("--- SLIDE")
st.success(f"PowerPoint carregado: {n_slides} slides extraidos")
with st.expander("Ver conteudo extraido"):
st.text(ppt_conteudo[:3000] + ("..." if len(ppt_conteudo) > 3000 else ""))
else:
st.warning("Nao foi possivel extrair conteudo do PPT.")
elif tipo_doc == "Apresentacao Interativa (Gamma.app)":
st.markdown("""
Transforme sua apresentacao (PowerPoint ou PDF) em um **roteiro completo para o Gamma.app** —
com narração, animações, e sugestões visuais para criar uma apresentação interativa e animada.
**Como funciona:**
1. Faca upload do seu arquivo abaixo (PPTX ou PDF)
2. O agente gera o roteiro completo com narração para cada card
3. Copie o conteudo no [Gamma.app](https://gamma.app) e ele cria a apresentação animada
""")
ppt_file = st.file_uploader("Upload da apresentação (.pptx ou .pdf):", type=["pptx", "pdf"], key="ppt_gamma")
if ppt_file:
with st.spinner("Extraindo conteudo do PowerPoint..."):
ppt_conteudo = extrair_texto_arquivo(ppt_file)
if ppt_conteudo:
n_slides = ppt_conteudo.count("--- SLIDE")
st.success(f"PowerPoint carregado: {n_slides} slides extraidos")
with st.expander("Ver conteudo extraido"):
st.text(ppt_conteudo[:3000] + ("..." if len(ppt_conteudo) > 3000 else ""))
else:
st.warning("Nao foi possivel extrair conteudo do PPT.")
mem_docs_tab = st.session_state.memoria_arquivos or []
docs_ctx = st.multiselect("Documentos de contexto (memoria):", [d["name"] for d in mem_docs_tab], key="docs_ctx")
instrucao = st.text_area("Descreva o que precisa:", height=150,
placeholder="Ex: Foco nos resultados, tom executivo para diretoria, destacar KPIs..."
if needs_ppt
else "Ex: Roteiro de apresentacao sobre os KPIs do trimestre...")
if st.button("Gerar Documento", type="primary"):
if needs_ppt and not ppt_conteudo:
st.warning("Faca upload do arquivo (PPTX ou PDF) primeiro.")
elif not instrucao.strip() and not needs_ppt:
st.warning("Descreva o documento que precisa.")
else:
contexto_docs = ""
for doc in mem_docs_tab:
if doc["name"] in docs_ctx:
contexto_docs += f"\n--- {doc['name']} ---\n{ler_arquivo_memoria(doc['id'])}\n"
if tipo_doc == "Video Executivo (MP4)":
placeholder = st.empty()
ppt_file_ref.seek(0)
video_bytes = gerar_video_apresentacao(
ppt_file_ref, instrucao, contexto_docs, placeholder
)
if video_bytes:
video_bytes.seek(0)
st.video(video_bytes, format="video/mp4")
video_bytes.seek(0)
st.download_button(
"Baixar Video (MP4)",
data=video_bytes,
file_name="apresentacao_executiva.mp4",
mime="video/mp4",
key="dl_video",
)
elif tipo_doc == "Apresentacao Interativa (Gamma.app)":
with st.spinner("Gerando apresentacao interativa..."):
prompt_doc = _GAMMA_PROMPT.format(
conteudo_ppt=ppt_conteudo,
instrucoes=instrucao or "Nenhuma instrucao adicional.",
contexto=contexto_docs or "Nenhum contexto adicional.",
)
placeholder = st.empty()
resposta = _gerar_com_continuacao(client_anthropic, st.session_state.modelo, prompt_doc, placeholder)
st.download_button(
"Baixar em Word (.docx)", data=criar_docx(resposta),
file_name=f"{sanitizar(tipo_doc)}.docx",
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
key="dl_doc"
)
else:
with st.spinner("Gerando..."):
prompt_doc = f"""Voce e um PM Senior. Gere um {tipo_doc} completo e profissional em portugues.
SOLICITACAO: {instrucao}
CONTEXTO ADICIONAL:
{contexto_docs or "Nenhum contexto adicional fornecido."}
Use formatacao Markdown com titulos, subtitulos e listas bem estruturadas."""
placeholder = st.empty()
resposta = _gerar_com_continuacao(client_anthropic, st.session_state.modelo, prompt_doc, placeholder)
st.download_button(
"Baixar em Word (.docx)", data=criar_docx(resposta),
file_name=f"{sanitizar(tipo_doc)}.docx",
mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
key="dl_doc_other"
)