Skip to content

Commit 30bec90

Browse files
committed
fix: thinking+json_mode crash, SSE dedup, improve meeting summary quality
- Extract JSON from reasoning_content before _strip_think() when json_mode is active (fixes DashScope/mimo empty response bug) - Deduplicate blueprint and section SSE streams to prevent duplicate LLM calls on page refresh - Scale max_tokens 2x when thinking mode is active - Rewrite Detail section style: final answers only, no discussion journey, speaker attribution only for decisions/opinions - Strengthen language constraint (hard failure on language switch) - Add inference rules: allow single-step arithmetic, forbid multi-step/domain-specific derivations
1 parent 4c5a1a2 commit 30bec90

3 files changed

Lines changed: 369 additions & 106 deletions

File tree

src/meeting/service.py

Lines changed: 122 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
350364
class 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

Comments
 (0)