- Store amount_usd in Stripe webhook transaction (was only storing credits)
- Analytics endpoint: sum amount_usd for real USD revenue instead of credits
- Persona count: query personas collection directly (not credit transactions
which counted transactions, not individual personas — batch creation
of 3 at once appeared as count=1)
- Frontend: Revenue card now shows USD not credits; Gross Margin uses
USD revenue so calculation is dimensionally correct
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Per-persona message history: each persona now sees their own 8 previous
responses, preventing repetition and enabling position evolution
- OCEAN archetype labels in decision engine context: instead of raw numbers,
the decision LLM now sees "agreeableness: 72/100 [HIGH] — consensus-seeker"
- P2P interaction context: when participants interact directly, each one now
knows who they are responding to and what that person last said
- Python-level contrarian override: when agreement ratio in recent messages
exceeds 6% and a contrarian persona (low agreeableness or high neuroticism)
hasn't spoken recently, Python overrides moderator/probe action to call them
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add techSavviness, hasPurchasingPower, hasChildren, description to
_format_persona_details() — previously ignored despite being in persona model
- Rewrite OCEAN block: each trait now includes its psychological definition,
full name, and [LOW/MODERATE/HIGH] label so LLM understands the scoring scale
(e.g. "Agreeableness (cooperation, empathy, conflict avoidance): 25/100 [LOW]")
- Add extraversion behavioral constraint to _generate_behavioral_instructions()
(was used for response length only, never for personality guidance)
- Add LOW conscientiousness constraint (spontaneous, gut-feel)
- Add BALANCED PERSONALITY baseline for personas with all-moderate OCEAN scores —
previously these got zero behavioral constraints, behaving generically
- Add optimist/enthusiast personality keyword mapping
- Add OCEAN archetype legend to conversation-decision-engine.md so decision
engine understands what high/low scores mean when selecting next speaker
- Add +15% Diversity Boost modifier: prefer speakers whose archetype differs
from the last 2 participants (reduces echo-chamber dynamics)
- Add OCEAN model explanation header to focus-group-response.md prompt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extend _format_persona_details() to include background, traits, values,
interests, communication_style, brandLoyalty, priceConsciousness, demographics
(race/ethnicity/culture) — previously these fields were stored but never
sent to the LLM, causing all personas to give generic homogeneous answers
- Add _generate_behavioral_instructions() that translates OCEAN scores into
explicit behavioral directives (low agreeableness → must push back,
high neuroticism → voice of doubt, skeptic keyword → find the overpromise)
- Update focus-group-response.md to render BEHAVIORAL CONSTRAINTS and
DEMOGRAPHIC CONTEXT sections so LLM uses cultural/financial context
- Add anti-groupthink rule to conversation-decision-engine: on convergence,
prefer a contrarian persona before asking moderator probe question
- Improve ai-moderator-system.md: active @mention pull-in of silent
participants, spontaneous follow-up probes on interesting responses,
engagement tracking
- Add LANGUAGE RULE to all three response prompts: always match the language
of the question (fixes mixed Russian/English responses)
- Add POST /focus-groups/<id>/restart endpoint: clears session collections,
resets status to 'new', preserves config
- Add POST /focus-groups/<id>/duplicate endpoint: copies config to new group
- Frontend: Duplicate + Restart buttons on focus group cards with confirmation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dict(session.metadata) fails with KeyError:0 — metadata is a StripeObject
in SDK v5, not a plain dict. Use getattr() for all metadata fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
stripe.Webhook.construct_event() now returns StripeObject instances that
do not support .get()/__getitem__ — must use attribute access.
event["type"] → event.type, session.get("x") → session.x, etc.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- /api/usage/me now returns credits_balance, credits_spent (MTD debits), and
last 30 credit transactions; no dollar amounts or token counts exposed
- MyUsage page redesigned: balance + MTD spent cards, transaction history table,
Buy Credits button
- Admin: POST /api/billing/test-checkout — $1 Stripe Checkout for any credit
amount to validate the payment flow end-to-end
- stripe_service: cast unit_amount to int (Stripe rejects float)
- i18n updated in EN/RU/UK
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- _get_runtime_config(): reads active provider endpoint, api_key, main/mini
model from app_settings (60s cache), falls back to env vars
- get_azure_client() now async, accepts cfg dict
- All generate_* methods call _get_runtime_config() per invocation so DB
changes take effect without restart
- app_settings: _seed_from_env() backfills empty endpoint/api_key from env
vars on first load so the admin UI shows current values immediately
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Batch-resolves user_id strings to emails after the aggregation via a
single find() on the users collection.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PDF via fpdf2 (DejaVu font from fonts-dejavu-core, Cyrillic support)
- Lang param (?lang=ru/en) passed from i18n.language in ThemesPanel
- Prompts updated: executive summary + new key-decisions prompt, both
write entirely in the requested language
- Analytics section: participation bar chart per participant
- Key themes with quotes rendered as side-bordered blocks
- Filename: ASCII slug for Content-Disposition + UTF-8 filename* fallback
- api.ts: handles PDF blob, decodes UTF-8 filename*, 120s timeout
- AnalyticsPanel, ThemeHighlighter: light-mode colors → dark tokens
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FocusGroupSession.tsx: replace bg-slate-50 with bg-background so the page
matches the rest of the dark UI; swap remaining text-slate-* for muted tokens.
focus_group_ai.py: before deducting 40 cr for an AI mode run, check if this
focus group already has a charge within the last 4 hours. Skip deduction if
found — prevents double-billing when the server restarts mid-session.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GET /api/focus-groups/{id}/report/download generates a markdown report
with AI-written executive summary, key themes with quotes, and full
transcript. Frontend adds "Export Full Report" button to ThemesPanel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Epic 1 — OG-image & SEO base:
- Replace wrong og-image.png with branded 1200×630 Cohorta design
- index.html: full title, og:type/url/image dimensions, twitter:card, canonical
Epic 2 — Pricing from admin panel:
- Pricing.tsx: remove hardcoded DEFAULT_PACKS; add loading skeleton and error+retry state
- Features list and personas/sessions counts computed from API credits/costs
- billing.py /packs: also returns persona_cost and run_cost for frontend math
- app_settings.py: add popular:True to pro pack default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Header: logo h-48px mobile / h-168px desktop, md:self-start overflow
- PublicLayout: main pt-80px to match new header height
- Hero: -mt-80px cancels PublicLayout pt, responsive inner pt
- AppLayout: replace PNG logo (black box) with SVG mark + text
- billing.py: add public GET /billing/packs endpoint
- api.ts + Pricing.tsx: fetch packs from API, fallback to defaults
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Register form: two required checkboxes (Terms of Service / Privacy Policy + UK GDPR data processing)
- Zod schema uses z.literal(true) — form won't submit until both are checked
- Backend: validates accept_terms + accept_data_processing flags (400 if missing)
- User.save() writes created_at, consent_terms_at, consent_data_processing_at to MongoDB
- Admin UsersTab: Registered column, email verified badge, consent timestamps in edit dialog
- Fix: EU-hosted → UK hosted badge in register form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs caused filters to show 0 and period selector to have no effect:
1. Python < 3.11 can't parse JS toISOString() Z suffix — every request with a
period filter threw ValueError → 500 → frontend received no data. Fixed with
_parse_iso() helper that replaces Z with +00:00 before fromisoformat().
2. 'All time' sends no from/to params, but backend defaulted to _month_start()
instead of omitting the ts filter. Fixed with _period_match() helper that
returns {} (no filter) when both from and to are absent.
Also: stale _user_mtd_cost reference in get_user route replaced with
_user_period_cost(user_id, None, None); adminApi types updated with from/to.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add WARNING log when usage_metadata/usage is None so zero-cost events
are visible in logs instead of silently disappearing
- Capture thoughts_token_count from Gemini thinking models into reasoning field
(already included in candidates_token_count for billing, now also tracked separately)
- Add same warning for OpenAI missing usage object
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Old logic used output text length as a proxy for prompt tokens — completely
wrong. Real Gemini calls send the full conversation history as context, so
prompt grows with every turn.
New logic:
- completion_tokens = len(response_text) / 3.8 (what was generated)
- prompt_tokens = base_template + sum(all_prior_messages_in_fg) / 3.8
- persona_response base: 1500 tok (template + persona details + topic)
- moderator base: 1200 tok (moderator template + fg context)
- persona_generate base: 2500 tok (persona-detailed-generation.md template)
Also:
- Sorts messages chronologically per focus group before processing
- Accumulates context correctly so turn N includes turns 0..N-1 as context
- Idempotency via pre-fetched set instead of per-doc find_one queries
- cost_usd breakdown now has correct input/output split (not 40/60 guess)
- Dry-run prints per-focus-group cost estimates for sanity checking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New usePeriod hook (day/week/month/all/custom presets) with from/to ISO string outputs
- New PeriodSelector component (button group + custom date inputs)
- UsersTab, UsageTab, FocusGroupsTab all wired up with period state
- Backend /admin/users and /admin/focus-groups now accept from/to query params
- MTD Cost column header now reflects selected period label (e.g. "Cost (MTD)")
- Logout clears local state only (no account sign-out)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- @active_required + @with_user_context applied to all LLM-invoking routes
in personas.py, focus_group_ai.py, ai_personas.py
- backend/app/routes/usage.py: GET /api/usage/me (MTD summary by feature),
GET /api/usage/focus-groups/<id> (owner or admin)
- Registered usage_bp in app/__init__.py
- llm_service._record_usage now emits usage_update WS event to focus group room
Frontend:
- useMyUsage + useFocusGroupUsage hooks
- MyUsage.tsx: personal billing dashboard (cost cards + per-feature table)
- /billing route (ProtectedRoute) + Billing nav link
- FocusGroupSession: quota_warning amber banner with Progress bar,
quota_exceeded + quota_warning WS events wired via websocketServiceNew
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- token_version in JWT (bump_token_version, get_token_version on User model);
jwt_required checks tv claim → 401 on mismatch; login routes embed version
- Quota pre-flight in all 3 LLM public methods (QuotaExceededError bubbles up)
- AI runner catches QuotaExceededError → sets status paused_quota + emits WS event
- Admin routes: POST /users (create), POST /users/<id>/reset-password,
POST /pricing, GET /focus-groups with aggregated cost; PUT /users/<id>
now bumps token_version on disable or role change
- backfill_usage.py: idempotent estimated-event generator for historical data,
tiktoken for GPT models, char/3.8 for Gemini, --dry-run flag
Frontend:
- 402 interceptor dispatches quota_exceeded CustomEvent
- adminApi: createUser, resetPassword, createPricing, listFocusGroups
- UsersTab: New User dialog + Reset Password in edit dialog
- PricingTab: New Price dialog (model, provider, input/output/cached prices)
- FocusGroupsTab: focus groups table sorted by total cost
- Admin.tsx: 4th tab (Focus Groups)
- FocusGroupSession: admin-only cost badge + dismissable quota exceeded banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend: /api/admin/* blueprint with user CRUD (list, get, update,
disable/enable), usage summary aggregation (group by user/model/feature/
day/focus_group), usage event drill-down, and pricing list. Fixed
admin_required decorator (async-safe). Added find_all/count/update
helpers to User model.
Frontend: /admin page (AdminRoute guard, 3 tabs) — Users table with
search/filter/edit dialog, Usage tab with KPI cards + bar chart +
events table, Pricing tab showing active model rows with tier details.
Admin nav link visible only to admin role.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The autonomous loop was crashing on every decision with:
TypeError: can't subtract offset-naive and offset-aware datetimes
because MongoDB stores created_at without timezone info but the code
compared it against datetime.now(timezone.utc).
- conversation_context_service: make created_at timezone-aware before
subtraction (replace tzinfo=utc when naive)
- DiscussionPanel: fix sync effect — when server reports AI mode is
inactive, always clear localAiModeActive regardless of its value,
so the "AI is generating..." spinner doesn't get stuck when the
backend fails/stops before the frontend has confirmed AI mode started
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The autonomous conversation loop could hang indefinitely because
self.response_timeout=30 was defined but never used in wait_for().
- autonomous_conversation_controller: wrap generate_persona_response()
with asyncio.wait_for(timeout=120s); 30s was too short for production
LLMs, raised to 120s; TimeoutError returns an error dict so the loop
can continue or count toward consecutive_silence limit
- conversation_decision_service: add asyncio.wait_for(timeout=60s)
around LLMService.generate_content() for the decision call; add
asyncio import and explicit TimeoutError handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Backend: set status to ai_mode in the route handler before submitting
to AI runner, eliminating the race condition where frontend's immediate
status poll read the old status
- Frontend: replace all raw isAiModeActive prop usages with
effectiveAiModeActive in DiscussionPanel (13 locations) so ReasoningPanel,
status text, loading indicator, and manual/AI controls all reflect the
correct state instantly on Start AI Mode click
- Frontend: add useEffect to sync localAiModeActive back to null once
the parent prop catches up, preventing permanent override after natural
session end
- These fixes also unblock the 3-second AI message polling which was
never activating due to isAiModeActive staying false
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The AI Runner runs on a dedicated background thread with its own asyncio
event loop. When it emitted WebSocket events via sio.emit(), the call
happened on the wrong loop (AI Runner's vs ASGI/Quart's), causing silent
failures — messages were saved to MongoDB but never reached the frontend.
Additionally, the frontend HTTP polling fallback was never enabled when
WebSocket appeared connected, leaving no way to discover missed messages.
- websocket_manager_async.py: store ASGI main loop reference; detect
cross-loop calls in emit_to_focus_group and use run_coroutine_threadsafe
to schedule emits on the correct loop
- __init__.py: register the ASGI event loop with the WebSocket manager
in before_serving hook
- FocusGroupSession.tsx: always poll fetchMessages every 3s during AI mode
as a reliability fallback regardless of WebSocket status
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Data isolation:
- GET /tasks/<id>: verify requesting user owns the task (403 if not)
- DELETE /tasks/<id>: same ownership check
- GET /tasks/status: add @jwt_required()
- GET /personas/<id>: add ownership check (403 if created_by != user)
- GET /focus-groups/<id>: add ownership check
- GET /focus-groups/<id>/messages: add ownership check
- POST/DELETE /focus-groups/<id>/participants: add ownership check
Fix conversation/decision 500:
- Convert POST /conversation/decision to async 202+background (was synchronous LLM → timed out / LLM errors → 500)
- Frontend polls waitForTaskResult for decision result before calling generateResponseAsync
- GET /conversation/insights: return empty insights (200) on LLM error instead of 500
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Backend: /generate-discussion-guide now returns task_id immediately (202)
and runs generation as a background asyncio task, delivering the guide
via WebSocket task_completed event (bypasses GCP LB 30s timeout)
- Frontend: useDiscussionGuideGeneration awaits ws:task_completed event
to resolve the guide Promise instead of waiting on the HTTP response
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>