"""Scale up evaluation report mapping against evaluation frameworks using agentic workflows"""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/08_mappr_lisette.ipynb.

# %% auto 0
__all__ = ['GEMINI_API_KEY', 'cfg', 'traces_dir', 'db_path', 'db', 'sections_cache', 'theme_cache', 'select_section_sp',
           'tagging_sp', 'tagging_sp_no_citation', 'SectionsCache', 'ThemeTaggingCache', 'load_report',
           'find_section_path', 'get_content_tool', 'flatten_sections', 'format_toc_for_llm', 'format_enabler_theme',
           'format_crosscutting_theme', 'format_gcm_theme', 'format_srf_output', 'CoreSectionsOutput',
           'EvidenceLocation', 'ThemeTaggingOutput', 'parse_response', 'identify_core_sections', 'extract_core_content',
           'tag_theme', 'Stage', 'TraceContext', 'setup_logger', 'setup_trace_logging', 'log_analysis_event',
           'get_from_cache', 'store_in_cache', 'limit', 'TaggingResult', 'PipelineResults', 'PipelineOrchestrator',
           'get_filtered_srf_output_ids']

# %% ../nbs/08_mappr_lisette.ipynb 5
from pathlib import Path
from functools import reduce
from toolslm.md_hier import *
from rich import print
import json
from fastcore.all import *
from enum import Enum
import logging
import uuid
from datetime import datetime
from typing import List, Callable
import dspy
from asyncio import Semaphore, gather, sleep
import time
from collections import defaultdict
import copy

from pydantic import BaseModel, Field
from typing import List

from .frameworks import (EvalData, 
                                 IOMEvalData, 
                                 FrameworkInfo, 
                                 Framework,
                                 FrameworkCat,
                                 find_srf_output_by_id)

#from evaluatr.db_traces import TraceDB, Trace
from fastlite import Database
from apswutils.db import NotFoundError

from lisette import Chat, AsyncChat, mk_msg
from lisette.core import acompletion
import json

from dataclasses import dataclass
from fastlite import Database
from pathlib import Path

# %% ../nbs/08_mappr_lisette.ipynb 6
from dotenv import load_dotenv
import os

load_dotenv()
GEMINI_API_KEY = os.getenv('GEMINI_API_KEY')

# %% ../nbs/08_mappr_lisette.ipynb 7
cfg = AttrDict({
    'lm': 'gemini/gemini-2.0-flash',
    'api_key': GEMINI_API_KEY,
    'max_tokens': 8192,
    'track_usage': False,
    'call_delay': 0.1, # in seconds
    'semaphore': 10,
    'dirs': AttrDict({
        'data': '.evaluatr',
        'trace': 'traces'
    }),
    'verbosity': 1,
    'cache': AttrDict({
        'db_name':  'pipeline_cache.db'
    }),
})

# %% ../nbs/08_mappr_lisette.ipynb 8
traces_dir = Path.home() / cfg.dirs.data / cfg.dirs.trace
traces_dir.mkdir(parents=True, exist_ok=True)

# %% ../nbs/08_mappr_lisette.ipynb 10
db_path = traces_dir / cfg.cache.db_name
db = Database(db_path)

# %% ../nbs/08_mappr_lisette.ipynb 11
@dataclass
class SectionsCache:
    report_id: str
    sections_selected: str  # JSON list
    reasoning: str
    timestamp: str = None

# %% ../nbs/08_mappr_lisette.ipynb 12
@dataclass
class ThemeTaggingCache:
    report_id: str
    stage: str
    framework: str
    framework_category: str
    framework_theme_id: str
    is_core: bool
    reasoning: str
    confidence: str
    timestamp: str = None

# %% ../nbs/08_mappr_lisette.ipynb 13
sections_cache = db.create(SectionsCache, pk='report_id', ignore=True)
theme_cache = db.create(
    ThemeTaggingCache, 
    pk=['report_id', 'stage', 'framework', 'framework_category', 'framework_theme_id'],
    ignore=True
)

