File size: 8,759 Bytes
ad70c89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""
streaming_summarizer.py β€” Advanced streaming + parallel grant summarization

Implements:
1. True parallelization with configurable concurrency
2. OpenAI streaming (stream=True) for real-time token delivery
3. Per-grant streaming with immediate feedback
4. Graceful fallback for Gradio (not async context)
"""
import asyncio
import logging
from typing import Any, Dict, List, Optional, AsyncGenerator

from .llm_client import LLMClient
from .summarizer_optimized import SummaryCache, extract_minimal_context

logger = logging.getLogger(__name__)


async def summarize_grant_streaming(
    grant: Dict[str, Any],
    client: LLMClient,
    cache: SummaryCache,
    past_winners: Optional[List[Dict[str, Any]]] = None,
) -> AsyncGenerator[str, None]:
    """
    Stream a single grant summary token-by-token.

    Yields tokens as they arrive from OpenAI.
    """
    grant_id = grant.get("id") or grant.get("title") or "unknown"
    title = grant.get("title") or grant.get("name") or "(untitled)"

    # Check cache first
    cached_summary = cache.get(grant)
    if cached_summary:
        logger.info("πŸ“¦ Cache HIT for %s", grant_id)
        # Stream cached content quickly
        for token in cached_summary.split():
            yield token + " "
        return

    # Build context
    context = extract_minimal_context(grant, past_winners)

    # Stream from OpenAI
    from .prompt_templates import build_prompt

    payload = build_prompt("openai", context)
    full_response = ""

    try:
        # Use stream=True to get token-by-token delivery
        stream = client.chat(payload["messages"], max_tokens=1200, temperature=0.25, stream=True)

        for token in stream:
            full_response += token
            yield token

        # Cache the full response
        cache.set(grant, full_response)
        logger.info("βœ… Cached summary for %s", grant_id)

    except Exception as e:
        logger.error("❌ Stream failed for %s: %s", grant_id, e)
        error_msg = f"Error generating summary: {str(e)[:100]}"
        cache.set(grant, error_msg)
        yield error_msg


async def summarize_grants_parallel_streaming(
    current: List[Dict[str, Any]],
    past_winners: Optional[List[Dict[str, Any]]] = None,
    *,
    limit: Optional[int] = None,
    client: Optional[LLMClient] = None,
    cache: Optional[SummaryCache] = None,
    batch_size: int = 5,
    max_concurrent: int = 3,
) -> AsyncGenerator[Dict[str, Any], None]:
    """
    Parallel + Streaming: Process multiple grants concurrently.

    Each grant streams its own summary as it's being generated.
    Results yielded as they complete (not in order).

    Args:
        max_concurrent: Max grants to process simultaneously (default: 3)
    """
    client = client or LLMClient({})
    cache = cache or SummaryCache(ttl_seconds=3600)

    items = current[: limit or len(current)]

    if not items:
        return

    # Create a semaphore to limit concurrent tasks
    semaphore = asyncio.Semaphore(max_concurrent)

    async def bounded_summarize(grant, index):
        """Summarize with concurrency limit."""
        async with semaphore:
            grant_id = grant.get("id") or grant.get("title") or "unknown"
            title = grant.get("title") or grant.get("name") or "(untitled)"

            full_summary = ""

            try:
                logger.info(f"[{index+1}/{len(items)}] Summarizing: {title[:50]}")

                async for token in summarize_grant_streaming(grant, client, cache, past_winners):
                    full_summary += token

                return {
                    "grant_id": grant_id,
                    "title": title,
                    "summary_md": full_summary,
                    "index": index,
                }
            except Exception as e:
                logger.error(f"Failed to summarize {grant_id}: {e}")
                return {
                    "grant_id": grant_id,
                    "title": title,
                    "summary_md": f"Error: {str(e)[:100]}",
                    "index": index,
                }

    # Create all tasks
    tasks = [bounded_summarize(grant, i) for i, grant in enumerate(items)]

    # Yield results as they complete (using as_completed)
    for coro in asyncio.as_completed(tasks):
        result = await coro
        yield result

    logger.info("βœ… Completed all %d grants", len(items))


async def summarize_grants_batch_parallel_streaming(
    current: List[Dict[str, Any]],
    past_winners: Optional[List[Dict[str, Any]]] = None,
    *,
    limit: Optional[int] = None,
    client: Optional[LLMClient] = None,
    cache: Optional[SummaryCache] = None,
    batch_size: int = 5,
    max_concurrent_batches: int = 2,
) -> AsyncGenerator[Dict[str, Any], None]:
    """
    Optimized: Process batches in parallel, stream batch results.

    5 grants per batch β†’ reduced API calls
    Multiple batches in parallel β†’ maximum throughput
    Results stream as soon as batch completes

    This is the recommended approach for 30+ grants.
    """
    from .summarizer_optimized import _summarize_batch_async

    client = client or LLMClient({})
    cache = cache or SummaryCache(ttl_seconds=3600)

    items = current[: limit or len(current)]

    if not items:
        return

    # Build contexts
    contexts = [extract_minimal_context(g, past_winners) for g in items]

    # Create batches
    batches = [
        (items[i:i+batch_size], contexts[i:i+batch_size])
        for i in range(0, len(items), batch_size)
    ]

    # Create a semaphore for concurrent batch processing
    semaphore = asyncio.Semaphore(max_concurrent_batches)

    async def process_batch(batch_items, batch_contexts, batch_idx):
        """Process a batch with concurrency limit."""
        async with semaphore:
            try:
                logger.info(f"Processing batch {batch_idx+1}/{len(batches)} ({len(batch_items)} grants)")
                batch_results = await _summarize_batch_async(batch_items, batch_contexts, client, cache)

                for result in batch_results:
                    yield result

            except Exception as e:
                logger.error(f"Batch {batch_idx} failed: {e}")
                for grant in batch_items:
                    yield {
                        "grant_id": grant.get("id") or "unknown",
                        "title": grant.get("title") or "(untitled)",
                        "summary_md": f"Batch error: {str(e)[:100]}",
                    }

    # Process batches concurrently
    tasks = [
        process_batch(batch_items, batch_contexts, i)
        for i, (batch_items, batch_contexts) in enumerate(batches)
    ]

    # Yield from all tasks as they complete
    for task in asyncio.as_completed(tasks):
        async for result in task:
            yield result

    logger.info("βœ… All batches processed")


# Synchronous wrapper for use in non-async contexts (e.g., Gradio callbacks)
def summarize_grants_streaming_sync(
    current: List[Dict[str, Any]],
    past_winners: Optional[List[Dict[str, Any]]] = None,
    *,
    limit: Optional[int] = None,
    client: Optional[LLMClient] = None,
    cache: Optional[SummaryCache] = None,
    batch_size: int = 5,
    mode: str = "batch_parallel",  # "grant_parallel" or "batch_parallel"
) -> List[Dict[str, Any]]:
    """
    Synchronous wrapper for streaming summarization.

    Returns all results as a list (blocking until complete).
    Use this when you need results in order.
    """
    async def run():
        results = []
        if mode == "batch_parallel":
            async for result in summarize_grants_batch_parallel_streaming(
                current,
                past_winners=past_winners,
                limit=limit,
                client=client,
                cache=cache,
                batch_size=batch_size,
            ):
                results.append(result)
        else:  # grant_parallel
            async for result in summarize_grants_parallel_streaming(
                current,
                past_winners=past_winners,
                limit=limit,
                client=client,
                cache=cache,
                batch_size=batch_size,
            ):
                results.append(result)

        # Sort by original index if available
        return sorted(results, key=lambda x: x.get("index", float('inf')))

    try:
        loop = asyncio.get_event_loop()
        if loop.is_running():
            # Already in async context, return async generator
            raise RuntimeError("Use async version directly in async context")
    except RuntimeError:
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)

    return loop.run_until_complete(run())