From abee5764893d98de3a367e41698329c9f480e23c Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Thu, 21 May 2026 10:34:31 +0200 Subject: [PATCH 1/2] feat: add evaluation api call Signed-off-by: Umberto Sgueglia --- .../src/activities/activities.ts | 17 +++- .../src/evaluator/evaluator.ts | 77 +++++++++++++++++-- .../src/evaluator/types.ts | 7 +- .../projects_evaluation_worker/src/main.ts | 6 +- .../src/project-catalog/projectCatalog.ts | 4 +- .../src/project-catalog/types.ts | 2 +- 6 files changed, 100 insertions(+), 13 deletions(-) diff --git a/services/apps/projects_evaluation_worker/src/activities/activities.ts b/services/apps/projects_evaluation_worker/src/activities/activities.ts index 34b1b81301..c8a0aa88b8 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. + // If evaluatedAt is already set, a previous run completed this project — skip. + const fresh = await findProjectCatalogById(pgpQx(svc.postgres.reader.connection()), 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..9a441d3004 100644 --- a/services/apps/projects_evaluation_worker/src/evaluator/evaluator.ts +++ b/services/apps/projects_evaluation_worker/src/evaluator/evaluator.ts @@ -1,9 +1,76 @@ 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 +} + +interface IApiResponse { + content: IApiResponseContent +} + 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: IApiResponse + try { + responseBody = (await response.json()) as IApiResponse + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + outcome: 'unsure', + evaluationResult: 'error', + evaluationReason: `Failed to parse API response: ${message}`, + } + } + + const { onboard, non_onboard_reason } = responseBody.content + + return { + outcome: onboard ? 'onboard' : 'skip', + evaluationResult: String(onboard), + evaluationReason: non_onboard_reason ?? '', + } } diff --git a/services/apps/projects_evaluation_worker/src/evaluator/types.ts b/services/apps/projects_evaluation_worker/src/evaluator/types.ts index b74ff2c516..e3f790042b 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 } 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 From 62906e649239450615101e501b3036ed5a291914 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Thu, 21 May 2026 11:57:19 +0200 Subject: [PATCH 2/2] fix: comment and lints Signed-off-by: Umberto Sgueglia --- .../src/activities/activities.ts | 4 +-- .../src/evaluator/evaluator.ts | 28 +++++++++++++------ .../src/evaluator/types.ts | 2 +- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/services/apps/projects_evaluation_worker/src/activities/activities.ts b/services/apps/projects_evaluation_worker/src/activities/activities.ts index c8a0aa88b8..1c1107cc7c 100644 --- a/services/apps/projects_evaluation_worker/src/activities/activities.ts +++ b/services/apps/projects_evaluation_worker/src/activities/activities.ts @@ -45,8 +45,8 @@ export async function evaluateAndUpdateProject(project: IDbProjectCatalog): Prom const startTime = Date.now() // Guard: fetch fresh state to ensure the API is called at most once per project. - // If evaluatedAt is already set, a previous run completed this project — skip. - const fresh = await findProjectCatalogById(pgpQx(svc.postgres.reader.connection()), project.id) + // 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 }, diff --git a/services/apps/projects_evaluation_worker/src/evaluator/evaluator.ts b/services/apps/projects_evaluation_worker/src/evaluator/evaluator.ts index 9a441d3004..30fd627694 100644 --- a/services/apps/projects_evaluation_worker/src/evaluator/evaluator.ts +++ b/services/apps/projects_evaluation_worker/src/evaluator/evaluator.ts @@ -5,10 +5,6 @@ interface IApiResponseContent { non_onboard_reason?: string } -interface IApiResponse { - content: IApiResponseContent -} - export async function evaluateProject(input: IEvaluationInput): Promise { const endpoint = process.env.CROWD_PROJECT_EVALUATION_API_ENDPOINT const userId = process.env.CROWD_PROJECT_EVALUATION_API_USER_ID @@ -18,7 +14,8 @@ export async function evaluateProject(input: IEvaluationInput): Promise