# %% ../nbs/08_mappr_lisette.ipynb 15
def load_report(doc_path:str):
    "Read evaluation report from enriched markdown pages"
    doc = Path(doc_path)
    pages = doc.ls(file_exts=".md").sorted(key=lambda p: int(p.stem.split('_')[1]))
    report = '\n\n---\n\n'.join(page.read_text() for page in pages)
    return report

# %% ../nbs/08_mappr_lisette.ipynb 20
def find_section_path(
    hdgs: dict, # The nested dictionary structure
    target_section: str # The section name to find
) -> list: # The nested key path for the given section name
    "Find the nested key path for a given section name."
    def search_recursive(current_dict, path=[]):
        for key, value in current_dict.items():
            current_path = path + [key]
            if key == target_section:
                return current_path
            if isinstance(value, dict):
                result = search_recursive(value, current_path)
                if result:
                    return result
        return None
    
    return search_recursive(hdgs)

# %% ../nbs/08_mappr_lisette.ipynb 24
def get_content_tool(
    hdgs: dict, # The nested dictionary structure
    keys_list: list, # The list of keys to navigate through
    ) -> str: # The content of the section
    "Navigate through nested levels using the exact key strings."
    return reduce(lambda current, key: current[key], keys_list, hdgs).text

# %% ../nbs/08_mappr_lisette.ipynb 26
def flatten_sections(hdgs, path=[]):
    """Extract flat list of (key, full_path) tuples from nested hdgs"""
    sections = []
    for key, value in hdgs.items():
        current_path = path + [key]
        sections.append((key, current_path))
        if isinstance(value, dict):
            sections.extend(flatten_sections(value, current_path))
    return sections

# %% ../nbs/08_mappr_lisette.ipynb 28
def format_toc_for_llm(hdgs: dict) -> str:
    """Format ToC as readable text with page numbers"""
    sections = flatten_sections(hdgs)
    lines = [f"- {key}" for key, path in sections]
    return '\n'.join(lines)

# %% ../nbs/08_mappr_lisette.ipynb 32
def format_enabler_theme(
    theme: EvalData # The theme object
    ) -> str: # The formatted theme string
    "Format SRF enabler into structured text for LM processing."
    parts = [
        f'## Enabler {theme.id}: {theme.title}',
        '### Description', 
        theme.description
    ]
    return '\n'.join(parts)

# %% ../nbs/08_mappr_lisette.ipynb 35
def format_crosscutting_theme(
    theme: EvalData # The theme object
    ) -> str: # The formatted theme string
    "Format SRF cross-cutting into structured text for LM processing."
    parts = [
        f'## Cross-cutting {theme.id}: {theme.title}',
        '### Description', 
        theme.description
    ]
    return '\n'.join(parts)

# %% ../nbs/08_mappr_lisette.ipynb 38
def format_gcm_theme(
    theme: dict # The GCM theme object from gcm_small
    ) -> str: # The formatted theme string
    "Format GCM objective into structured text for LM processing."
    parts = [
        f'## GCM Objective {theme["id"]}: {theme["title"]}',
        '### Core Theme', 
        theme["core_theme"]
    ]
    
    if theme.get("key_principles"):
        parts.extend(['### Key Principles', ', '.join(theme["key_principles"])])
    
    if theme.get("target_groups"):
        parts.extend(['### Target Groups', ', '.join(theme["target_groups"])])
        
    if theme.get("main_activities"):
        parts.extend(['### Main Activities', ', '.join(theme["main_activities"])])
    
    return '\n'.join(parts)

# %% ../nbs/08_mappr_lisette.ipynb 41
def format_srf_output(output_context: dict) -> str:
    "Format SRF output with full hierarchical context for LM processing."
    parts = [
        f'## SRF Output {output_context["output"]["id"]}: {output_context["output"]["title"]}',
        '### Strategic Context',
        f'**Objective {output_context["objective"]["id"]}**: {output_context["objective"]["title"]}',
        f'**Long    -term Outcome {output_context["long_outcome"]["id"]}**: {output_context["long_outcome"]["title"]}',
        f'**Short-term Outcome {output_context["short_outcome"]["id"]}**: {output_context["short_outcome"]["title"]}'
    ]
    
    return '\n'.join(parts)

# %% ../nbs/08_mappr_lisette.ipynb 45
class CoreSectionsOutput(BaseModel):
    """Identify the core sections of the report"""
    section_names: list[str]
    reasoning: str

