diff --git a/services/apps/projects_evaluation_worker/src/activities/activities.ts b/services/apps/projects_evaluation_worker/src/activities/activities.ts index 34b1b81301..1c1107cc7c 100644 --- a/services/apps/projects_evaluation_worker/src/activities/activities.ts +++ b/services/apps/projects_evaluation_worker/src/activities/activities.ts @@ -1,4 +1,5 @@ import { + findProjectCatalogById, findProjectCatalogPendingEvaluation, promoteProjectsToEvaluate, updateProjectCatalog, @@ -43,6 +44,17 @@ export async function evaluateAndUpdateProject(project: IDbProjectCatalog): Prom const qx = pgpQx(svc.postgres.writer.connection()) const startTime = Date.now() + // Guard: fetch fresh state to ensure the API is called at most once per project. + // Uses the writer connection to avoid replica lag missing a just-written evaluatedAt. + const fresh = await findProjectCatalogById(qx, project.id) + if (fresh?.evaluatedAt) { + log.info( + { id: project.id, repoUrl: project.repoUrl, evaluatedAt: fresh.evaluatedAt }, + 'Project already evaluated, skipping API call.', + ) + return + } + log.info({ id: project.id, repoUrl: project.repoUrl }, 'Starting evaluation.') const result = await evaluateProject({ @@ -56,6 +68,8 @@ export async function evaluateAndUpdateProject(project: IDbProjectCatalog): Prom await updateProjectCatalog(qx, project.id, { action: result.outcome, + evaluationResult: result.evaluationResult, + evaluationReason: result.evaluationReason, evaluatedAt: new Date().toISOString(), }) @@ -66,7 +80,8 @@ export async function evaluateAndUpdateProject(project: IDbProjectCatalog): Prom id: project.id, repoUrl: project.repoUrl, outcome: result.outcome, - reason: result.reason, + evaluationResult: result.evaluationResult, + evaluationReason: result.evaluationReason, elapsedSeconds, }, 'Evaluation complete.', diff --git a/services/apps/projects_evaluation_worker/src/evaluator/evaluator.ts b/services/apps/projects_evaluation_worker/src/evaluator/evaluator.ts index d865a4be1f..30fd627694 100644 --- a/services/apps/projects_evaluation_worker/src/evaluator/evaluator.ts +++ b/services/apps/projects_evaluation_worker/src/evaluator/evaluator.ts @@ -1,9 +1,86 @@ import { IEvaluationInput, IEvaluationResult } from './types' -// TODO: Replace with the actual AI evaluation algorithm once the external repo is integrated. -// The algorithm is described in the technical spec and currently takes ~30-40s per project -// at ~$0.15/project. Reference: https://github.com/... (link TBD). +interface IApiResponseContent { + onboard: boolean + non_onboard_reason?: string +} + export async function evaluateProject(input: IEvaluationInput): Promise { - console.error(`evaluateProject is not implemented yet for repo: ${input.repoUrl}`) - return { outcome: 'unsure', reason: 'evaluator not implemented' } + const endpoint = process.env.CROWD_PROJECT_EVALUATION_API_ENDPOINT + const userId = process.env.CROWD_PROJECT_EVALUATION_API_USER_ID + const secret = process.env.CROWD_PROJECT_EVALUATION_API_SECRET + + if (!endpoint || !userId || !secret) { + return { + outcome: 'unsure', + evaluationResult: 'error', + evaluationReason: + 'Missing API configuration: CROWD_PROJECT_EVALUATION_API_ENDPOINT, CROWD_PROJECT_EVALUATION_API_USER_ID, or CROWD_PROJECT_EVALUATION_API_SECRET', + } + } + + const body = new URLSearchParams() + body.append('message', JSON.stringify({ repo_url: input.repoUrl })) + body.append('stream', 'false') + body.append('user_id', userId) + + let response: Response + try { + response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${secret}`, + }, + body, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + outcome: 'unsure', + evaluationResult: 'error', + evaluationReason: `API request failed: ${message}`, + } + } + + if (!response.ok) { + return { + outcome: 'unsure', + evaluationResult: 'error', + evaluationReason: `API returned HTTP ${response.status}: ${response.statusText}`, + } + } + + let responseBody: unknown + try { + responseBody = await response.json() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + outcome: 'unsure', + evaluationResult: 'error', + evaluationReason: `Failed to parse API response: ${message}`, + } + } + + const content = (responseBody as { content?: unknown } | null)?.content + if ( + !content || + typeof content !== 'object' || + typeof (content as IApiResponseContent).onboard !== 'boolean' + ) { + return { + outcome: 'unsure', + evaluationResult: 'error', + evaluationReason: `Unexpected API response shape: ${JSON.stringify(responseBody)}`, + } + } + + const { onboard, non_onboard_reason } = content as IApiResponseContent + + return { + outcome: onboard ? 'onboard' : 'skip', + evaluationResult: String(onboard), + evaluationReason: non_onboard_reason ?? null, + } } diff --git a/services/apps/projects_evaluation_worker/src/evaluator/types.ts b/services/apps/projects_evaluation_worker/src/evaluator/types.ts index b74ff2c516..03851f2d65 100644 --- a/services/apps/projects_evaluation_worker/src/evaluator/types.ts +++ b/services/apps/projects_evaluation_worker/src/evaluator/types.ts @@ -9,10 +9,11 @@ export interface IEvaluationInput { source: string | null } -// Evaluation can only resolve to 'onboard' or 'unsure' — never back to 'evaluate' or 'auto'. -export type EvaluationOutcome = Extract +// Evaluation can only resolve to 'onboard', 'skip', or 'unsure' — never back to 'evaluate' or 'auto'. +export type EvaluationOutcome = Extract export interface IEvaluationResult { outcome: EvaluationOutcome - reason: string + evaluationResult: string + evaluationReason: string | null } diff --git a/services/apps/projects_evaluation_worker/src/main.ts b/services/apps/projects_evaluation_worker/src/main.ts index e76c155fab..11ae5e6f13 100644 --- a/services/apps/projects_evaluation_worker/src/main.ts +++ b/services/apps/projects_evaluation_worker/src/main.ts @@ -4,7 +4,11 @@ import { Options, ServiceWorker } from '@crowd/archetype-worker' import { scheduleProjectsEvaluation } from './schedules/scheduleProjectsEvaluation' const config: Config = { - envvars: [], + envvars: [ + 'CROWD_PROJECT_EVALUATION_API_ENDPOINT', + 'CROWD_PROJECT_EVALUATION_API_USER_ID', + 'CROWD_PROJECT_EVALUATION_API_SECRET', + ], producer: { enabled: false, }, diff --git a/services/libs/data-access-layer/src/project-catalog/projectCatalog.ts b/services/libs/data-access-layer/src/project-catalog/projectCatalog.ts index 1643c3b352..08bed850e6 100644 --- a/services/libs/data-access-layer/src/project-catalog/projectCatalog.ts +++ b/services/libs/data-access-layer/src/project-catalog/projectCatalog.ts @@ -316,7 +316,7 @@ export async function upsertProjectCatalog( "repoName" = EXCLUDED."repoName", "source" = COALESCE(EXCLUDED."source", "projectCatalog"."source"), "action" = CASE - WHEN "projectCatalog"."action" IN ('onboard', 'unsure') THEN "projectCatalog"."action" + WHEN "projectCatalog"."action" IN ('onboard', 'skip', 'unsure') THEN "projectCatalog"."action" WHEN EXCLUDED.action = 'evaluate' THEN 'evaluate' ELSE "projectCatalog"."action" END, @@ -389,7 +389,7 @@ export async function bulkUpsertProjectCatalog( "repoName" = EXCLUDED."repoName", "source" = COALESCE(EXCLUDED."source", "projectCatalog"."source"), "action" = CASE - WHEN "projectCatalog"."action" IN ('onboard', 'unsure') THEN "projectCatalog"."action" + WHEN "projectCatalog"."action" IN ('onboard', 'skip', 'unsure') THEN "projectCatalog"."action" WHEN EXCLUDED.action = 'evaluate' THEN 'evaluate' ELSE "projectCatalog"."action" END, diff --git a/services/libs/data-access-layer/src/project-catalog/types.ts b/services/libs/data-access-layer/src/project-catalog/types.ts index e54da5c1b3..69d93254be 100644 --- a/services/libs/data-access-layer/src/project-catalog/types.ts +++ b/services/libs/data-access-layer/src/project-catalog/types.ts @@ -1,4 +1,4 @@ -export type ProjectCatalogAction = 'auto' | 'evaluate' | 'onboard' | 'unsure' +export type ProjectCatalogAction = 'auto' | 'evaluate' | 'onboard' | 'skip' | 'unsure' export interface IDbProjectCatalog { id: string