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>
Without this step the model_pricing collection is empty and all LLM
costs are recorded as $0. Seed is idempotent — safe on every deploy.
Also wait for backend container readiness before running migrations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a task completed, the result payload (personas_created, errors_count, etc.)
was discarded instead of being saved to state. AIRecruiter always read
generationState.result as undefined → count = 0 → showed error even when
the backend had successfully created all personas.
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>
- favicon.svg: indigo/purple gradient rounded square with 3 persona silhouettes (focus group concept)
- index.html: link SVG favicon (with ICO fallback), set title to Semblance
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>
_fg_logger was used but never defined, causing a NameError on every
PUT /focus-groups/:id request that included llm_model (i.e. all autosave
and handleSubmit updates) — resulting in a 500 Internal Server Error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- FocusGroup.update: use matched_count > 0 instead of modified_count > 0
so updates succeed even when data is unchanged (was returning 500)
- useFocusGroupAutoSave: skip save if name is empty (not all-fields-empty)
preventing 400 Bad Request when autosave fires before name is filled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes all debug/verbose console.log calls across frontend to prevent
sensitive data exposure (session IDs, tokens) and reduce console noise.
Keeps only console.error and console.warn for genuine errors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Don't send full persona objects in WS event — only send counts.
Frontend navigates to list page where personas load from API.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Backend: /generate-personas-full now returns task_id immediately (202)
and runs generation as a background asyncio task, delivering results
via WebSocket task_completed event (bypasses GCP LB 30s timeout)
- Frontend: AIRecruiter listens for ws:task_completed to process personas
instead of awaiting the long HTTP response
- Remove 53 debug console.log calls from websocketServiceNew.ts including
session_id exposure and a self-test emit that was firing fake events
- Remove debug logs from WebSocketContextNew, AIRecruiter, personaGenerator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
google-genai SDK uses aiohttp when it's available in the environment
(installed via llama-index-core), causing AssertionError (connector is None)
on async requests. Pass httpx_async_client in HttpOptions to bypass aiohttp.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Server-level COOP prevents parent from monitoring popup.location,
so loginPopup never resolves in parent. Redirect flow is the correct
solution: page navigates to Microsoft, returns with #code, and
handleRedirectPromise exchanges the idToken with backend.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MSAL parent polls popup URL for auth code (same-origin), so the popup
doesn't need to render anything. Prevents app from opening inside popup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
same-origin-allow-popups allows the Microsoft auth popup to communicate
back to the parent window, which is required for loginPopup to work.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The real issue was the wrong domain (oliver.solution vs oliver.solutions).
Now that Azure has the correct redirect URI configured, popup flow works.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Popup-based auth fails in production due to Cross-Origin-Opener-Policy
headers blocking window.closed calls. Redirect flow avoids this entirely.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- deploy.sh runs entirely on server, no local Mac needed
- docker-compose: add 'frontend' service (node:20-alpine, profile=build)
builds frontend and copies dist/ to /var/www/html/semblance
- Remove server-deploy.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Build frontend locally (Node not installed on server)
- rsync dist/ to server instead of building remotely
- Change PYTHON_CMD to python3 (server has 3.12, not 3.13)
- Add step to start MongoDB in Docker (mongo:7, not installed natively)
- Add step to inject Apache proxy config for /semblance_back/ and /semblance/
- SSH_HOST=optical-dev (uses ~/.ssh/config alias)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace ai-sandbox.oliver.solutions with optical-dev.oliver.solution
across all config, env, docs, and source files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix missing await on FocusGroup.get_messages() (N-L1)
- Replace time.sleep with asyncio.sleep in key_theme_service and focus_group_service (N-P10)
- Replace flask import with quart in focus_groups.py (N-S3)
- Add logger.error before all 500 returns in focus_groups.py (N-P6)
- Add logging to silent except blocks across routes (N-M10, N-M11)
- Add @rate_limit to 6 remaining AI endpoints (N-H4)
- Add --confirm flag to populate scripts before delete_many (S-H2)
- Remove hardcoded Azure ID fallbacks from msal_service.py and msalConfig.ts (A-M2, F-H4)
- Centralize make_serializable() in utils.py, remove duplicates from 3 route files (N-P7)
- Replace all datetime.utcnow() with datetime.now(timezone.utc) across entire backend (M-L2)
- AuthContext.tsx: only mark token validated on 200 success, not on non-401 errors (F-H2)
- Rename authType → auth_type in auth.py (N-S4)
- Add security_report.md and security_report.pdf with full 92-finding status
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>