# %% ../nbs/08_mappr_lisette.ipynb 46
class EvidenceLocation(BaseModel):
    """Identify the location of the evidence in the report"""
    section: str
    citation: str

# %% ../nbs/08_mappr_lisette.ipynb 47
class ThemeTaggingOutput(BaseModel):
    """Tag the theme in the report"""
    is_core: bool
    reasoning: str
    evidence_locations: list[EvidenceLocation]
    confidence: str  # low/medium/high

# %% ../nbs/08_mappr_lisette.ipynb 49
select_section_sp = """### ROLE AND OBJECTIVE
You are an expert evaluation report analyst. Your task is to identify sections that would help determine if specific themes are CORE to this report for synthesis and retrieval purposes.

### CONTEXT
You will receive a table of contents (ToC) with section headings from an evaluation report. Select sections where report authors signal what matters most - these will be used to tag themes for future synthesis work.

### SECTIONS TO IDENTIFY
Look for sections that reveal core themes (in any language):
1. Executive Summary / Overview / Résumé exécutif / Resumen ejecutivo
2. Introduction / Objectives / Purpose / Questions d'évaluation / Preguntas de evaluación
3. Main Findings / Results / Résultats / Resultados / Constatations
4. Conclusions / Conclusiones
5. Recommendations / Recommandations / Recomendaciones

### SELECTION CRITERIA
- Match flexibly by meaning, not exact wording
- Prioritize where authors explicitly state what's important
- Aim for ~8-10 pages total (use page numbers in ToC as guide)
- Avoid methodology, background, annexes unless unusually central
- Not all report types have all sections - select what exists

### OUTPUT FORMAT
JSON with section_names (list) and reasoning (string).

**CRITICAL**: section_names must contain EXACT strings from the ToC provided.
Copy the complete line including section numbers and page references.
Example: If ToC shows "4.1. Relevance of programme activities .... page 34"
Return exactly: "4.1. Relevance of programme activities .... page 34"
"""

# %% ../nbs/08_mappr_lisette.ipynb 50
tagging_sp = """### ROLE AND OBJECTIVE
You are an evaluation synthesis specialist. Your task is to determine if this report should be tagged with a specific theme for future retrieval in synthesis work.

### CONTEXT
You will receive:
- Key sections from an evaluation report
- A specific theme to evaluate

### CRITICAL DISTINCTION
You are NOT evaluating whether the theme is mentioned or relevant.
You ARE evaluating whether the theme is CENTRAL to what this report is fundamentally about.

### TAGGING DECISION CRITERIA
Tag as CORE only if the theme meets BOTH conditions:

**1. Centrality Test**: The theme is a PRIMARY focus of the report:
- The theme appears in the report's main objectives/evaluation questions
- Multiple major sections dedicate substantial analysis to this theme
- Key findings and conclusions center on this theme
- Major recommendations address this theme

**2. Synthesis Value Test**: Ask yourself:
"If I were synthesizing evaluation findings specifically on [Theme X], would EXCLUDING this report create a significant gap in my synthesis?"

### DECISION RULE
- Tag as CORE: The report would be among the TOP sources for a synthesis on this theme
- Tag as NOT CORE: The report mentions the theme but isn't fundamentally about it

**When uncertain → Tag as NOT CORE**

Aim for precision: Only 2-4 themes per report should be CORE.

### OUTPUT FORMAT
JSON with:
- is_core: boolean
- reasoning: explain centrality (or lack thereof) with specific evidence
- evidence_locations: list of {"section": "...", "citation": "..."}
- confidence: low/medium/high
"""

