fix: PDF layout, credit idempotency, more active discussion

- PDF: meta_row redesigned to same-line two-column layout so values
  don't overflow the right edge; explicit cursor reset before each
  multi_cell body() call fixes key themes appearing to the right of
  titles; page numbers now use len(pdf.pages) + pdf.h-12 positioning
- Credits: replace fragile 4h credit_transactions lookup with atomic
  findOneAndUpdate stamp on the focus group doc itself (24h window),
  with rollback on insufficient balance — eliminates double-charging
  on crash/restart; CreditTransaction.record failure is now non-fatal
- Key themes: cap input at 80 messages + max_output_tokens=4096 to
  fix truncated JSON (Unterminated string at char 1580)
- Decision engine: require ≥4 participant responses per question before
  moving on; mandate debate/contrarian seeking after 2 agreements;
  call all participants to each question before anyone speaks 3rd time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-25 21:09:14 +01:00
parent 8f068cc81c
commit 4c70bc8aa6
5 changed files with 106 additions and 52 deletions

View file

@ -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...")

View file

@ -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()

View file

@ -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")

View file

@ -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):

View file

@ -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 46 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.