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>
251 lines
7.7 KiB
TypeScript
Executable file
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.";
|
|
}
|
|
};
|