# %% ../nbs/08_mappr_lisette.ipynb 51
tagging_sp_no_citation = """### ROLE AND OBJECTIVE
You are an evaluation synthesis specialist. Your task is to determine if this report should be tagged with a 
specific theme for future retrieval in synthesis work.

### CONTEXT
You will receive:
- Key sections from an evaluation report
- A specific theme to evaluate

### CRITICAL DISTINCTION
You are NOT evaluating whether the theme is mentioned or relevant.
You ARE evaluating whether the theme is CENTRAL to what this report is fundamentally about.

### TAGGING DECISION CRITERIA
Tag as CORE only if the theme meets BOTH conditions:

**1. Centrality Test**: The theme is a PRIMARY focus of the report:
- The theme appears in the report's main objectives/evaluation questions
- Multiple major sections dedicate substantial analysis to this theme
- Key findings and conclusions center on this theme
- Major recommendations address this theme

**2. Synthesis Value Test**: Ask yourself:
"If I were synthesizing evaluation findings specifically on [Theme X], would EXCLUDING this report create a 
significant gap in my synthesis?"

### DECISION RULE
- Tag as CORE: The report would be among the TOP sources for a synthesis on this theme
- Tag as NOT CORE: The report mentions the theme but isn't fundamentally about it

**When uncertain → Tag as NOT CORE**

Aim for precision: Only 2-4 themes per report should be CORE.

### OUTPUT FORMAT
JSON with:
- is_core: boolean
- reasoning: explain centrality (or lack thereof) with specific evidence (max 150 words)
- confidence: low/medium/high"""


# %% ../nbs/08_mappr_lisette.ipynb 53
def parse_response(result):
    "Extract JSON from Lisette response"
    return json.loads(result.choices[0].message.content)

# %% ../nbs/08_mappr_lisette.ipynb 55
async def identify_core_sections(
    hdgs: dict,
    system_prompt: str,
    model: str = 'gemini/gemini-2.0-flash'
) -> dict:
    chat = AsyncChat(model=model, sp=system_prompt, temp=0)
    toc_text = format_toc_for_llm(hdgs)
    result = await chat(
        f"Here is the table of contents:\n\n{toc_text}",
        response_format=CoreSectionsOutput
    )
    return parse_response(result)

# %% ../nbs/08_mappr_lisette.ipynb 57
def extract_core_content(
    core_section_names: list[str],  # Section names from Step 1
    hdgs: dict  # Nested heading structure
) -> str:  # Combined content with section headers
    "Extract and combine content from core sections"
    if not core_section_names:
        logging.warning("No core sections provided")
        return ""
    
    sections_lookup = {key: path for key, path in flatten_sections(hdgs)}
    content_parts = []
    
    for section_name in core_section_names:
        if section_name not in sections_lookup:
            logging.warning(f"Section not found: {section_name}")
            continue
        path = sections_lookup[section_name]
        content = get_content_tool(hdgs, path)
        content_parts.append(content)
    
    return "\n\n---\n\n".join(content_parts)

# %% ../nbs/08_mappr_lisette.ipynb 61
async def tag_theme(
    doc_content: str,
    theme: str,
    system_prompt: str,
    response_format: type = TagResult,
    model: str = "claude-sonnet-4-5",
    cache_system: bool = True,   # Always cache system+doc (default)
    cache_theme: bool = False     # Only cache theme for resume
):
    "Tag a single theme against the document"
    system_blocks = [
        {"type": "text", "text": system_prompt},
        {"type": "text", "text": f"\n\n## Document to Analyze\n\n{doc_content}"}
    ]
    
    if cache_system: system_blocks[-1]["cache_control"] = {"type": "ephemeral"}
    
    messages = [mk_msg(theme, cache=cache_theme)]
    
    response = await acompletion(
        model=model,
        messages=messages,
        system=system_blocks,
        response_format=response_format
    )
    return response

# %% ../nbs/08_mappr_lisette.ipynb 64
class Stage(Enum):
    "Pipeline stage number."
    STAGE1 = "stage1"
    STAGE2 = "stage2"
    STAGE3 = "stage3"
    def __str__(self): return self.value

# %% ../nbs/08_mappr_lisette.ipynb 66
class TraceContext(AttrDict):
    "Context for tracing the mapping process"
    def __init__(self, 
                 report_id:str,  # Report identifier
                 stage:Stage,  # Pipeline stage number
                 framework:FrameworkInfo,  # Framework info (name, category, theme_id)
                 ): 
        # self.run_id = str(uuid.uuid4())[:8]  # Short unique ID
        store_attr()

    def __repr__(self):
        return f"TraceContext(report_id={self.report_id}, stage={self.stage}, framework={self.framework})"


