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:
parent
8f068cc81c
commit
4c70bc8aa6
5 changed files with 106 additions and 52 deletions
|
|
@ -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...")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue