@@ -347,11 +347,34 @@ async def reset_stale_processing_states():
347347)
348348
349349
350+ def _thinking_max_tokens (base : int , thinking : bool | None ) -> int :
351+ """Scale max_tokens when thinking mode is active.
352+
353+ Thinking tokens and output tokens share the same max_tokens budget.
354+ When thinking is enabled, the model may consume thousands of tokens
355+ on reasoning before producing any output. Scale the budget up so
356+ the actual content is not truncated.
357+ """
358+ if not thinking :
359+ return base
360+ # At minimum give 2× the base; cap at 32768 to avoid excessive latency.
361+ return min (base * 2 , 32768 )
362+
363+
350364class MeetingService :
351365 """High-level meeting operations: transcription providers, summary, allocation (v3)."""
352366
353367 def __init__ (self ) -> None :
354- pass
368+ import threading as _threading
369+ # Per-(meeting,tab) active section-stream tasks.
370+ # Maps "meeting_id:tab_id" → (event_queue, thread).
371+ # Prevents duplicate LLM calls when SSE reconnects on page refresh.
372+ self ._section_stream_lock = _threading .Lock ()
373+ self ._active_section_streams : dict [str , tuple ] = {}
374+ # Per-meeting active blueprint-stream tasks.
375+ # Maps "meeting_id" → (event_queue, thread).
376+ self ._blueprint_stream_lock = _threading .Lock ()
377+ self ._active_blueprint_streams : dict [str , tuple ] = {}
355378
356379 # -- Provider accessors -------------------------------------------------
357380
@@ -396,6 +419,10 @@ def generate_blueprint_stream(self, meeting_id: str):
396419 Runs after Pass 1 completes. Emits ``blueprint_start`` and
397420 ``blueprint_done`` events. State: idle → prefilling → idle.
398421
422+ Deduplication: if a blueprint task is already running for this
423+ meeting, the new SSE connection attaches to the existing event
424+ queue instead of spawning duplicate LLM calls.
425+
399426 Yields dicts::
400427
401428 {"event": "state", "data": {"summary": "prefilling"}}
@@ -408,8 +435,39 @@ def generate_blueprint_stream(self, meeting_id: str):
408435 {"event": "state", "data": {"blueprint": "idle"}}
409436 {"event": "error", "data": {"message": "..."}}
410437 """
438+ import queue
439+ import threading
440+
411441 from src .meeting .models import ProcessingState , GenerationState
412442
443+ # ── Deduplication: reuse existing task if one is running ────
444+ with self ._blueprint_stream_lock :
445+ existing = self ._active_blueprint_streams .get (meeting_id )
446+ if existing is not None :
447+ eq , thread = existing
448+ if thread .is_alive ():
449+ logger .info (
450+ "[STREAM] Reusing existing blueprint task for %s" ,
451+ meeting_id ,
452+ )
453+ try :
454+ while True :
455+ try :
456+ event_type , event_data = eq .get (timeout = 0.1 )
457+ except queue .Empty :
458+ continue
459+ if event_type == "done" :
460+ break
461+ yield {"event" : event_type , "data" : event_data }
462+ except GeneratorExit :
463+ logger .info (
464+ "[STREAM] SSE client disconnected (reuse) for %s" ,
465+ meeting_id ,
466+ )
467+ return
468+ else :
469+ del self ._active_blueprint_streams [meeting_id ]
470+
413471 logger .info ("[STREAM] Starting blueprint stream for meeting %s" , meeting_id )
414472
415473 # ── Build context (shared with _do_blueprint_summary) ──────
@@ -594,7 +652,7 @@ def _run_llm() -> None:
594652 prompt = blueprint_prompt ,
595653 system = MEETING_BLUEPRINT_SYSTEM ,
596654 temperature = 0.0 ,
597- max_tokens = 8192 ,
655+ max_tokens = _thinking_max_tokens ( 8192 , meeting_thinking ) ,
598656 response_format = {"type" : "json_object" },
599657 thinking = meeting_thinking ,
600658 )
@@ -656,7 +714,7 @@ def _run_llm() -> None:
656714 raw_blueprint = llm .generate (
657715 blueprint_prompt ,
658716 system = MEETING_BLUEPRINT_SYSTEM ,
659- max_tokens = 8192 ,
717+ max_tokens = _thinking_max_tokens ( 8192 , meeting_thinking ) ,
660718 temperature = 0.0 ,
661719 thinking = meeting_thinking ,
662720 response_format = {"type" : "json_object" },
@@ -759,9 +817,13 @@ def _run_llm() -> None:
759817 finally :
760818 bp_executor .shutdown (wait = False )
761819 event_queue .put (("done" , None ))
820+ with self ._blueprint_stream_lock :
821+ self ._active_blueprint_streams .pop (meeting_id , None )
762822
763823 # ── Launch thread ────────────────────────────────────────
764824 thread = threading .Thread (target = _run_llm , daemon = True )
825+ with self ._blueprint_stream_lock :
826+ self ._active_blueprint_streams [meeting_id ] = (event_queue , thread )
765827 thread .start ()
766828
767829 # ── Read queue → SSE events ──────────────────────────────
@@ -939,7 +1001,7 @@ def _do_blueprint_summary(self, meeting_id: str) -> None:
9391001 raw_blueprint = llm .generate (
9401002 blueprint_prompt ,
9411003 system = MEETING_BLUEPRINT_SYSTEM ,
942- max_tokens = 8192 ,
1004+ max_tokens = _thinking_max_tokens ( 8192 , meeting_thinking ) ,
9431005 temperature = 0.0 ,
9441006 thinking = meeting_thinking ,
9451007 response_format = {"type" : "json_object" },
@@ -1392,7 +1454,7 @@ def _run_tagger_phase(tab_id: str, receipt: dict) -> None:
13921454 raw = llm .generate (
13931455 tagger_prompt ,
13941456 system = MEETING_TAGGER_V3_SYSTEM ,
1395- max_tokens = 16384 ,
1457+ max_tokens = _thinking_max_tokens ( 16384 , meeting_thinking ) ,
13961458 temperature = 0.0 ,
13971459 thinking = meeting_thinking ,
13981460 response_format = {"type" : "json_object" },
@@ -1484,7 +1546,7 @@ def _run_summarizer_phase(tab_id: str, receipt: dict) -> None:
14841546 raw = llm .generate (
14851547 summarizer_prompt ,
14861548 system = MEETING_SUMMARIZER_V3_SYSTEM ,
1487- max_tokens = 8192 ,
1549+ max_tokens = _thinking_max_tokens ( 8192 , meeting_thinking ) ,
14881550 thinking = meeting_thinking ,
14891551 )
14901552 validated = _clean_refs (_normalize_refs (_normalize_brackets (raw )), list (payload_ids ))
@@ -1691,6 +1753,10 @@ def generate_section_stream(self, meeting_id: str, tab_id: str):
16911753 the main generator reads from a queue so the SSE connection can drop
16921754 without cancelling the work.
16931755
1756+ Deduplication: if a generation task is already running for this
1757+ ``(meeting_id, tab_id)``, the new SSE connection attaches to the
1758+ existing event queue instead of spawning a duplicate LLM call.
1759+
16941760 Yields dicts::
16951761
16961762 {"event": "state", "data": {"section_gen": "prefilling"}}
@@ -1707,8 +1773,38 @@ def generate_section_stream(self, meeting_id: str, tab_id: str):
17071773 from src .meeting .pipeline import build_payload
17081774 from src .meeting .schemas import Sentence
17091775
1776+ # ── Deduplication: reuse existing task if one is running ────────
1777+ task_key = f"{ meeting_id } :{ tab_id } "
1778+ with self ._section_stream_lock :
1779+ existing = self ._active_section_streams .get (task_key )
1780+ if existing is not None :
1781+ eq , thread = existing
1782+ if thread .is_alive ():
1783+ logger .info (
1784+ "[SECTION-STREAM] Reusing existing task for %s" , task_key ,
1785+ )
1786+ try :
1787+ while True :
1788+ try :
1789+ event_type , event_data = eq .get (timeout = 0.1 )
1790+ except queue .Empty :
1791+ continue
1792+ if event_type == "done" :
1793+ break
1794+ yield {"event" : event_type , "data" : event_data }
1795+ except GeneratorExit :
1796+ logger .info (
1797+ "[SECTION-STREAM] SSE client disconnected (reuse) for %s" ,
1798+ task_key ,
1799+ )
1800+ return
1801+ else :
1802+ # Thread finished — clean up stale entry
1803+ del self ._active_section_streams [task_key ]
1804+
17101805 event_queue : queue .Queue = queue .Queue ()
17111806
1807+ # Register this task so reconnecting SSE clients can reuse it
17121808 def _run () -> None :
17131809 try :
17141810 # ── Load context ──────────────────────────────────
@@ -1822,7 +1918,7 @@ def _other_sections_text(exclude_tid: str) -> str:
18221918 raw = llm .generate (
18231919 tagger_prompt ,
18241920 system = MEETING_TAGGER_V3_SYSTEM ,
1825- max_tokens = 16384 ,
1921+ max_tokens = _thinking_max_tokens ( 16384 , meeting_thinking ) ,
18261922 temperature = 0.0 ,
18271923 thinking = meeting_thinking ,
18281924 response_format = {"type" : "json_object" },
@@ -1908,7 +2004,7 @@ def _other_sections_text(exclude_tid: str) -> str:
19082004 for text , is_thinking in llm .generate_stream_tagged (
19092005 summarizer_prompt ,
19102006 system = MEETING_SUMMARIZER_V3_SYSTEM ,
1911- max_tokens = 8192 ,
2007+ max_tokens = _thinking_max_tokens ( 8192 , meeting_thinking ) ,
19122008 thinking = meeting_thinking ,
19132009 ):
19142010 if is_thinking :
@@ -1955,9 +2051,13 @@ def _other_sections_text(exclude_tid: str) -> str:
19552051 event_queue .put (("error" , {"message" : str (e )}))
19562052 finally :
19572053 event_queue .put (("done" , None ))
2054+ with self ._section_stream_lock :
2055+ self ._active_section_streams .pop (task_key , None )
19582056
19592057 # ── Launch background thread ──────────────────────────────
19602058 thread = threading .Thread (target = _run , daemon = True )
2059+ with self ._section_stream_lock :
2060+ self ._active_section_streams [task_key ] = (event_queue , thread )
19612061 thread .start ()
19622062
19632063 # ── Read queue → SSE events ──────────────────────────────
@@ -2602,24 +2702,6 @@ def _parse_tagger_response(raw: str) -> dict[str, list[str]]:
26022702 import re as _re
26032703
26042704 raw_stripped = raw .strip ()
2605- # Search for {"sentence_ids" specifically (not bare '{' which
2606- # appears in reasoning text). Look from the end — the JSON
2607- # should be the last thing in the response.
2608- idx = raw_stripped .rfind ('{"sentence_ids"' )
2609- if idx < 0 :
2610- # Fallback: try any '{' from the end
2611- idx = raw_stripped .rfind ("{" )
2612- if idx < 0 :
2613- # Last resort: try to extract bare integer IDs from the text
2614- fallback_ids = _re .findall (r"\b(\d{1,4})\b" , raw_stripped )
2615- if fallback_ids :
2616- logger .warning (
2617- "[TAGGER] No JSON found, fallback extracted %d numeric IDs from text" ,
2618- len (fallback_ids ),
2619- )
2620- return {"sentence_ids" : _normalize_ids ([int (x ) for x in fallback_ids ])}
2621- logger .warning ("[TAGGER] No JSON object found in LLM response (%d chars)" , len (raw_stripped ))
2622- return {"sentence_ids" : []}
26232705
26242706 def _normalize_ids (raw_ids : list ) -> list [str ]:
26252707 """Convert integer IDs → 'stt_XXXX', pass strings through unchanged."""
@@ -2633,6 +2715,20 @@ def _normalize_ids(raw_ids: list) -> list[str]:
26332715 result .append (str (i )) # legacy: "stt_0001" etc.
26342716 return result
26352717
2718+ # Search for {"sentence_ids" specifically (not bare '{' which
2719+ # appears in reasoning text). Look from the end — the JSON
2720+ # should be the last thing in the response.
2721+ idx = raw_stripped .rfind ('{"sentence_ids"' )
2722+ if idx < 0 :
2723+ # Fallback: try any '{' from the end
2724+ idx = raw_stripped .rfind ("{" )
2725+ if idx < 0 :
2726+ # No JSON structure found at all — do NOT attempt regex ID
2727+ # extraction from reasoning/thinking text (produces hundreds
2728+ # of false positives). Return empty and let the caller retry.
2729+ logger .warning ("[TAGGER] No JSON object found in LLM response (%d chars)" , len (raw_stripped ))
2730+ return {"sentence_ids" : []}
2731+
26362732 # Try raw_decode first (proper nested-brace handling)
26372733 last_err = ""
26382734 try :
0 commit comments