# %% ../nbs/08_mappr_lisette.ipynb 68
def setup_logger(name, handler, level=logging.INFO, **kwargs):
    "Helper function to setup a logger with common configuration"
    logger = logging.getLogger(name)
    logger.handlers.clear()
    logger.addHandler(handler)
    logger.setLevel(level)
    for k,v in kwargs.items(): setattr(logger, k, v)
    return logger

# %% ../nbs/08_mappr_lisette.ipynb 69
def setup_trace_logging(report_id, verbosity=cfg.verbosity):
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f'{report_id}_{timestamp}.jsonl'
    file_handler = logging.FileHandler(traces_dir / filename, mode='w')
    setup_logger('trace.file', file_handler)    
    console_handler = logging.StreamHandler()
    setup_logger('trace.console', console_handler, verbosity=verbosity)

# %% ../nbs/08_mappr_lisette.ipynb 70
def _build_base_data(event: str, report_id: str, stage: Stage = None, 
                     framework_info: FrameworkInfo = None, **extra_data):
    """Build base data dictionary for logging"""
    base_data = {
        "timestamp": datetime.now().isoformat(),
        "event": event,
        "report_id": report_id,
    }
    
    if stage:
        base_data["stage"] = str(stage)
    
    if framework_info:
        base_data.update({
            "framework": str(framework_info.name),
            "framework_category": str(framework_info.category),
            "framework_theme_id": str(framework_info.theme_id),
        })
    
    base_data.update(extra_data)
    return base_data

# %% ../nbs/08_mappr_lisette.ipynb 71
def _format_console_msg(base_data, verbosity, stage, framework_info):
    """Format console message based on verbosity level"""
    report_id = base_data['report_id']
    event = base_data['event']
    
    if verbosity == 1:
        return f"{report_id}" + (f" - {stage}" if stage else "")
    elif verbosity == 2:
        parts = [report_id]
        if stage: parts.append(str(stage))
        parts.append(event)
        if framework_info:
            parts.append(f"{framework_info.name}/{framework_info.category}/{framework_info.theme_id}")
        return " - ".join(parts)
    else:  # verbosity == 3
        return json.dumps(base_data, indent=2)

# %% ../nbs/08_mappr_lisette.ipynb 72
def log_analysis_event(event: str, report_id: str, stage: Stage = None, 
                       framework_info: FrameworkInfo = None, **extra_data):
    """Log an analysis event to file and console with different verbosity levels"""
    file_logger = logging.getLogger('trace.file')
    console_logger = logging.getLogger('trace.console')
    
    base_data = _build_base_data(event, report_id, stage, framework_info, **extra_data)
    
    # File logger - always full JSON
    file_logger.info(json.dumps(base_data, indent=2))
    
    # Console logger - verbosity-based
    if hasattr(console_logger, 'verbosity'):
        console_msg = _format_console_msg(base_data, console_logger.verbosity, 
                                         stage, framework_info)
        console_logger.info(console_msg)


# %% ../nbs/08_mappr_lisette.ipynb 74
def get_from_cache(table, pk_value):
    """Generic cache retrieval, returns None if not found"""
    try:
        return table.get(pk_value)
    except NotFoundError:
        return None

# %% ../nbs/08_mappr_lisette.ipynb 75
def store_in_cache(table, data):
    """Generic cache storage with automatic timestamp"""
    data['timestamp'] = datetime.now().isoformat()
    table.upsert(data)

# %% ../nbs/08_mappr_lisette.ipynb 76
async def limit(semaphore, coro, delay=None):
    "Execute coroutine with semaphore concurrency control"
    async with semaphore:
        result = await coro
        if delay: await sleep(delay)
        return result

# %% ../nbs/08_mappr_lisette.ipynb 77
class TaggingResult(AttrDict):
    def __init__(self, response: dict, framework_info: FrameworkInfo):
        self.is_core = response['is_core']
        self.reasoning = response['reasoning']
        self.confidence = response['confidence']
        self.framework_name = framework_info.name
        self.framework_category = framework_info.category
        self.framework_theme_id = framework_info.theme_id

