modcomms/frontend/services/geminiService.ts
Vadym Samoilenko a6fc149788 Replace WebSocket with REST polling to fix GCP LB 30s timeout
POST /api/analyze submits an analysis job and returns job_id instantly.
GET /api/analyze/{job_id} returns progress + result; frontend polls every 2s.

Analysis runs as asyncio.create_task in the background — each HTTP request
completes in milliseconds, well within the 30s GCP Load Balancer limit.

- Add backend/app/services/job_store.py: in-memory AnalysisJob store with
  30-min TTL cleanup
- Add backend/app/api/analysis_routes.py: POST + GET /api/analyze endpoints
  with full analysis pipeline (hash check, DB persistence, PDF pages, etc.)
- Remove backend/app/websocket/: handlers.py, manager.py, __init__.py
- Update backend/app/main.py: wire analysis_router, store analysis_service
  in app.state, drop all WebSocket imports and endpoint
- Update frontend/services/geminiService.ts: replace WS with fetch+poll;
  function signatures unchanged so App.tsx / WIPReviewer.tsx need no edits
- Remove VITE_BACKEND_WS_URL from vite.config.ts, deploy.sh, .env.deploy.example
- Update cloudrun.yaml: remove WebSocket-specific session affinity annotation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:26:01 +00:00

251 lines
7.7 KiB
TypeScript
Executable file

import type { AgentReview, SubReview, AgentName, PDFPage } from '../types';
import { IPublicClientApplication } from '@azure/msal-browser';
import { getAccessToken } from './authService';
const HTTP_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000';
const POLL_INTERVAL_MS = 2000;
/**
* Options for proof analysis with optional database persistence.
*/
export interface AnalyzeProofOptions {
campaignId?: string;
proofName?: string;
channel?: string;
subChannel?: string;
proofType?: string;
/** Brand to use for brand guidelines analysis: 'Barclays' or 'Barclaycard' */
brand?: string;
}
/**
* Result of proof analysis, including optional database IDs if persisted.
*/
export interface AnalyzeProofResult {
review: AgentReview;
proofId?: string;
versionId?: string;
pdfPages?: PDFPage[];
isIdenticalFile?: boolean;
}
/** Read a File as base64 string (without the data-url prefix). */
async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve((reader.result as string).split(',')[1]);
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
/** Sleep for `ms` milliseconds. */
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
/** Authenticated fetch helper — adds Bearer token. */
async function authFetch(
url: string,
init: RequestInit,
accessToken: string,
): Promise<Response> {
return fetch(url, {
...init,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
...(init.headers ?? {}),
},
});
}
/**
* Analyze a proof using the backend REST API.
* Provides real-time updates as each agent completes via polling.
*/
export const analyzeProof = async (
file: File,
onAgentUpdate: (name: AgentName | 'Summary', review?: SubReview) => void,
msalInstance: IPublicClientApplication,
options?: AnalyzeProofOptions,
onNotification?: (message: string) => void,
): Promise<AnalyzeProofResult> => {
const accessToken = await getAccessToken(msalInstance);
if (!accessToken) {
throw new Error('Failed to acquire access token. Please sign in again.');
}
const fileData = await fileToBase64(file);
// Submit the analysis job
const submitRes = await authFetch(
`${HTTP_URL}/api/analyze`,
{
method: 'POST',
body: JSON.stringify({
file_data: fileData,
file_type: file.type,
is_wip: false,
campaign_id: options?.campaignId,
proof_name: options?.proofName,
channel: options?.channel,
sub_channel: options?.subChannel,
proof_type: options?.proofType,
brand: options?.brand ?? 'Barclaycard',
}),
},
accessToken,
);
if (!submitRes.ok) {
const err = await submitRes.text();
throw new Error(`Failed to submit analysis: ${submitRes.status} ${err}`);
}
const { job_id } = await submitRes.json();
// Poll until complete
const seenAgents = new Set<string>();
let notifiedFallback = false;
while (true) {
await sleep(POLL_INTERVAL_MS);
const pollRes = await authFetch(
`${HTTP_URL}/api/analyze/${job_id}`,
{ method: 'GET' },
accessToken,
);
if (!pollRes.ok) {
throw new Error(`Polling failed: ${pollRes.status}`);
}
const job = await pollRes.json();
// Notify about model fallback once
if (job.model_fallback && !notifiedFallback) {
notifiedFallback = true;
onNotification?.('The primary AI model is currently unavailable. Analysis is continuing with the backup model and may take longer than usual.');
}
// Fire callbacks for newly completed agents
for (const agentName of Object.keys(job.agents_completed ?? {})) {
if (!seenAgents.has(agentName)) {
seenAgents.add(agentName);
onAgentUpdate(agentName as AgentName, job.agents_completed[agentName] as SubReview);
}
}
if (job.status === 'complete') {
return {
review: job.result as AgentReview,
proofId: job.proof_id ?? undefined,
versionId: job.version_id ?? undefined,
pdfPages: job.pdf_pages as PDFPage[] | undefined,
isIdenticalFile: job.is_identical_file ?? undefined,
};
}
if (job.status === 'error') {
throw new Error(job.error_message || 'Analysis failed');
}
}
};
/**
* Analyze a work-in-progress proof.
* Returns a conversational summary for the creative team.
*/
export const analyzeWIPProof = async (
file: File,
onAgentUpdate: (name: AgentName | 'Summary', review?: SubReview) => void,
msalInstance: IPublicClientApplication
): Promise<string> => {
const accessToken = await getAccessToken(msalInstance);
if (!accessToken) {
throw new Error('Failed to acquire access token. Please sign in again.');
}
const fileData = await fileToBase64(file);
// Submit the analysis job
const submitRes = await authFetch(
`${HTTP_URL}/api/analyze`,
{
method: 'POST',
body: JSON.stringify({
file_data: fileData,
file_type: file.type,
is_wip: true,
}),
},
accessToken,
);
if (!submitRes.ok) {
const err = await submitRes.text();
throw new Error(`Failed to submit WIP analysis: ${submitRes.status} ${err}`);
}
const { job_id } = await submitRes.json();
// Poll until complete
const seenAgents = new Set<string>();
while (true) {
await sleep(POLL_INTERVAL_MS);
const pollRes = await authFetch(
`${HTTP_URL}/api/analyze/${job_id}`,
{ method: 'GET' },
accessToken,
);
if (!pollRes.ok) {
throw new Error(`Polling failed: ${pollRes.status}`);
}
const job = await pollRes.json();
// Fire callbacks for newly completed agents
for (const agentName of Object.keys(job.agents_completed ?? {})) {
if (!seenAgents.has(agentName)) {
seenAgents.add(agentName);
onAgentUpdate(agentName as AgentName, job.agents_completed[agentName] as SubReview);
}
}
if (job.status === 'complete') {
return job.result?.leadAgentSummary || 'Analysis complete.';
}
if (job.status === 'error') {
throw new Error(job.error_message || 'Analysis failed');
}
}
};
/**
* Get a chat response from the WIP Lead Agent.
* Uses HTTP REST endpoint.
*/
export const getWIPChatResponse = async (prompt: string): Promise<string> => {
try {
const response = await fetch(`${HTTP_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data.response || data.message || 'No response from Lead Agent.';
} catch (error) {
console.error('Error getting WIP chat response:', error);
return "I'm sorry, the chat feature requires the backend chat endpoint to be implemented. For now, please use the proof analysis feature.";
}
};