| """NEXUS 6-step agent loop β Manus-style: analyze β search β plan β generate β assemble β save.""" |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import json |
| import logging |
| import re |
| from datetime import datetime, timezone |
| from typing import Any, Awaitable, Callable |
|
|
| from sqlalchemy import select |
|
|
| from agent.deck_repair import repair_for_validator |
| from agent.layouts_registry import ( |
| CANONICAL_LAYOUTS, |
| FALLBACK_LAYOUT, |
| normalize_layout, |
| ) |
| from agent.memory import AgentMemory |
| from agent.planner import Planner |
| from agent.prompt_intent import extract_slide_count |
| from agent.prompts import nexus_system_prompt, slides_user_message |
| from agent.prompts import CRITIC_SYSTEM_PROMPT, critic_user_message |
| from database.connection import SessionLocal |
| from database.models import SlideDeck, Task |
| from services.claude_service import ClaudeService |
| from services.lifecycle_service import JobCancelled |
| from services.search_service import SearchService |
|
|
| logger = logging.getLogger("nexus.agent.loop") |
|
|
| ProgressCallback = Callable[[str, float, str], Awaitable[None]] |
|
|
| |
| |
| _VALID_LAYOUTS = frozenset(CANONICAL_LAYOUTS) |
|
|
|
|
| class NexusAgentLoop: |
| """Orchestrates the full slide generation pipeline for a single task.""" |
|
|
| def __init__( |
| self, |
| claude: ClaudeService | None = None, |
| search: SearchService | None = None, |
| planner: Planner | None = None, |
| ) -> None: |
| self.claude = claude or ClaudeService() |
| self.search = search or SearchService() |
| self.planner = planner or Planner(claude=self.claude) |
|
|
| async def _ai_call( |
| self, |
| *, |
| role: str, |
| system: str, |
| user: str, |
| max_tokens: int, |
| temperature: float = 0.7, |
| ) -> tuple[str, int, float]: |
| """Phase 6W: route via ``complete_for_role`` when available, else |
| fall back to ``complete`` so ClaudeService stand-ins/fakes that |
| don't implement role routing keep working unchanged.""" |
| if hasattr(self.claude, "complete_for_role"): |
| return await self.claude.complete_for_role( |
| role=role, |
| system=system, |
| user=user, |
| max_tokens=max_tokens, |
| temperature=temperature, |
| ) |
| return await self.claude.complete( |
| system=system, user=user, max_tokens=max_tokens, temperature=temperature |
| ) |
|
|
| |
| |
| |
| |
| _RESEARCH_SUMMARIZE_THRESHOLD = 10_000 |
|
|
| async def _summarize_long_research( |
| self, topic: str, research: str |
| ) -> tuple[str, int, float]: |
| """Compress harvested research via the ``summarize`` role when long. |
| |
| Returns ``(text, tokens, cost)``. If research is short or summarize |
| fails, returns the original text unchanged with zero tokens/cost. |
| Source metadata is preserved separately by the caller; this only |
| compresses the narrative blob. |
| """ |
| if not research or len(research) < self._RESEARCH_SUMMARIZE_THRESHOLD: |
| return research, 0, 0.0 |
| try: |
| text, tokens, cost = await self._ai_call( |
| role="summarize", |
| system=( |
| "You compress research notes into dense, fact-preserving " |
| "summaries. Keep all numbers, dates, names, and citations. " |
| "Drop filler, marketing language, and duplicates. Plain text only." |
| ), |
| user=( |
| f"Topic: {topic}\n\n" |
| f"Compress the following research to <= 1500 words while " |
| f"preserving every concrete figure, date, and source mention.\n\n" |
| f"---\n{research}\n---" |
| ), |
| max_tokens=2048, |
| temperature=0.2, |
| ) |
| if text and len(text) < len(research): |
| logger.info( |
| "loop.research_summarized", |
| extra={"orig_chars": len(research), "new_chars": len(text)}, |
| ) |
| return text, tokens, cost |
| except Exception as exc: |
| logger.warning("loop.summarize_failed", extra={"err": str(exc)}) |
| return research, 0, 0.0 |
|
|
| async def _json_fix_retry( |
| self, raw_text: str, *, expected: str = "array" |
| ) -> tuple[str, int, float]: |
| """Phase 6W: ask the ``json_fix`` role to repair malformed model output. |
| |
| Used when ``_parse_slides_array``/``_parse_single_slide`` fails on the |
| primary writer output. Returns ``(repaired_text, tokens, cost)``; the |
| caller is responsible for re-parsing. On any error returns the input |
| text unchanged with zero tokens/cost so callers can fall through to |
| the existing deterministic fallback path. |
| """ |
| if not raw_text or not raw_text.strip(): |
| return raw_text, 0, 0.0 |
| target = "JSON array of slide objects" if expected == "array" else "single JSON slide object" |
| try: |
| text, tokens, cost = await self._ai_call( |
| role="json_fix", |
| system=( |
| "You repair malformed JSON. Output ONLY valid JSON matching " |
| "the requested shape. No prose, no code fences, no commentary." |
| ), |
| user=( |
| f"The following text was supposed to be a {target} but failed to parse. " |
| f"Repair it and return ONLY the valid JSON.\n\n---\n{raw_text}\n---" |
| ), |
| max_tokens=4096, |
| temperature=0.0, |
| ) |
| return text or raw_text, tokens, cost |
| except Exception as exc: |
| logger.warning("loop.json_fix_failed", extra={"err": str(exc)}) |
| return raw_text, 0, 0.0 |
|
|
| async def run( |
| self, |
| task_id: str, |
| topic: str, |
| slide_count: int, |
| theme: str, |
| search_web: bool, |
| on_progress: ProgressCallback, |
| min_sources: int = 0, |
| ) -> dict[str, Any]: |
| memory = AgentMemory(task_id) |
| total_tokens = 0 |
| total_cost = 0.0 |
| model_used = self.claude.active_model |
| research_sources: list[dict[str, Any]] = [] |
| |
| _deck_type = "overview" |
| _mood = "neutral" |
| |
| |
| |
| |
| _beats: list[str] = [] |
|
|
| |
| |
| |
| |
| hinted = extract_slide_count(topic) |
| if hinted is not None and hinted != slide_count: |
| logger.info( |
| "loop.slide_count_override topic_hint=%d field=%d", hinted, slide_count |
| ) |
| slide_count = hinted |
|
|
| try: |
| |
| await on_progress("Analyzing your topic...", 8.0, "analyze") |
| await self._mark_running(task_id, "analyze", 8.0) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| search_queries: list[str] = [topic] |
| try: |
| from agent.topic_expander import expand_topic, canonicalize_topic |
|
|
| expanded, exp_tokens, exp_cost = await expand_topic( |
| topic, ai_call=self._ai_call, max_queries=5 |
| ) |
| if expanded: |
| search_queries = expanded |
| total_tokens += exp_tokens |
| total_cost += exp_cost |
| |
| |
| |
| canon = canonicalize_topic(topic) |
| if canon: |
| topic = canon |
| if len(search_queries) > 1: |
| await on_progress( |
| f"Expanded to {len(search_queries)} search angles.", |
| 12.0, |
| "analyze", |
| event="design_decision", |
| decision="topic_expanded", |
| value={ |
| "queries": search_queries, |
| "tokens": exp_tokens, |
| }, |
| ) |
| except Exception as exc: |
| logger.warning("loop.topic_expander_failed", extra={"err": str(exc)}) |
|
|
| |
| research = "" |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| seed_research_text = "" |
| try: |
| from pathlib import Path as _Path |
| _seed_root = memory.root |
| _seed_json = _seed_root / "seed_research.json" |
| _seed_txt = _seed_root / "seed_research.txt" |
| if _seed_json.exists(): |
| import json as _json |
| seed_research_text = _json.dumps( |
| _json.loads(_seed_json.read_text(encoding="utf-8")), |
| ensure_ascii=False, indent=2, |
| ) |
| elif _seed_txt.exists(): |
| seed_research_text = _seed_txt.read_text(encoding="utf-8") |
| except Exception as exc: |
| logger.warning("loop.seed_research_read_failed", extra={"err": str(exc)}) |
| seed_research_text = "" |
| if seed_research_text.strip(): |
| research = seed_research_text[:20_000] |
| await on_progress( |
| "Grounding on user-supplied data.", |
| 18.0, |
| "search", |
| event="design_decision", |
| decision="seed_research", |
| value={"chars": len(research)}, |
| rationale=( |
| "Skipped web search; the deck is generated from the " |
| "JSON payload provided with the import." |
| ), |
| ) |
| elif search_web: |
| await on_progress("Researching topic on the web...", 18.0, "search") |
| await self._mark_running(task_id, "search", 18.0) |
| try: |
| |
| |
| |
| |
| |
| if len(search_queries) > 1: |
| seen_urls: set[str] = set() |
| merged_sources: list[dict[str, Any]] = [] |
| summary_parts: list[str] = [] |
| for qi, q in enumerate(search_queries): |
| try: |
| if ( |
| qi == 0 |
| and min_sources |
| and hasattr(self.search, "harvest") |
| ): |
| sub_research, sub_sources = ( |
| await self.search.harvest( |
| q, |
| target_min=min_sources, |
| max_total=8, |
| ) |
| ) |
| else: |
| sub_research, sub_sources = ( |
| await self.search.search(q, max_results=4) |
| ) |
| except Exception as exc: |
| logger.warning( |
| "loop.search_query_failed", |
| extra={"q": q[:80], "err": str(exc)}, |
| ) |
| continue |
| if isinstance(sub_research, str) and sub_research.strip(): |
| summary_parts.append(sub_research.strip()) |
| for s in sub_sources or []: |
| if not isinstance(s, dict): |
| continue |
| url = str(s.get("url") or "").strip() |
| key = url or str(s) |
| if key in seen_urls: |
| continue |
| seen_urls.add(key) |
| merged_sources.append(s) |
| if len(merged_sources) >= 12: |
| break |
| if len(merged_sources) >= 12: |
| break |
| research = "\n\n".join(summary_parts[:3]).strip() |
| search_sources = merged_sources |
| elif min_sources and hasattr(self.search, "harvest"): |
| research, search_sources = await self.search.harvest( |
| topic, target_min=min_sources, max_total=12 |
| ) |
| else: |
| research, search_sources = await self.search.search( |
| topic, max_results=6 |
| ) |
| if isinstance(search_sources, list): |
| research_sources = list(search_sources) |
| except Exception as exc: |
| logger.warning("loop.search_failed", extra={"err": str(exc)}) |
| research = "" |
| memory.write_research(research) |
|
|
| |
| for src in research_sources[:8]: |
| src_title = (src.get("title") or src.get("url") or "Source")[:100] |
| src_url = src.get("url") or "" |
| await on_progress(src_title, 19.0, "search", event="source_found", url=src_url) |
|
|
| |
| |
| |
| if research: |
| research, _stok, _scost = await self._summarize_long_research( |
| topic, research |
| ) |
| total_tokens += _stok |
| total_cost += _scost |
|
|
| |
| if research: |
| await on_progress(research[:800], 23.5, "search", event="research_note") |
|
|
| |
| |
| |
| |
| |
| |
| await on_progress("Building deck strategy...", 24.0, "strategy") |
| try: |
| from agent.art_direction import infer_art_direction |
| from agent.deck_strategy import build_deck_strategy |
|
|
| art = infer_art_direction(topic, theme) |
| strategy = build_deck_strategy( |
| topic=topic, |
| slide_count=slide_count, |
| art_direction=art, |
| research=research, |
| research_sources=research_sources, |
| ) |
| memory.write_artifact("strategy.json", strategy.to_dict()) |
| _deck_type = strategy.deck_type |
| _mood = art.mood |
|
|
| |
| |
| try: |
| await on_progress( |
| f"Deck type: {strategy.deck_type}", |
| 24.5, "strategy", |
| event="design_decision", |
| decision="deck_type", |
| value=strategy.deck_type, |
| rationale=(strategy.thesis or "")[:240], |
| ) |
| await on_progress( |
| f"Mood: {art.mood}", |
| 24.7, "strategy", |
| event="design_decision", |
| decision="mood", |
| value=art.mood, |
| rationale=art.rationale, |
| category=art.category, |
| ) |
| if strategy.story_arc: |
| await on_progress( |
| f"Story arc: {' β '.join(strategy.story_arc[:5])}", |
| 24.9, "strategy", |
| event="design_decision", |
| decision="story_arc", |
| value=list(strategy.story_arc), |
| rationale=f"{strategy.deck_type} decks pace as: {' β '.join(strategy.story_arc)}", |
| ) |
| if strategy.layout_recipe: |
| await on_progress( |
| f"Layout recipe: {', '.join(strategy.layout_recipe[:6])}", |
| 25.1, "strategy", |
| event="design_decision", |
| decision="layout_recipe", |
| value=list(strategy.layout_recipe), |
| rationale=f"Tone: {strategy.tone}; visual: {strategy.visual_direction}", |
| ) |
| except Exception as exc: |
| logger.warning("loop.design_decision_emit_failed", extra={"err": str(exc)}) |
|
|
| |
| |
| try: |
| from agent.narrative_beats import derive_beats |
| _beats = derive_beats( |
| slide_count=slide_count, |
| story_arc=list(strategy.story_arc) if strategy else None, |
| research=research, |
| ) |
| if _beats: |
| memory.write_artifact("narrative_beats.json", {"beats": _beats}) |
| await on_progress( |
| f"Narrative beats: {' β '.join(_beats[:7])}", |
| 25.3, "strategy", |
| event="design_decision", |
| decision="narrative_beats", |
| value=list(_beats), |
| rationale=( |
| "Documentary beat sequence derived from the deck strategy " |
| "and research signals; drives per-slide intent and section " |
| "divider placement." |
| ), |
| ) |
| except Exception as exc: |
| logger.warning("loop.narrative_beats_failed", extra={"err": str(exc)}) |
| _beats = [] |
| except Exception as exc: |
| logger.warning("loop.strategy_failed", extra={"err": str(exc)}) |
| strategy = None |
| await on_progress( |
| "Research strategy ready.", |
| 26.0, |
| "strategy", |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| narrative = None |
| try: |
| from agent.narrative_synthesizer import synthesize_narrative |
| key_facts: list[str] = [] |
| audience = "" |
| tone = "" |
| thesis_hint = "" |
| story_arc_list: list[str] = [] |
| if strategy is not None: |
| key_facts = list(getattr(strategy, "key_facts", []) or []) |
| audience = getattr(strategy, "audience", "") or "" |
| tone = getattr(strategy, "tone", "") or "" |
| thesis_hint = getattr(strategy, "thesis", "") or "" |
| story_arc_list = list(getattr(strategy, "story_arc", []) or []) |
| draft, n_tokens, n_cost = await synthesize_narrative( |
| topic=topic, |
| research=research, |
| deck_type=_deck_type, |
| audience=audience, |
| tone=tone, |
| thesis_hint=thesis_hint, |
| story_arc=story_arc_list, |
| key_facts=key_facts, |
| slide_count=slide_count, |
| ai_call=self._ai_call, |
| ) |
| total_tokens += n_tokens |
| total_cost += n_cost |
| if not draft.is_empty: |
| narrative = draft |
| memory.write_artifact( |
| "narrative.json", |
| { |
| "thesis": draft.thesis, |
| "sections": [ |
| {"beat": s.beat, "heading": s.heading, "body": s.body} |
| for s in draft.sections |
| ], |
| }, |
| ) |
| try: |
| await on_progress( |
| (draft.thesis or "Narrative drafted.")[:240], |
| 27.0, |
| "strategy", |
| event="design_decision", |
| decision="narrative", |
| value={ |
| "thesis": draft.thesis, |
| "sections": [ |
| {"beat": s.beat, "heading": s.heading} |
| for s in draft.sections |
| ], |
| }, |
| rationale=( |
| "Story-first draft written before the slide " |
| "skeleton. Each slide condenses one section." |
| ), |
| ) |
| except Exception as exc: |
| logger.warning( |
| "loop.narrative_emit_failed", extra={"err": str(exc)} |
| ) |
| except Exception as exc: |
| logger.warning("loop.narrative_failed", extra={"err": str(exc)}) |
| narrative = None |
|
|
| |
| await on_progress("Planning slide structure...", 28.0, "plan") |
| await self._mark_running(task_id, "plan", 28.0) |
| outline, p_tokens, p_cost = await self.planner.plan( |
| topic, slide_count, research, strategy=strategy, narrative=narrative |
| ) |
| total_tokens += p_tokens |
| total_cost += p_cost |
| memory.write_outline(outline) |
| memory.write_todo(outline) |
|
|
| |
| if outline: |
| _outline_titles = [ |
| {"i": i + 1, "layout": p.get("layout", ""), "title": p.get("title", "")} |
| for i, p in enumerate(outline) |
| ] |
| await on_progress( |
| "Slide structure planned", |
| 29.0, |
| "plan", |
| event="outline_ready", |
| outline=_outline_titles, |
| ) |
|
|
| |
| slides: list[dict[str, Any]] = [] |
| try: |
| slides, g_tokens, g_cost = await self._generate_all_at_once( |
| topic, slide_count, research, outline, on_progress, memory, |
| strategy=strategy, |
| deck_type=_deck_type, |
| mood=_mood, |
| narrative=narrative, |
| ) |
| total_tokens += g_tokens |
| total_cost += g_cost |
| except Exception as exc: |
| logger.warning( |
| "loop.batch_generate_failed_falling_back", extra={"err": str(exc)} |
| ) |
| slides = [] |
|
|
| if not slides: |
| slides, g_tokens, g_cost = await self._generate_per_slide( |
| topic, research, outline, on_progress, memory, |
| deck_type=_deck_type, |
| mood=_mood, |
| ) |
| total_tokens += g_tokens |
| total_cost += g_cost |
|
|
| |
| await on_progress("Finalizing presentation...", 90.0, "assemble") |
| await self._mark_running(task_id, "assemble", 90.0) |
| slides = self._normalize_slides(slides, slide_count, topic) |
|
|
| |
| |
| try: |
| from agent.source_grounding import attach_research_sources_to_deck |
| slides = attach_research_sources_to_deck(slides, research_sources) |
| except Exception as exc: |
| logger.warning("loop.attach_sources_failed", extra={"err": str(exc)}) |
|
|
| |
| |
| try: |
| from agent.slide_intent import attach_slide_intent |
| slides = attach_slide_intent( |
| slides, |
| story_arc=list(strategy.story_arc) if strategy else None, |
| strategy_tone=strategy.tone if strategy else None, |
| art_mood=_mood, |
| beats=_beats if _beats else None, |
| ) |
| |
| |
| try: |
| rhythm = [ |
| { |
| "i": i + 1, |
| "role": (s.get("intent") or {}).get("narrative_role"), |
| "density": (s.get("intent") or {}).get("density"), |
| } |
| for i, s in enumerate(slides) |
| if isinstance(s, dict) |
| ] |
| if rhythm: |
| await on_progress( |
| f"Pacing: {' Β· '.join(r['role'] or '?' for r in rhythm[:8])}", |
| 91.0, "assemble", |
| event="design_decision", |
| decision="intent_rhythm", |
| value=rhythm, |
| rationale=f"Per-slide narrative role and density across {len(rhythm)} slides.", |
| ) |
| except Exception as exc: |
| logger.warning("loop.intent_rhythm_emit_failed", extra={"err": str(exc)}) |
| except Exception as exc: |
| logger.warning("loop.attach_intent_failed", extra={"err": str(exc)}) |
|
|
| |
| |
| |
| |
| |
| |
| |
| try: |
| from agent.layout_recommender import ( |
| recommend_layouts, |
| enforce_hero, |
| insert_section_dividers, |
| ) |
| slides, _layout_upgrades = recommend_layouts(slides) |
| |
| _slides_after_hero, _hero_upgrades = enforce_hero(slides) |
| if _hero_upgrades: |
| slides = _slides_after_hero |
| _layout_upgrades = list(_layout_upgrades) + list(_hero_upgrades) |
| |
| _slides_after_div, _div_upgrades = insert_section_dividers(slides) |
| if _div_upgrades: |
| slides = _slides_after_div |
| _layout_upgrades = list(_layout_upgrades) + list(_div_upgrades) |
| if _layout_upgrades: |
| slides = self._normalize_slides(slides, slide_count, topic) |
| try: |
| from agent.slide_intent import attach_slide_intent |
| slides = attach_slide_intent( |
| slides, |
| story_arc=list(strategy.story_arc) if strategy else None, |
| strategy_tone=strategy.tone if strategy else None, |
| art_mood=_mood, |
| ) |
| except Exception as exc: |
| logger.warning("loop.attach_intent_failed", extra={"err": str(exc)}) |
| |
| |
| for u in _layout_upgrades: |
| try: |
| await on_progress( |
| f"Slide {u['slide_index']}: {u['from']} β {u['to']}", |
| 91.5, "assemble", |
| event="design_decision", |
| decision="layout_upgrade", |
| value=u, |
| rationale=u.get("reason") or "", |
| ) |
| except Exception as exc: |
| logger.warning( |
| "loop.layout_upgrade_emit_failed", |
| extra={"err": str(exc)}, |
| ) |
| except Exception as exc: |
| logger.warning("loop.layout_recommender_failed", extra={"err": str(exc)}) |
|
|
| |
| try: |
| slides, c_tokens, c_cost = await self._critique_and_rewrite( |
| topic, research, slides, on_progress |
| ) |
| total_tokens += c_tokens |
| total_cost += c_cost |
| slides = self._normalize_slides(slides, slide_count, topic) |
| |
| try: |
| from agent.source_grounding import attach_research_sources_to_deck |
| slides = attach_research_sources_to_deck(slides, research_sources) |
| except Exception as exc: |
| logger.warning("loop.attach_sources_failed", extra={"err": str(exc)}) |
| |
| try: |
| from agent.slide_intent import attach_slide_intent |
| slides = attach_slide_intent( |
| slides, |
| story_arc=list(strategy.story_arc) if strategy else None, |
| strategy_tone=strategy.tone if strategy else None, |
| art_mood=_mood, |
| ) |
| except Exception as exc: |
| logger.warning("loop.attach_intent_failed", extra={"err": str(exc)}) |
| except Exception as exc: |
| logger.warning("loop.critic_failed", extra={"err": str(exc)}) |
|
|
| |
| try: |
| slides, i_tokens, i_cost = await self._add_hero_images( |
| topic, slides, on_progress |
| ) |
| total_tokens += i_tokens |
| total_cost += i_cost |
| except Exception as exc: |
| logger.warning("loop.images_failed", extra={"err": str(exc)}) |
|
|
| |
| await on_progress("Saving your slides...", 96.0, "save") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| slides = repair_for_validator(slides) |
| except Exception as exc: |
| logger.warning("loop.deck_repair_failed", extra={"err": str(exc)}) |
|
|
| |
| |
| |
| |
| |
| |
| |
| try: |
| from agent.editorial_pass import apply_editorial_pass |
| slides, editorial_summary = apply_editorial_pass(slides) |
| try: |
| await on_progress( |
| f"Edited {editorial_summary.get('headline_rewrites', 0)} titles, " |
| f"{editorial_summary.get('bullet_rewrites', 0)} bullets; " |
| f"added {editorial_summary.get('transitions_added', 0)} transitions.", |
| 96.5, |
| "save", |
| event="editorial_pass", |
| editorial_summary=editorial_summary, |
| ) |
| except Exception as exc: |
| logger.warning("loop.editorial_event_failed", extra={"err": str(exc)}) |
| except Exception as exc: |
| logger.warning("loop.editorial_pass_failed", extra={"err": str(exc)}) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| from agent.voice_pass import apply_voice_pass |
| slides, voice_summary = apply_voice_pass(slides) |
| if ( |
| voice_summary.get("headline_rewrites", 0) |
| + voice_summary.get("subtitle_kills", 0) |
| + voice_summary.get("transition_scrubs", 0) |
| + voice_summary.get("closing_rewrites", 0) |
| > 0 |
| ): |
| try: |
| await on_progress( |
| f"Voice pass: " |
| f"{voice_summary.get('headline_rewrites', 0)} headline(s), " |
| f"{voice_summary.get('subtitle_kills', 0)} filler subtitle(s), " |
| f"{voice_summary.get('transition_scrubs', 0)} transition(s), " |
| f"{voice_summary.get('closing_rewrites', 0)} closing(s) rewritten.", |
| 96.6, |
| "save", |
| event="design_decision", |
| decision="voice_pass", |
| value=voice_summary, |
| rationale="Replaced category-label headlines and filler copy with authored deck voice.", |
| ) |
| except Exception as exc: |
| logger.warning("loop.voice_event_failed", extra={"err": str(exc)}) |
| except Exception as exc: |
| logger.warning("loop.voice_pass_failed", extra={"err": str(exc)}) |
|
|
| |
| |
| |
| |
| |
| try: |
| from agent.cinematic_marker import mark_hero_moments |
| slides, hero_summary = mark_hero_moments(slides) |
| if hero_summary.get("total", 0) > 0: |
| try: |
| await on_progress( |
| f"Marked {hero_summary.get('total', 0)} cinematic moment(s).", |
| 96.8, |
| "save", |
| event="design_decision", |
| decision="cinematic_hero", |
| value=hero_summary, |
| rationale="Promoted hero slides for full-bleed cinematic rendering.", |
| ) |
| except Exception as exc: |
| logger.warning("loop.cinematic_event_failed", extra={"err": str(exc)}) |
| except Exception as exc: |
| logger.warning("loop.cinematic_marker_failed", extra={"err": str(exc)}) |
|
|
| |
| |
| |
| |
| try: |
| from agent.citation_attach import attach_citations_to_deck |
| slides, citation_summary = attach_citations_to_deck(slides) |
| try: |
| await on_progress( |
| f"Cited {citation_summary.get('supported', 0)}/" |
| f"{citation_summary.get('total_claims', 0)} claims " |
| f"across {citation_summary.get('slides_with_citations', 0)} slides.", |
| 97.0, |
| "save", |
| event="citation_checked", |
| citation_summary=citation_summary, |
| ) |
| except Exception as exc: |
| logger.warning("loop.citation_event_failed", extra={"err": str(exc)}) |
| except Exception as exc: |
| logger.warning("loop.citation_attach_failed", extra={"err": str(exc)}) |
|
|
| await self._save_deck(task_id, slides, theme, total_tokens, total_cost, model_used) |
|
|
| await on_progress("Done! Your slides are ready.", 100.0, "done", status="done") |
| return {"slides": slides, "tokens": total_tokens, "cost_usd": total_cost} |
|
|
| except JobCancelled as exc: |
| |
| |
| |
| logger.info( |
| "loop.cancelled", |
| extra={"task_id": task_id, "stage": str(exc) or "unknown"}, |
| ) |
| await on_progress( |
| "Generation cancelled by user.", |
| 100.0, |
| "cancelled", |
| status="cancelled", |
| ) |
| raise |
| except Exception as exc: |
| logger.exception("loop.failed", extra={"task_id": task_id}) |
| await self._mark_failed(task_id, str(exc)) |
| await on_progress(f"Generation failed: {exc}", 100.0, "failed", status="failed", error=str(exc)) |
| raise |
|
|
| |
| async def _generate_all_at_once( |
| self, |
| topic: str, |
| slide_count: int, |
| research: str, |
| outline: list[dict[str, Any]], |
| on_progress: ProgressCallback, |
| memory: AgentMemory, |
| *, |
| strategy: Any | None = None, |
| deck_type: str = "overview", |
| mood: str = "neutral", |
| narrative: Any | None = None, |
| ) -> tuple[list[dict[str, Any]], int, float]: |
| await on_progress(f"Writing slides 1 of {slide_count}...", 35.0, "generate") |
| outline_text = "\n".join( |
| f"{i + 1}. ({p['layout']}) {p['title']} β {p['intent']}" |
| for i, p in enumerate(outline) |
| ) |
| user_msg = slides_user_message( |
| topic, slide_count, research, outline_text, |
| strategy=strategy, narrative=narrative, |
| ) |
| text, tokens, cost = await self._ai_call( |
| role="writer", |
| system=nexus_system_prompt(deck_type, mood), |
| user=user_msg, |
| max_tokens=8096, |
| ) |
| slides = self._parse_slides_array(text) |
| if not slides: |
| |
| repaired, rtok, rcost = await self._json_fix_retry(text, expected="array") |
| tokens += rtok |
| cost += rcost |
| if repaired and repaired != text: |
| slides = self._parse_slides_array(repaired) |
| if not slides: |
| return [], tokens, cost |
| for i, slide in enumerate(slides[:slide_count]): |
| memory.write_slide(i, slide) |
| memory.mark_todo_done(i) |
| pct = 35.0 + (i + 1) * (50.0 / slide_count) |
| await on_progress( |
| f"Writing slide {i + 1} of {slide_count}...", |
| min(pct, 85.0), |
| "generate", |
| event="slide", |
| slide_index=i, |
| slide_total=slide_count, |
| slide=slide, |
| ) |
| return slides[:slide_count], tokens, cost |
|
|
| async def _generate_per_slide( |
| self, |
| topic: str, |
| research: str, |
| outline: list[dict[str, Any]], |
| on_progress: ProgressCallback, |
| memory: AgentMemory, |
| *, |
| deck_type: str = "overview", |
| mood: str = "neutral", |
| ) -> tuple[list[dict[str, Any]], int, float]: |
| from agent.prompts import single_slide_user_message |
|
|
| slides: list[dict[str, Any]] = [] |
| total_tokens = 0 |
| total_cost = 0.0 |
| n = len(outline) |
| |
| |
| seen_responses: set[str] = set() |
| MAX_ATTEMPTS = 2 |
|
|
| for i, plan in enumerate(outline): |
| pct = 35.0 + (i + 1) * (50.0 / max(n, 1)) |
| await on_progress( |
| f"Writing slide {i + 1} of {n}...", min(pct, 85.0), "generate" |
| ) |
| base_user = single_slide_user_message(topic, research, plan, i, n) |
| slide = None |
| for attempt in range(MAX_ATTEMPTS): |
| |
| user_msg = base_user |
| temp = 0.7 |
| if attempt > 0: |
| user_msg = ( |
| base_user |
| + "\n\nThe previous attempt produced unusable output. " |
| "Try a different angle, use specific numbers, and return ONLY valid JSON." |
| ) |
| temp = 0.95 |
| try: |
| text, tokens, cost = await self._ai_call( |
| role="writer", |
| system=nexus_system_prompt(deck_type, mood), |
| user=user_msg, |
| max_tokens=2048, |
| temperature=temp, |
| ) |
| total_tokens += tokens |
| total_cost += cost |
| fingerprint = (text or "").strip()[:200] |
| if fingerprint and fingerprint in seen_responses: |
| logger.info( |
| "loop.stuck_detected_retrying", extra={"i": i, "attempt": attempt} |
| ) |
| continue |
| if fingerprint: |
| seen_responses.add(fingerprint) |
| slide = self._parse_single_slide(text) |
| if slide is not None: |
| break |
| |
| repaired, rtok, rcost = await self._json_fix_retry( |
| text, expected="object" |
| ) |
| total_tokens += rtok |
| total_cost += rcost |
| if repaired and repaired != text: |
| slide = self._parse_single_slide(repaired) |
| if slide is not None: |
| break |
| except Exception as exc: |
| logger.warning( |
| "loop.single_slide_failed", |
| extra={"i": i, "attempt": attempt, "err": str(exc)}, |
| ) |
| if slide is None: |
| slide = self._fallback_slide(plan, i, n, topic) |
| if slide is None: |
| slide = self._fallback_slide(plan, i, n, topic) |
| slides.append(slide) |
| memory.write_slide(i, slide) |
| memory.mark_todo_done(i) |
| await on_progress( |
| f"Wrote slide {i + 1} of {n}.", |
| min(35.0 + (i + 1) * (50.0 / max(n, 1)), 85.0), |
| "generate", |
| event="slide", |
| slide_index=i, |
| slide_total=n, |
| slide=slide, |
| ) |
| return slides, total_tokens, total_cost |
|
|
| |
| _BLAND_PHRASES = ( |
| "enhanced productivity", |
| "improved efficiency", |
| "improved decision", |
| "data-driven insight", |
| "personalized customer", |
| "increased accuracy", |
| "leverage synergies", |
| "streamline operations", |
| "key insight", |
| "drive value", |
| "cutting-edge", |
| "state-of-the-art", |
| "next-generation", |
| "innovative solution", |
| "seamless integration", |
| "unlock potential", |
| "transform industries", |
| "revolutionize", |
| ) |
|
|
| @classmethod |
| def _is_weak(cls, slide: dict[str, Any]) -> bool: |
| """Heuristic: bland phrases or no concrete data β needs critic rewrite.""" |
| layout = slide.get("layout") |
| if layout in ("title", "closing"): |
| return False |
|
|
| |
| chunks: list[str] = [str(slide.get("title") or "")] |
| if layout == "bullets": |
| chunks.extend(str(b) for b in (slide.get("bullets") or [])) |
| elif layout == "two-col": |
| for c in slide.get("columns") or []: |
| if isinstance(c, dict): |
| chunks.append(str(c.get("heading") or "")) |
| chunks.append(str(c.get("body") or "")) |
| elif layout == "stats": |
| for s in slide.get("stats") or []: |
| if isinstance(s, dict): |
| chunks.append(str(s.get("value") or "")) |
| chunks.append(str(s.get("label") or "")) |
| elif layout == "quote": |
| chunks.append(str(slide.get("quote") or "")) |
| chunks.append(str(slide.get("attribution") or "")) |
|
|
| text = " ".join(chunks).lower() |
| if not text.strip(): |
| return True |
|
|
| |
| for phrase in cls._BLAND_PHRASES: |
| if phrase in text: |
| return True |
|
|
| |
| if layout == "stats": |
| for s in slide.get("stats") or []: |
| val = str(s.get("value") if isinstance(s, dict) else "") |
| if not re.search(r"\d", val): |
| return True |
|
|
| |
| if layout in ("bullets", "two-col"): |
| joined = " ".join(chunks[1:]) |
| has_signal = bool( |
| re.search(r"\d", joined) |
| or re.search(r"\b(19|20)\d{2}\b", joined) |
| or re.search(r"\b[A-Z][a-z]+(?:\s[A-Z][a-z]+)+", joined) |
| ) |
| if not has_signal: |
| return True |
|
|
| return False |
|
|
| async def _critique_and_rewrite( |
| self, |
| topic: str, |
| research: str, |
| slides: list[dict[str, Any]], |
| on_progress: ProgressCallback, |
| ) -> tuple[list[dict[str, Any]], int, float]: |
| """For each weak slide, ask the LLM to rewrite it with specifics.""" |
| weak_indices = [i for i, s in enumerate(slides) if self._is_weak(s)] |
| if not weak_indices: |
| logger.info("critic.all_strong", extra={"n": len(slides)}) |
| return slides, 0, 0.0 |
|
|
| await on_progress( |
| f"Refining {len(weak_indices)} slide(s)...", 92.0, "critique" |
| ) |
| logger.info( |
| "critic.rewriting", extra={"weak": weak_indices, "total": len(slides)} |
| ) |
|
|
| total_tokens = 0 |
| total_cost = 0.0 |
| out = list(slides) |
|
|
| async def _rewrite_one(idx: int) -> None: |
| nonlocal total_tokens, total_cost |
| original = out[idx] |
| try: |
| text, tokens, cost = await self._ai_call( |
| role="critic", |
| system=CRITIC_SYSTEM_PROMPT, |
| user=critic_user_message(topic, research, original), |
| max_tokens=1024, |
| ) |
| total_tokens += tokens |
| total_cost += cost |
| rewritten = self._parse_single_slide(text) |
| if rewritten and rewritten.get("layout") == original.get("layout"): |
| rewritten["id"] = original.get("id") |
| out[idx] = rewritten |
| |
| try: |
| await on_progress( |
| f"Refined slide {idx + 1}.", |
| 93.0, |
| "critique", |
| event="slide", |
| slide_index=idx, |
| slide_total=len(out), |
| slide=rewritten, |
| ) |
| except Exception: |
| pass |
| except Exception as exc: |
| logger.warning( |
| "critic.rewrite_failed", extra={"i": idx, "err": str(exc)} |
| ) |
|
|
| |
| sem = asyncio.Semaphore(4) |
|
|
| async def _bound(idx: int) -> None: |
| async with sem: |
| await _rewrite_one(idx) |
|
|
| await asyncio.gather(*(_bound(i) for i in weak_indices)) |
| return out, total_tokens, total_cost |
|
|
| |
| |
| |
| |
| |
| |
| _IMAGE_LAYOUTS = { |
| "title", "bullets", "two-col", "closing", |
| "quote", "bigstat", "section_divider", |
| } |
|
|
| |
| |
| |
| _IMAGE_BANNED_WORDS = ( |
| "calming", "serene", "peaceful", "tranquil", |
| "growing", "vibrant", "modern", "professional", "corporate", |
| "beautiful", "stunning", "amazing", "perfect", "wonderful", |
| "abstract", "minimal", "minimalist", "clean", "simple", |
| "background", "wallpaper", "stock", "generic", |
| "businessman", "businesswoman", "team meeting", "handshake", |
| "lightbulb", "rocket", "graph going up", "thumbs up", |
| ) |
|
|
| |
| |
| |
| |
| _IMAGE_CINEMATIC_SUFFIX = ( |
| "shot on 35mm film, shallow depth of field, golden hour lighting, " |
| "editorial photography, atmospheric, cinematic composition, " |
| "muted color grading, no text, no logos, no watermarks" |
| ) |
|
|
| @classmethod |
| def _scrub_image_prompt(cls, raw: str) -> str: |
| """Strip banned vocabulary; collapse whitespace. Never returns empty.""" |
| cleaned = (raw or "").strip() |
| if not cleaned: |
| return "" |
| lowered = cleaned.lower() |
| for word in cls._IMAGE_BANNED_WORDS: |
| if word in lowered: |
| |
| |
| cleaned = re.sub( |
| rf"\b{re.escape(word)}\b", "", cleaned, flags=re.IGNORECASE |
| ) |
| cleaned = re.sub(r"\s+", " ", cleaned).strip(" ,.;:-") |
| return cleaned |
|
|
| @classmethod |
| def _direct_image_prompt(cls, subject: str) -> str: |
| """Compose subject + cinematic suffix into the final Pollinations brief.""" |
| subj = cls._scrub_image_prompt(subject) |
| if not subj: |
| subj = "documentary scene" |
| return f"{subj}, {cls._IMAGE_CINEMATIC_SUFFIX}" |
|
|
| async def _add_hero_images( |
| self, |
| topic: str, |
| slides: list[dict[str, Any]], |
| on_progress: ProgressCallback, |
| ) -> tuple[list[dict[str, Any]], int, float]: |
| """Attach a Pollinations image URL to slides that benefit from a visual.""" |
| from services.image_service import pollinations_url |
|
|
| target_indices = [ |
| i for i, s in enumerate(slides) if s.get("layout") in self._IMAGE_LAYOUTS |
| ] |
| if not target_indices: |
| return slides, 0, 0.0 |
|
|
| await on_progress("Generating slide imagery...", 94.0, "images") |
|
|
| |
| |
| |
| |
| |
| prompts: list[str] = [] |
| tokens = 0 |
| cost = 0.0 |
| try: |
| indexed = "\n".join( |
| f"{i + 1}. layout={slides[i].get('layout')} title={slides[i].get('title','')}" |
| for i in target_indices |
| ) |
| user = ( |
| f"Topic: {topic}\n\n" |
| f"Slides needing a cinematic image:\n{indexed}\n\n" |
| f"You are an art director briefing a documentary photographer.\n" |
| f"For EACH slide, write ONE concrete visual brief (max 22 words) " |
| f"specifying a SUBJECT, a LIGHTING condition, and a CAMERA framing.\n\n" |
| f"Examples of GOOD briefs:\n" |
| f"- 'rain-slicked harbor at dawn, fishermen pulling nets, long lens, side light'\n" |
| f"- 'half-empty warehouse with single forklift, fluorescent overhead, wide angle, deep shadow'\n" |
| f"- 'analyst's desk at night, blue monitor glow on face, 50mm portrait, shallow focus'\n\n" |
| f"DO NOT use words like: calming, serene, vibrant, modern, professional, " |
| f"abstract, beautiful, stunning, background. No stock-photo cliches " |
| f"(handshakes, lightbulbs, arrows pointing up, team meetings).\n" |
| f"DO use specific subjects, real places, named lighting, real lens choices.\n\n" |
| f"Return ONLY a JSON array of strings in slide order. No prose." |
| ) |
| text, tokens, cost = await self._ai_call( |
| role="vision", |
| system=( |
| "You are an art director writing cinematic image briefs. " |
| "You return only a JSON array of strings, each describing " |
| "a concrete subject, lighting, and lens." |
| ), |
| user=user, |
| max_tokens=700, |
| temperature=0.5, |
| ) |
| cleaned = self._strip_fences(text) |
| match = re.search(r"\[[\s\S]*\]", cleaned) |
| if match: |
| cleaned = match.group(0) |
| data = json.loads(cleaned) |
| if isinstance(data, list): |
| prompts = [str(p) for p in data] |
| except Exception as exc: |
| logger.warning("images.prompt_gen_failed", extra={"err": str(exc)}) |
| prompts = [] |
|
|
| |
| |
| |
| if len(prompts) < len(target_indices): |
| for i in target_indices[len(prompts):]: |
| t = slides[i].get("title") or topic |
| prompts.append(f"documentary scene illustrating {t}, low key lighting, 35mm") |
|
|
| for slot, idx in enumerate(target_indices): |
| raw = prompts[slot] if slot < len(prompts) else topic |
| final_prompt = self._direct_image_prompt(raw) |
| slides[idx]["image_url"] = pollinations_url(final_prompt, seed=idx + 1) |
| slides[idx]["image_prompt"] = final_prompt |
|
|
| logger.info( |
| "images.attached", extra={"count": len(target_indices), "total": len(slides)} |
| ) |
| return slides, tokens, cost |
|
|
|
|
| @staticmethod |
| def _strip_fences(text: str) -> str: |
| cleaned = (text or "").strip() |
| cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned) |
| cleaned = re.sub(r"\s*```$", "", cleaned) |
| return cleaned.strip() |
|
|
| @classmethod |
| def _parse_slides_array(cls, text: str) -> list[dict[str, Any]]: |
| cleaned = cls._strip_fences(text) |
| match = re.search(r"\[\s*[\s\S]*\]", cleaned) |
| if match: |
| cleaned = match.group(0) |
| try: |
| data = json.loads(cleaned) |
| except json.JSONDecodeError: |
| return [] |
| if not isinstance(data, list): |
| return [] |
| return [s for s in data if isinstance(s, dict)] |
|
|
| @classmethod |
| def _parse_single_slide(cls, text: str) -> dict[str, Any] | None: |
| cleaned = cls._strip_fences(text) |
| match = re.search(r"\{[\s\S]*\}", cleaned) |
| if match: |
| cleaned = match.group(0) |
| try: |
| data = json.loads(cleaned) |
| except json.JSONDecodeError: |
| return None |
| return data if isinstance(data, dict) else None |
|
|
| @classmethod |
| def _normalize_slides( |
| cls, slides: list[dict[str, Any]], slide_count: int, topic: str |
| ) -> list[dict[str, Any]]: |
| out: list[dict[str, Any]] = [] |
| for i, raw in enumerate(slides): |
| |
| |
| layout = normalize_layout(raw.get("layout")) |
| if layout not in _VALID_LAYOUTS: |
| layout = FALLBACK_LAYOUT |
| base = { |
| "id": f"slide-{i:03d}", |
| "layout": layout, |
| "title": str(raw.get("title") or "").strip(), |
| } |
| if layout == "title": |
| base.update( |
| { |
| "subtitle": str(raw.get("subtitle") or "").strip(), |
| "eyebrow": str(raw.get("eyebrow") or "Presentation").strip(), |
| } |
| ) |
| elif layout == "bullets": |
| bullets = raw.get("bullets") or [] |
| if not isinstance(bullets, list): |
| bullets = [] |
| base["bullets"] = [str(b).strip() for b in bullets if str(b).strip()][:4] |
| elif layout == "two-col": |
| cols = raw.get("columns") or [] |
| if not isinstance(cols, list): |
| cols = [] |
| base["columns"] = [ |
| { |
| "heading": str(c.get("heading") or "").strip(), |
| "body": str(c.get("body") or "").strip(), |
| } |
| for c in cols[:2] |
| if isinstance(c, dict) |
| ] |
| elif layout == "quote": |
| base["quote"] = str(raw.get("quote") or raw.get("title") or "").strip() |
| base["attribution"] = str(raw.get("attribution") or "").strip() |
| elif layout == "stats": |
| stats = raw.get("stats") or [] |
| if not isinstance(stats, list): |
| stats = [] |
| base["stats"] = [ |
| { |
| "value": str(s.get("value") or "").strip(), |
| "label": str(s.get("label") or "").strip(), |
| } |
| for s in stats[:3] |
| if isinstance(s, dict) |
| ] |
| elif layout == "chart": |
| cd = raw.get("chart_data") or {} |
| if not isinstance(cd, dict): |
| cd = {} |
| labels = cd.get("labels") or [] |
| values = cd.get("values") or [] |
| if not isinstance(labels, list): |
| labels = [] |
| if not isinstance(values, list): |
| values = [] |
| |
| numeric: list[float] = [] |
| for v in values: |
| try: |
| numeric.append(float(str(v).replace(",", "").replace("$", ""))) |
| except (TypeError, ValueError): |
| numeric.append(0.0) |
| pairs = min(len(labels), len(numeric)) |
| base["chart_type"] = ( |
| str(raw.get("chart_type") or "bar").strip().lower() or "bar" |
| ) |
| if base["chart_type"] not in {"bar", "line", "doughnut"}: |
| base["chart_type"] = "bar" |
| base["chart_data"] = { |
| "labels": [str(x).strip() for x in labels[:pairs]], |
| "values": numeric[:pairs], |
| "unit": str(cd.get("unit") or "").strip(), |
| "source": str(cd.get("source") or "").strip(), |
| } |
| base["subtitle"] = str(raw.get("subtitle") or "").strip() |
| elif layout == "closing": |
| base["subtitle"] = str(raw.get("subtitle") or "").strip() |
| base["cta"] = str(raw.get("cta") or "Thank you").strip() |
| elif layout == "bigstat": |
| |
| |
| |
| stats_first = None |
| stats = raw.get("stats") |
| if isinstance(stats, list) and stats and isinstance(stats[0], dict): |
| stats_first = stats[0] |
| base["value"] = str( |
| raw.get("value") |
| or (stats_first.get("value") if stats_first else "") |
| or "" |
| ).strip() |
| base["label"] = str( |
| raw.get("label") |
| or (stats_first.get("label") if stats_first else "") |
| or "" |
| ).strip() |
| base["subtitle"] = str(raw.get("subtitle") or "").strip() |
| elif layout == "section_divider": |
| |
| |
| base["eyebrow"] = str(raw.get("eyebrow") or "").strip() |
| base["subtitle"] = str(raw.get("subtitle") or "").strip() |
| elif layout == "timeline": |
| |
| |
| events = raw.get("events") or [] |
| if not isinstance(events, list): |
| events = [] |
| base["events"] = [ |
| { |
| "date": str(e.get("date") or "").strip(), |
| "label": str(e.get("label") or "").strip(), |
| } |
| for e in events[:6] |
| if isinstance(e, dict) |
| ] |
| base["subtitle"] = str(raw.get("subtitle") or "").strip() |
| elif layout == "comparison": |
| |
| |
| |
| left = raw.get("left") or {} |
| right = raw.get("right") or {} |
| if not isinstance(left, dict): |
| left = {} |
| if not isinstance(right, dict): |
| right = {} |
| base["left"] = { |
| "heading": str(left.get("heading") or "").strip(), |
| "body": str(left.get("body") or "").strip(), |
| } |
| base["right"] = { |
| "heading": str(right.get("heading") or "").strip(), |
| "body": str(right.get("body") or "").strip(), |
| } |
| base["subtitle"] = str(raw.get("subtitle") or "").strip() |
| out.append(base) |
|
|
| |
| while len(out) < slide_count: |
| out.append( |
| cls._fallback_slide( |
| {"layout": "bullets", "title": f"Section {len(out) + 1}", "intent": ""}, |
| len(out), |
| slide_count, |
| topic, |
| ) |
| ) |
| out = out[:slide_count] |
| if out: |
| out[0]["layout"] = "title" |
| out[-1]["layout"] = "closing" |
|
|
| |
| |
| has_chart = any(s.get("layout") == "chart" for s in out) |
| if not has_chart: |
| for s in out[1:-1]: |
| if s.get("layout") != "stats": |
| continue |
| stats = s.get("stats") or [] |
| labels: list[str] = [] |
| values: list[float] = [] |
| for item in stats: |
| raw_v = str(item.get("value", "")) |
| cleaned = raw_v.replace("$", "").replace(",", "").replace("%", "").strip() |
| |
| num = "" |
| for ch in cleaned: |
| if ch.isdigit() or ch in {".", "-"}: |
| num += ch |
| else: |
| break |
| try: |
| v = float(num) |
| except ValueError: |
| continue |
| labels.append(str(item.get("label", "")).strip()[:24] or f"Item {len(values) + 1}") |
| values.append(v) |
| if len(values) >= 2: |
| s["layout"] = "chart" |
| s["chart_type"] = "bar" |
| s["chart_data"] = { |
| "labels": labels, |
| "values": values, |
| "unit": "", |
| "source": s.get("source", ""), |
| } |
| |
| |
| |
| |
| |
| s["subtitle"] = s.get("subtitle", "") |
| s.pop("stats", None) |
| break |
|
|
| |
| |
| |
| |
| |
| try: |
| from agent.deck_quality import build_deck_quality_report |
|
|
| report = build_deck_quality_report(out) |
| for record in report.errors: |
| logger.warning( |
| "loop.slide_validation_failed slide=%d layout=%s path=%s code=%s message=%s", |
| record["slide_index"], |
| record["layout"], |
| record["path"], |
| record["code"], |
| record["message"], |
| ) |
| logger.info( |
| "loop.deck_quality_report ok=%s slide_count=%d valid=%d invalid=%d repairs_needed=%d", |
| report.ok, |
| report.slide_count, |
| report.valid_count, |
| report.invalid_count, |
| len(report.repair_actions), |
| ) |
| except Exception as exc: |
| logger.warning("loop.slide_validation_telemetry_failed err=%s", exc) |
|
|
| return out |
|
|
| @staticmethod |
| def _fallback_slide(plan: dict, index: int, total: int, topic: str) -> dict[str, Any]: |
| layout = plan.get("layout", "bullets") |
| title = plan.get("title") or f"Slide {index + 1}" |
| if layout == "title": |
| return {"id": f"slide-{index:03d}", "layout": "title", "title": topic, "subtitle": title, "eyebrow": "Presentation"} |
| if layout == "closing": |
| return {"id": f"slide-{index:03d}", "layout": "closing", "title": title, "subtitle": "", "cta": "Thank you"} |
| return { |
| "id": f"slide-{index:03d}", |
| "layout": "bullets", |
| "title": title, |
| "bullets": [ |
| "Key insight one.", |
| "Key insight two.", |
| "Key insight three.", |
| ], |
| } |
|
|
| |
| async def _mark_running(self, task_id: str, step: str, pct: float) -> None: |
| async with SessionLocal() as session: |
| res = await session.execute(select(Task).where(Task.id == task_id)) |
| task = res.scalar_one_or_none() |
| if task is None: |
| return |
| |
| |
| |
| if task.status == "cancelling": |
| raise JobCancelled(step) |
| task.status = "running" |
| task.current_step = step |
| task.progress_pct = pct |
| session.add(task) |
| await session.commit() |
|
|
| async def _mark_failed(self, task_id: str, err: str) -> None: |
| async with SessionLocal() as session: |
| res = await session.execute(select(Task).where(Task.id == task_id)) |
| task = res.scalar_one_or_none() |
| if task is None: |
| return |
| task.status = "failed" |
| task.current_step = "failed" |
| task.progress_pct = 100.0 |
| task.error_msg = err[:2000] |
| task.completed_at = datetime.now(timezone.utc) |
| session.add(task) |
| await session.commit() |
|
|
| async def _save_deck( |
| self, |
| task_id: str, |
| slides: list[dict[str, Any]], |
| theme: str, |
| tokens: int, |
| cost: float, |
| model_used: str, |
| ) -> None: |
| async with SessionLocal() as session: |
| existing = await session.execute( |
| select(SlideDeck).where(SlideDeck.task_id == task_id) |
| ) |
| deck = existing.scalar_one_or_none() |
| if deck is None: |
| deck = SlideDeck( |
| task_id=task_id, |
| slide_data=slides, |
| theme=theme, |
| slide_count=len(slides), |
| ) |
| session.add(deck) |
| else: |
| deck.slide_data = slides |
| deck.theme = theme |
| deck.slide_count = len(slides) |
| session.add(deck) |
|
|
| res = await session.execute(select(Task).where(Task.id == task_id)) |
| task = res.scalar_one_or_none() |
| if task is not None: |
| task.status = "done" |
| task.current_step = "done" |
| task.progress_pct = 100.0 |
| task.tokens_used = tokens |
| task.cost_usd = round(cost, 6) |
| task.model_used = model_used |
| task.completed_at = datetime.now(timezone.utc) |
| session.add(task) |
|
|
| await session.commit() |
|
|