# %% ../nbs/08_mappr_lisette.ipynb 78
class PipelineResults(dict):
    def __init__(self):
        super().__init__()
        self[Stage.STAGE1] = defaultdict(lambda: defaultdict(dict))
        self[Stage.STAGE2] = defaultdict(lambda: defaultdict(dict))
        self[Stage.STAGE3] = defaultdict(lambda: defaultdict(dict))

# %% ../nbs/08_mappr_lisette.ipynb 79
@patch
def __call__(self:PipelineResults, stage=Stage.STAGE1, filter_type="all"):
    themes = []
    for frameworks in self[stage].values():
        for categories in frameworks.values():
            for theme in categories.values():
                if filter_type == "all" or \
                   (filter_type == "tagged" and theme.is_core) or \
                   (filter_type == "untagged" and not theme.is_core):
                    themes.append(theme)
    return themes

# %% ../nbs/08_mappr_lisette.ipynb 80
cfg.pipeline = AttrDict({
    'select_section_prompt': select_section_sp,
    'tagging_prompt': tagging_sp_no_citation,
    'model': 'claude-sonnet-4-5',
    'cache_system': True,
    'cache_theme': True,
    'response_format': TagResult,
    'verbosity': 2,
    'call_delay': 1,
    'force_refresh': AttrDict({
        'sections': False,
        'stage1': False,
        'stage2': False,
        'stage3': False
    })
})

# %% ../nbs/08_mappr_lisette.ipynb 81
class PipelineOrchestrator:
    def __init__(self, report_id, hdgs, eval_data, cfg):
        self.cfg_p = cfg.pipeline
        store_attr()
        setup_trace_logging(report_id, self.cfg_p.verbosity)
        self.results = PipelineResults()
        self.core_sections = None
        self.doc_content = None

# %% ../nbs/08_mappr_lisette.ipynb 82
@patch
async def identify_sections(self:PipelineOrchestrator, semaphore):
    "Identify core sections and log the selection"
    if not self.cfg_p.force_refresh.sections:
        cached = get_from_cache(sections_cache, self.report_id)
        if cached:
            self.core_sections = json.loads(cached.sections_selected)
            self.doc_content = extract_core_content(self.core_sections, self.hdgs)
            log_analysis_event(
                "sections_retrieved_from_cache",
                self.report_id,
                sections_selected=self.core_sections)
            return
    
    core_sections_result = await limit(
        semaphore,
        identify_core_sections(self.hdgs, self.cfg_p.select_section_prompt, self.cfg_p.model),
        self.cfg_p.call_delay
    )
    
    self.core_sections = core_sections_result['section_names']
    self.doc_content = extract_core_content(self.core_sections, self.hdgs)
    
    store_in_cache(sections_cache, {
        'report_id': self.report_id,
        'sections_selected': json.dumps(self.core_sections),
        'reasoning': core_sections_result['reasoning']
    })
    
    log_analysis_event(
        "sections_identified",
        self.report_id,
        sections_selected=self.core_sections,
        reasoning=core_sections_result['reasoning']
    )

# %% ../nbs/08_mappr_lisette.ipynb 83
@patch
async def run(self:PipelineOrchestrator, semaphore):
    "Run complete pipeline: identify sections, then all stages"
    await self.identify_sections(semaphore)
    await self.run_stage1(semaphore)
    await self.run_stage2(semaphore)
    await self.run_stage3(semaphore)

# %% ../nbs/08_mappr_lisette.ipynb 84
@patch
def _tag_kwargs(self:PipelineOrchestrator):
    return {
        'system_prompt': self.cfg_p.tagging_prompt,
        'response_format': self.cfg_p.response_format,
        'model': self.cfg_p.model,
        'cache_system': self.cfg_p.cache_system,
        'cache_theme': self.cfg_p.cache_theme
    }

