diff --git a/backend/app/routes/focus_group_ai.py b/backend/app/routes/focus_group_ai.py index 58fe3d42..b8b1aea9 100755 --- a/backend/app/routes/focus_group_ai.py +++ b/backend/app/routes/focus_group_ai.py @@ -767,22 +767,42 @@ async def start_autonomous_conversation(focus_group_id): settings = await get_settings() run_cost = settings.get("run_cost", 40) - # Skip charge if this focus group was already charged within the last 4 hours - # (handles server restarts and error-triggered restarts without double-billing) + # Atomic idempotency: stamp the focus group doc with a charge token so that + # restarts (crash recovery, manual re-runs within 24h) never double-bill. + # find_one_and_update returns None when the doc already has a fresh charge_token. from datetime import datetime, timezone, timedelta + from bson import ObjectId as _OID db = await get_db() - recent_cutoff = datetime.now(timezone.utc) - timedelta(hours=4) - recent_charge = await db.credit_transactions.find_one({ - "user_id": run_user_id, - "ref.focus_group_id": focus_group_id, - "description": "Focus group session run", - "ts": {"$gte": recent_cutoff}, - }) + cutoff_24h = datetime.now(timezone.utc) - timedelta(hours=24) + charge_token = str(_OID()) + stamped = await db.focus_groups.find_one_and_update( + { + "_id": _OID(focus_group_id), + "$or": [ + {"last_charged_at": {"$exists": False}}, + {"last_charged_at": {"$lt": cutoff_24h}}, + ], + }, + { + "$set": { + "last_charged_at": datetime.now(timezone.utc), + "last_charge_token": charge_token, + "last_charge_user": run_user_id, + } + }, + return_document=False, + ) - if recent_charge is None: + if stamped is not None: + # Stamp succeeded → this is a fresh run, charge credits user_data = await User.find_by_id(run_user_id) balance = (user_data or {}).get("credits_balance", 0) if balance < run_cost: + # Roll back the stamp so the user can try again after topping up + await db.focus_groups.update_one( + {"_id": _OID(focus_group_id), "last_charge_token": charge_token}, + {"$unset": {"last_charged_at": "", "last_charge_token": "", "last_charge_user": ""}}, + ) return jsonify({ "error": "Insufficient credits", "message": f"You need {run_cost} credits to run a focus group session. Current balance: {balance}.", @@ -791,18 +811,25 @@ async def start_autonomous_conversation(focus_group_id): }), 402 new_balance = await User.deduct_credits(run_user_id, run_cost) if new_balance is None: + await db.focus_groups.update_one( + {"_id": _OID(focus_group_id), "last_charge_token": charge_token}, + {"$unset": {"last_charged_at": "", "last_charge_token": "", "last_charge_user": ""}}, + ) return jsonify({"error": "Insufficient credits", "message": "Credit balance changed. Please try again."}), 402 - await CreditTransaction.record( - user_id=run_user_id, - tx_type="debit", - amount=-run_cost, - balance_after=new_balance, - description="Focus group session run", - ref={"focus_group_id": focus_group_id}, - ) + try: + await CreditTransaction.record( + user_id=run_user_id, + tx_type="debit", + amount=-run_cost, + balance_after=new_balance, + description="Focus group session run", + ref={"focus_group_id": focus_group_id}, + ) + except Exception as _tx_err: + current_app.logger.warning(f"CreditTransaction.record failed (non-fatal): {_tx_err}") current_app.logger.info(f"Charged {run_cost} credits for focus group {focus_group_id}") else: - current_app.logger.info(f"Focus group {focus_group_id} already charged within 4h window — skipping deduction") + current_app.logger.info(f"Focus group {focus_group_id} already charged within 24h window — skipping deduction") # Create autonomous conversation controller current_app.logger.info("Creating AutonomousConversationController...") diff --git a/backend/app/routes/focus_groups.py b/backend/app/routes/focus_groups.py index fa66dea1..4f03d5ee 100755 --- a/backend/app/routes/focus_groups.py +++ b/backend/app/routes/focus_groups.py @@ -1358,13 +1358,18 @@ async def download_full_report(focus_group_id): pdf.set_text_color(*DARK) def meta_row(label, value): - # Bold muted label above normal value + """Two-column row: muted label left, value right, same line.""" + LABEL_W = 50 + VAL_W = EPW - LABEL_W + y0 = pdf.get_y() set_r(8, bold=True) pdf.set_text_color(*MUTED) - pdf.multi_cell(EPW, 5, label.upper()) + pdf.set_x(pdf.l_margin) + pdf.multi_cell(LABEL_W, 5.5, label.upper(), new_x="RIGHT", new_y="TOP") set_r(10) pdf.set_text_color(*DARK) - pdf.multi_cell(EPW, 5.5, value) + pdf.set_xy(pdf.l_margin + LABEL_W, y0) + pdf.multi_cell(VAL_W, 5.5, value, new_x="LMARGIN", new_y="NEXT") pdf.ln(2) def quote_block(text): @@ -1422,10 +1427,13 @@ async def download_full_report(focus_group_id): pdf.ln(3) set_r(11, bold=True) pdf.set_text_color(*DARK) - pdf.multi_cell(EPW, 6, f"{i}. {t.get('title', '')}") + pdf.set_x(pdf.l_margin) + pdf.multi_cell(EPW, 6, f"{i}. {t.get('title', '')}", new_x="LMARGIN", new_y="NEXT") + pdf.set_x(pdf.l_margin) body(t.get("description", ""), color=MUTED) quotes = t.get("quotes", []) if quotes: + pdf.set_x(pdf.l_margin) set_r(9, bold=True) pdf.set_text_color(*MUTED) pdf.cell(EPW, 5, L["quotes"] + ":", new_x="LMARGIN", new_y="NEXT") @@ -1487,16 +1495,18 @@ async def download_full_report(focus_group_id): pdf.multi_cell(EPW, 4.5, line) pdf.ln(1) - # Page numbers footer + # Page numbers footer — iterate all pages and stamp at bottom def add_page_numbers(): - set_r(8) - pdf.set_text_color(*MUTED) - for i in range(1, pdf.page + 1): + n = len(pdf.pages) + saved = pdf.page + for i in range(1, n + 1): pdf.page = i - pdf.set_y(-12) + pdf.set_y(pdf.h - 12) pdf.set_x(pdf.l_margin) - pdf.cell(EPW, 5, f"{i}", align="C", new_x="LMARGIN", new_y="NEXT") - pdf.page = pdf.pages_count + set_r(8) + pdf.set_text_color(*MUTED) + pdf.cell(EPW, 5, str(i), align="C", new_x="LMARGIN", new_y="NEXT") + pdf.page = saved add_page_numbers() diff --git a/backend/app/services/key_theme_service.py b/backend/app/services/key_theme_service.py index a5af989f..0e6968ed 100755 --- a/backend/app/services/key_theme_service.py +++ b/backend/app/services/key_theme_service.py @@ -57,7 +57,11 @@ class KeyThemeService: raise KeyThemeServiceError("No messages found in this focus group") logger.info(f"Found {len(messages)} messages in focus group {focus_group_id}") - + # Cap at 80 messages to keep the input prompt within token limits + if len(messages) > 80: + messages = messages[-80:] + logger.info("Truncated to last 80 messages for theme extraction") + # Get all participants (personas) in the focus group participants_data = [] if 'participants' in focus_group and focus_group['participants']: @@ -144,7 +148,8 @@ class KeyThemeService: prompt=prompt, temperature=temperature, system_prompt=system_prompt, - model_name=llm_model + model_name=llm_model, + max_output_tokens=4000, ) logger.info(f"Attempt {attempt_num}/{max_retries}: LLM ({llm_model or 'gpt-5.4'}) call successful, received {len(themes)} themes") diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index 75b576b9..bc563c2c 100755 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -233,9 +233,10 @@ class LLMService: input_content, reasoning_effort: Optional[str] = None, verbosity: Optional[str] = None, + max_output_tokens: Optional[int] = None, ) -> dict: """Build the kwargs dict for a Responses API call.""" - return { + kwargs = { "model": actual_model, "input": input_content, "reasoning": {"effort": reasoning_effort or "low"}, @@ -244,6 +245,9 @@ class LLMService: "verbosity": verbosity or "medium", }, } + if max_output_tokens is not None: + kwargs["max_output_tokens"] = max_output_tokens + return kwargs @staticmethod async def generate_content( @@ -253,7 +257,8 @@ class LLMService: model_name: Optional[str] = None, system_prompt: Optional[str] = None, reasoning_effort: Optional[str] = None, - verbosity: Optional[str] = None + verbosity: Optional[str] = None, + max_output_tokens: Optional[int] = None, ) -> str: """Generate text content via the Azure AI Foundry Responses API. @@ -265,6 +270,7 @@ class LLMService: system_prompt: Optional system instruction prepended to the prompt. reasoning_effort: Responses API reasoning effort (low/medium/high). verbosity: Responses API verbosity (low/medium/high). + max_output_tokens: Hard cap on output tokens for Responses API. """ logger = logging.getLogger(__name__) max_retries = 3 @@ -290,7 +296,7 @@ class LLMService: else: input_content = prompt - kwargs = LLMService._build_responses_kwargs(actual_model, input_content, reasoning_effort, verbosity) + kwargs = LLMService._build_responses_kwargs(actual_model, input_content, reasoning_effort, verbosity, max_output_tokens) for attempt in range(max_retries): attempt_num = attempt + 1 @@ -369,13 +375,15 @@ class LLMService: model_name: Optional[str] = None, system_prompt: Optional[str] = None, reasoning_effort: Optional[str] = None, - verbosity: Optional[str] = None + verbosity: Optional[str] = None, + max_output_tokens: Optional[int] = None, ) -> List[Dict[str, Any]]: """Generate and parse a structured JSON array response.""" text = await LLMService.generate_content( prompt=prompt, temperature=temperature, max_tokens=max_tokens, model_name=model_name, system_prompt=system_prompt, reasoning_effort=reasoning_effort, verbosity=verbosity, + max_output_tokens=max_output_tokens, ) result = LLMService.parse_json_response(text) if not isinstance(result, list): diff --git a/backend/prompts/conversation-decision-engine.md b/backend/prompts/conversation-decision-engine.md index e1e68ae2..fa83c064 100755 --- a/backend/prompts/conversation-decision-engine.md +++ b/backend/prompts/conversation-decision-engine.md @@ -205,35 +205,39 @@ EXAMPLE_JSON_END 1. **Systematic Guide Progression**: ALWAYS continue through the discussion guide systematically, regardless of how well topics may have been covered in previous conversation 2. **No Time Pressure**: Time duration is NOT a factor in decision making - continue through the guide at a natural pace without rushing to end due to elapsed time -3. **Previously Covered Topics**: If a question relates to topics discussed earlier: +3. **Minimum Depth Per Question**: Each discussion guide question MUST receive at least 4–6 participant responses before the moderator moves on. If fewer than 4 participants have responded to the current question, prioritize `participant_respond` for those who haven't spoken on this topic yet. Never transition to the next question prematurely. +4. **Previously Covered Topics**: If a question relates to topics discussed earlier: - Acknowledge and briefly summarize previous points made - Still ask the current question from the discussion guide - Allow participants to expand, clarify, or add new perspectives - Example: "Earlier you mentioned X about this topic. Now I'd like to focus specifically on [current question]..." -4. **Natural Pacing**: Allow natural pauses and don't rush responses -5. **Balanced Participation**: Ensure all participants have opportunities to contribute -6. **Topic Coherence**: Keep conversation focused while allowing organic development -7. **Emotional Intelligence**: Respond appropriately to participant emotions and energy -8. **Research Objectives**: Always keep the research goals in mind -9. **Authentic Interactions**: Facilitate genuine-feeling exchanges between participants +5. **Active Debate**: The goal is a RICH, LIVELY discussion. After 2 participants agree on a point, actively seek out a contrarian or devil's advocate. After 2 responses on the same angle, push for a different perspective. Use `participant_interaction` to spark direct participant-to-participant exchange. +6. **Natural Pacing**: Allow natural pauses and don't rush responses +7. **Balanced Participation**: Ensure ALL participants contribute to EVERY question. A participant who hasn't spoken on the current topic should be called on before anyone speaks a third time on that topic. +8. **Topic Coherence**: Keep conversation focused while allowing organic development +9. **Emotional Intelligence**: Respond appropriately to participant emotions and energy +10. **Research Objectives**: Always keep the research goals in mind +11. **Authentic Interactions**: Facilitate genuine-feeling exchanges between participants ## DECISION MAKING INSTRUCTIONS 1. **FIRST AND FOREMOST**: Scan the most recent moderator message for @mentions of participant names 2. **If @mentions found**: Select "participant_respond" action with the @mentioned participant - skip all other analysis 3. **If no @mentions**: Analyze the current conversation state and participant context -4. **SYSTEMATIC PROGRESSION PRIORITY**: Always prioritize moving through the discussion guide systematically - do NOT end sessions or skip questions because topics seem "thoroughly covered" -5. **IGNORE TIME FACTORS**: Do not consider duration, elapsed time, or time pressure when making decisions - focus solely on discussion guide completion -6. **HANDLE REPEAT TOPICS**: If the current question relates to previously discussed topics: +4. **DEPTH BEFORE BREADTH**: Before moving to the next discussion guide question, count how many DISTINCT participants have responded to the current question. If fewer than 4 have responded, select `participant_respond` for one who hasn't spoken on this topic yet. Only move on once the current question has been genuinely explored by multiple participants. +5. **SYSTEMATIC PROGRESSION PRIORITY**: Always prioritize moving through the discussion guide systematically - do NOT end sessions or skip questions because topics seem "thoroughly covered" +6. **IGNORE TIME FACTORS**: Do not consider duration, elapsed time, or time pressure when making decisions - focus solely on discussion guide completion +7. **PROMOTE DEBATE**: If the last 3 responses are all in agreement, use `probe_trigger` (convergence) or select a contrarian participant via `participant_respond` with a challenging `topic_context`. +8. **HANDLE REPEAT TOPICS**: If the current question relates to previously discussed topics: - Choose "moderator_speak" to acknowledge previous discussion and ask the current question - Include brief summary of previous relevant points in the content - Still proceed with the question to allow deeper exploration -7. Apply the behavioral rules to determine the most appropriate action -8. Consider the research objectives and discussion guide progress -9. Choose the action that best serves systematic guide progression -10. **CRITICAL**: Identify the MOST SPECIFIC question or activity ID that matches the current conversation topic - avoid general section-only mapping -11. Provide clear reasoning for your decision (mention @mention detection in reasoning) -12. Format your response as valid JSON with precise position mapping +9. Apply the behavioral rules to determine the most appropriate action +10. Consider the research objectives and discussion guide progress +11. Choose the action that best serves systematic guide progression +12. **CRITICAL**: Identify the MOST SPECIFIC question or activity ID that matches the current conversation topic - avoid general section-only mapping +13. Provide clear reasoning for your decision (mention @mention detection in reasoning) +14. Format your response as valid JSON with precise position mapping **REMINDER**: @Mentions are ABSOLUTE PRIORITY. If you see "@John" or "John, what do you think?" - John MUST respond regardless of fatigue, recency, or any other factors.