# %% ../nbs/08_mappr_lisette.ipynb 85
@patch
async def process_themes_batch(self:PipelineOrchestrator, 
                               themes, 
                               semaphore, 
                               stage, 
                               force_refresh,
                               log_fn=None
                               ):
    "Process multiple themes in parallel with rate limiting and caching"
    
    async def process_one(theme, framework_info):
        # Check cache first (unless force_refresh)
        if not force_refresh:
            pk = (self.report_id, str(stage), str(framework_info.name), 
                  str(framework_info.category), str(framework_info.theme_id))
            cached = get_from_cache(theme_cache, pk)
            if cached:
                result = TaggingResult(
                    {'is_core': cached.is_core, 'reasoning': cached.reasoning, 
                     'confidence': cached.confidence}, 
                    framework_info
                )
                if log_fn: 
                    log_analysis_event("theme_retrieved_from_cache", self.report_id, 
                                     stage=stage, framework_info=framework_info)
                return result
        
        # Cache miss - call LLM
        response = await tag_theme(
            doc_content=self.doc_content,
            theme=theme,
            **self._tag_kwargs()
        )
        parsed = parse_response(response)
        result = TaggingResult(parsed, framework_info)
        
        # Store in cache
        store_in_cache(theme_cache, {
            'report_id': self.report_id,
            'stage': str(stage),
            'framework': str(framework_info.name),
            'framework_category': str(framework_info.category),
            'framework_theme_id': str(framework_info.theme_id),
            'is_core': result.is_core,
            'reasoning': result.reasoning,
            'confidence': result.confidence
        })
        
        if log_fn: log_fn(result, framework_info)
        return result
    
    # Process with rate limiting
    results = [await limit(semaphore, process_one(*themes[0]), self.cfg_p.call_delay)]
    remaining = await gather(*[limit(semaphore, process_one(*t), self.cfg_p.call_delay) for t in themes[1:]])
    results.extend(remaining)
    
    return results


# %% ../nbs/08_mappr_lisette.ipynb 86
@patch
async def run_stage1(self:PipelineOrchestrator, semaphore):
    themes = []
    
    for item in self.eval_data.srf_enablers:
        framework_info = FrameworkInfo(Framework.SRF, FrameworkCat.ENABLERS, item.id)
        themes.append((format_enabler_theme(item), framework_info))
    
    for item in self.eval_data.srf_crosscutting_priorities:
        framework_info = FrameworkInfo(Framework.SRF, FrameworkCat.CROSSCUT, item.id)
        themes.append((format_crosscutting_theme(item), framework_info))
    
    log_fn = lambda result, finfo: log_analysis_event(
        "theme_tagged",
        self.report_id,
        stage=Stage.STAGE1,
        framework_info=finfo,
        is_core=result.is_core,
        reasoning=result.reasoning,
        confidence=result.confidence
    )

    results = await self.process_themes_batch(
        themes=themes, 
        semaphore=semaphore, 
        stage=Stage.STAGE1, 
        force_refresh=self.cfg_p.force_refresh.stage1,
        log_fn=log_fn
    )
    
    for result in results:
        self.results[Stage.STAGE1][result.framework_name][result.framework_category][result.framework_theme_id] = result

# %% ../nbs/08_mappr_lisette.ipynb 89
@patch
def get_stage1_context(self:PipelineOrchestrator) -> str:
    "Get formatted context from Stage 1 tagged themes"
    tagged_themes = self.results(Stage.STAGE1, filter_type="tagged")
    if not tagged_themes: 
        return ""
    
    context_parts = []
    for theme in tagged_themes:
        if theme.framework_category == str(FrameworkCat.ENABLERS):
            theme_data = next(t for t in self.eval_data.srf_enablers 
                            if t.id == theme.framework_theme_id)
        elif theme.framework_category == str(FrameworkCat.CROSSCUT):
            theme_data = next(t for t in self.eval_data.srf_crosscutting_priorities 
                            if t.id == theme.framework_theme_id)
        
        context_parts.append(f"- **{theme.framework_category} {theme_data.id}**: {theme_data.title}")
    
    return f"### Report Preliminary Context\nThis evaluation report covers the following Strategic Results Framework themes:\n" + "\n".join(context_parts)


# %% ../nbs/08_mappr_lisette.ipynb 92
@patch
async def run_stage2(self:PipelineOrchestrator, semaphore):
    "Run stage 2 - GCM objectives analysis with Stage 1 context"
    stage1_context = self.get_stage1_context()
    themes = []
    
    for gcm_obj in self.eval_data.gcm_objectives_small:
        framework_info = FrameworkInfo(Framework.GCM, FrameworkCat.OBJS, gcm_obj["id"])
        theme = format_gcm_theme(gcm_obj) + "\n\n" + stage1_context
        themes.append((theme, framework_info))
    
    log_fn = lambda result, finfo: log_analysis_event(
        "theme_tagged",
        self.report_id,
        stage=Stage.STAGE2,
        framework_info=finfo,
        is_core=result.is_core,
        reasoning=result.reasoning,
        confidence=result.confidence
    )

    results = await self.process_themes_batch(
        themes=themes,
        semaphore=semaphore,
        stage=Stage.STAGE2,
        force_refresh=self.cfg_p.force_refresh.stage2,
        log_fn=log_fn
    )
    
    for result in results: 
        self.results[Stage.STAGE2][result.framework_name][result.framework_category][result.framework_theme_id] = result


# %% ../nbs/08_mappr_lisette.ipynb 95
def get_filtered_srf_output_ids(
    results: PipelineResults, # PipelineResults
    eval_data: EvalData # EvalData
    ) -> list: # list of SRF output IDs
    "Get filtered SRF output IDs based on covered GCM themes."
    covered_gcm = results(Stage.STAGE2, filter_type="tagged")
    srf_output_ids = set()
    
    for gcm_theme in covered_gcm:
        gcm_id = gcm_theme.framework_theme_id
        if gcm_id in eval_data.gcm_srf_lut:
            srf_output_ids.update(eval_data.gcm_srf_lut[gcm_id])
    
    return list(srf_output_ids)

# %% ../nbs/08_mappr_lisette.ipynb 98
@patch
def get_combined_context(self:PipelineOrchestrator) -> str:
    "Get combined context from Stage 1 and Stage 2 tagged themes"
    stage1_context = self.get_stage1_context()
    tagged_gcm = self.results(Stage.STAGE2, filter_type="tagged")
    
    if not tagged_gcm:
        return stage1_context
    
    gcm_context = "\n".join([
        f"- **GCM {theme.framework_theme_id}**: {self.eval_data.gcm_objectives_small[int(theme.framework_theme_id)-1]['title']}" 
        for theme in tagged_gcm
    ])
    
    return f"{stage1_context}\n\n### Covered GCM Objectives\n{gcm_context}"

# %% ../nbs/08_mappr_lisette.ipynb 101
@patch
def get_filtered_srf_outputs(self:PipelineOrchestrator) -> list:
    "Get filtered SRF output IDs based on tagged GCM themes"
    tagged_gcm = self.results(Stage.STAGE2, filter_type="tagged")
    srf_output_ids = set()
    
    for gcm_theme in tagged_gcm:
        gcm_id = gcm_theme.framework_theme_id
        if gcm_id in self.eval_data.gcm_srf_lut:
            srf_output_ids.update(self.eval_data.gcm_srf_lut[gcm_id])
    
    return list(srf_output_ids)

# %% ../nbs/08_mappr_lisette.ipynb 103
@patch
async def run_stage3(self:PipelineOrchestrator, semaphore):
    "Run stage 3 - Targeted SRF outputs analysis with combined context"
    combined_context = self.get_combined_context()
    filtered_output_ids = self.get_filtered_srf_outputs()
    themes = []
    
    for output_id in filtered_output_ids:
        output_context = find_srf_output_by_id(self.eval_data, output_id)
        if output_context:
            framework_info = FrameworkInfo(Framework.SRF, FrameworkCat.OUTPUTS, output_id)
            theme = format_srf_output(output_context) + "\n\n" + combined_context
            themes.append((theme, framework_info))
    
    log_fn = lambda result, finfo: log_analysis_event(
        "theme_tagged",
        self.report_id,
        stage=Stage.STAGE3,
        framework_info=finfo,
        is_core=result.is_core,
        reasoning=result.reasoning,
        confidence=result.confidence
    )

    results = await self.process_themes_batch(
        themes=themes,
        semaphore=semaphore,
        stage=Stage.STAGE3,
        force_refresh=self.cfg_p.force_refresh.stage3,
        log_fn=log_fn
    )
    
    for result in results: 
        self.results[Stage.STAGE3][result.framework_name][result.framework_category][result.framework_theme_id] = result

