diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2828b98944..74143e26dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,9 +692,6 @@ importers: '@crowd/common': specifier: workspace:* version: link:../../libs/common - '@crowd/common_services': - specifier: workspace:* - version: link:../../libs/common_services '@crowd/data-access-layer': specifier: workspace:* version: link:../../libs/data-access-layer @@ -710,15 +707,9 @@ importers: '@crowd/slack': specifier: workspace:* version: link:../../libs/slack - '@crowd/snowflake': - specifier: workspace:* - version: link:../../libs/snowflake '@crowd/temporal': specifier: workspace:* version: link:../../libs/temporal - '@crowd/types': - specifier: workspace:* - version: link:../../libs/types '@temporalio/activity': specifier: 'catalog:' version: 1.17.2 @@ -2804,6 +2795,21 @@ importers: '@crowd/common': specifier: workspace:* version: link:../common + '@crowd/common_services': + specifier: workspace:* + version: link:../common_services + '@crowd/data-access-layer': + specifier: workspace:* + version: link:../data-access-layer + '@crowd/logging': + specifier: workspace:* + version: link:../logging + '@crowd/snowflake': + specifier: workspace:* + version: link:../snowflake + '@crowd/types': + specifier: workspace:* + version: link:../types devDependencies: '@types/node': specifier: 'catalog:' diff --git a/services/apps/automatic_projects_discovery_worker/package.json b/services/apps/automatic_projects_discovery_worker/package.json index b6ebc34202..1a301665be 100644 --- a/services/apps/automatic_projects_discovery_worker/package.json +++ b/services/apps/automatic_projects_discovery_worker/package.json @@ -13,15 +13,12 @@ "@crowd/archetype-standard": "workspace:*", "@crowd/archetype-worker": "workspace:*", "@crowd/common": "workspace:*", - "@crowd/common_services": "workspace:*", "@crowd/data-access-layer": "workspace:*", "@crowd/logging": "workspace:*", "@crowd/project-onboarding": "workspace:*", "@crowd/redis": "workspace:*", "@crowd/slack": "workspace:*", - "@crowd/snowflake": "workspace:*", "@crowd/temporal": "workspace:*", - "@crowd/types": "workspace:*", "@temporalio/activity": "catalog:", "@temporalio/client": "catalog:", "@temporalio/workflow": "catalog:", diff --git a/services/apps/automatic_projects_discovery_worker/src/activities/activities.ts b/services/apps/automatic_projects_discovery_worker/src/activities/activities.ts index e83ae5ebdc..e3288d3ae8 100644 --- a/services/apps/automatic_projects_discovery_worker/src/activities/activities.ts +++ b/services/apps/automatic_projects_discovery_worker/src/activities/activities.ts @@ -23,6 +23,8 @@ import { } from '@crowd/data-access-layer/src/project-catalog/types' import { pgpQx } from '@crowd/data-access-layer/src/queryExecutor' import { getServiceLogger } from '@crowd/logging' +import { countNodes } from '@crowd/project-onboarding' +import { withRequestClassifierDeps } from '@crowd/project-onboarding/src/requestClassifierDeps' import { SlackChannel, SlackPersona, sendSlackNotificationAsync } from '@crowd/slack' import { svc } from '../main' @@ -37,8 +39,6 @@ import { buildRequestClassificationAlert, buildRequestClassificationAlertTitle, } from './requestClassificationAlert' -import { withRequestClassificationDeps } from './requestClassificationDeps' -import { countNodes } from './requestClassificationTrace' const log = getServiceLogger() @@ -146,7 +146,7 @@ async function classifyAcceptedRows( return { rows: accepted, alerts: [], nodes: [] } } - return withRequestClassificationDeps(qx, (deps) => + return withRequestClassifierDeps(qx, (deps) => classifyDiscussions(accepted, requestTextBySourceUrl, deps, heartbeatClassification, dryRun), ) } diff --git a/services/apps/automatic_projects_discovery_worker/src/activities/requestClassification.test.ts b/services/apps/automatic_projects_discovery_worker/src/activities/requestClassification.test.ts index 8c13ef4de1..61e5a4f1fb 100644 --- a/services/apps/automatic_projects_discovery_worker/src/activities/requestClassification.test.ts +++ b/services/apps/automatic_projects_discovery_worker/src/activities/requestClassification.test.ts @@ -1,11 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { IDbProjectCatalogCreate } from '@crowd/data-access-layer/src/project-catalog/types' -import { IPccCandidate } from '@crowd/project-onboarding' +import { IPccCandidate, IRequestClassificationDeps } from '@crowd/project-onboarding' import { AMBIGUOUS_SKIP_REASON, - IRequestClassificationDeps, LF_NOT_IN_CDP_SKIP_REASON, classifyDiscussionRows, classifyDiscussions, diff --git a/services/apps/automatic_projects_discovery_worker/src/activities/requestClassification.ts b/services/apps/automatic_projects_discovery_worker/src/activities/requestClassification.ts index c8f72e0c34..7f5522e4d4 100644 --- a/services/apps/automatic_projects_discovery_worker/src/activities/requestClassification.ts +++ b/services/apps/automatic_projects_discovery_worker/src/activities/requestClassification.ts @@ -1,31 +1,16 @@ -import { getErrorMessage } from '@crowd/common' import { IDbProjectCatalogCreate } from '@crowd/data-access-layer/src/project-catalog/types' import { getServiceLogger } from '@crowd/logging' import { CdpIntegrationAction, - IOnboardingRequestLookups, - OnboardingResolution, - OnboardingRequestLlm, - parseOnboardingRequest, - resolveOnboardingRequest, -} from '@crowd/project-onboarding' - -import { ClassificationNode, - IClassificationTrace, + IRequestClassificationDeps, + OnboardingResolution, buildClassificationLogEntry, - createClassificationTrace, - toClassificationNode, - traceLookups, -} from './requestClassificationTrace' + classifyOnboardingRequest, +} from '@crowd/project-onboarding' const log = getServiceLogger() -export interface IRequestClassificationDeps { - queryLlm: OnboardingRequestLlm - lookups: IOnboardingRequestLookups -} - export interface IRequestClassificationAlert { sourceUrl: string repoUrls: string[] @@ -65,40 +50,13 @@ function toSkipReason(resolution: Exclude { - try { - const parsed = await parseOnboardingRequest(requestText, deps.queryLlm) - if (parsed.ok === false) { - trace.failure = { stage: 'parse', reason: parsed.reason } - return unresolved(`Request could not be parsed: ${parsed.reason}`) - } - - trace.parsed = parsed.request - return await resolveOnboardingRequest(parsed.request, traceLookups(deps.lookups, trace)) - } catch (err) { - const reason = getErrorMessage(err) - trace.failure = { stage: 'resolve', reason } - return unresolved(`Classification failed: ${reason}`) - } -} - export async function classifyDiscussionRows( rows: IDbProjectCatalogCreate[], requestText: string, deps: IRequestClassificationDeps, dryRun = false, ): Promise { - const trace = createClassificationTrace() - const resolution = await resolveRequestText(requestText, deps, trace) - const node = toClassificationNode(resolution) + const { resolution, node, trace } = await classifyOnboardingRequest(requestText, deps) log.info( buildClassificationLogEntry(rows[0].sourceUrl ?? '', resolution, trace), diff --git a/services/apps/automatic_projects_discovery_worker/src/bin/classificationCases.ts b/services/apps/automatic_projects_discovery_worker/src/bin/classificationCases.ts index 93d888b0ad..2f104fa207 100644 --- a/services/apps/automatic_projects_discovery_worker/src/bin/classificationCases.ts +++ b/services/apps/automatic_projects_discovery_worker/src/bin/classificationCases.ts @@ -1,4 +1,4 @@ -import { ClassificationNode } from '../activities/requestClassificationTrace' +import { ClassificationNode } from '@crowd/project-onboarding' export interface IClassificationCase { id: string diff --git a/services/apps/automatic_projects_discovery_worker/src/bin/classify-onboarding-cases.ts b/services/apps/automatic_projects_discovery_worker/src/bin/classify-onboarding-cases.ts index 7b19259e66..dfde68ed40 100644 --- a/services/apps/automatic_projects_discovery_worker/src/bin/classify-onboarding-cases.ts +++ b/services/apps/automatic_projects_discovery_worker/src/bin/classify-onboarding-cases.ts @@ -3,9 +3,9 @@ import { WRITE_DB_CONFIG, getDbConnection } from '@crowd/data-access-layer/src/d import { IDbProjectCatalogCreate } from '@crowd/data-access-layer/src/project-catalog/types' import { pgpQx } from '@crowd/data-access-layer/src/queryExecutor' import { getServiceLogger } from '@crowd/logging' +import { withRequestClassifierDeps } from '@crowd/project-onboarding/src/requestClassifierDeps' import { classifyDiscussionRows } from '../activities/requestClassification' -import { withRequestClassificationDeps } from '../activities/requestClassificationDeps' import { CLASSIFICATION_CASES, IClassificationCase } from './classificationCases' const log = getServiceLogger() @@ -90,7 +90,7 @@ async function main(): Promise { const cases = selectCases(readCaseFilter(argv)) const qx = pgpQx(await getDbConnection(WRITE_DB_CONFIG())) - const outcomes = await withRequestClassificationDeps(qx, async (deps) => { + const outcomes = await withRequestClassifierDeps(qx, async (deps) => { const results: ICaseOutcome[] = [] for (const testCase of cases) { const { nodes } = await classifyDiscussionRows(toRow(testCase), testCase.text, deps, true) diff --git a/services/apps/automatic_projects_discovery_worker/tsconfig.json b/services/apps/automatic_projects_discovery_worker/tsconfig.json index d1a011159f..8d71af8edb 100644 --- a/services/apps/automatic_projects_discovery_worker/tsconfig.json +++ b/services/apps/automatic_projects_discovery_worker/tsconfig.json @@ -11,9 +11,6 @@ { "path": "../../libs/common" }, - { - "path": "../../libs/common_services" - }, { "path": "../../libs/data-access-layer" }, @@ -25,12 +22,6 @@ }, { "path": "../../libs/slack" - }, - { - "path": "../../libs/snowflake" - }, - { - "path": "../../libs/types" } ] } diff --git a/services/libs/project-onboarding/package.json b/services/libs/project-onboarding/package.json index 2191a5e167..8b1afb76c0 100644 --- a/services/libs/project-onboarding/package.json +++ b/services/libs/project-onboarding/package.json @@ -3,7 +3,12 @@ "private": true, "main": "src/index.ts", "dependencies": { - "@crowd/common": "workspace:*" + "@crowd/common": "workspace:*", + "@crowd/common_services": "workspace:*", + "@crowd/data-access-layer": "workspace:*", + "@crowd/logging": "workspace:*", + "@crowd/snowflake": "workspace:*", + "@crowd/types": "workspace:*" }, "devDependencies": { "@types/node": "catalog:" diff --git a/services/apps/automatic_projects_discovery_worker/src/activities/cdpSegmentLookup.ts b/services/libs/project-onboarding/src/cdpSegmentLookup.ts similarity index 90% rename from services/apps/automatic_projects_discovery_worker/src/activities/cdpSegmentLookup.ts rename to services/libs/project-onboarding/src/cdpSegmentLookup.ts index 3d5f1310b6..391383cff3 100644 --- a/services/apps/automatic_projects_discovery_worker/src/activities/cdpSegmentLookup.ts +++ b/services/libs/project-onboarding/src/cdpSegmentLookup.ts @@ -1,10 +1,7 @@ import { fetchIntegrationsForSegment, findSubprojectsBySourceId } from '@crowd/data-access-layer' import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' -import { - CdpSegmentLookupResult, - ICdpSegmentMatch, - toCdpIntegrationState, -} from '@crowd/project-onboarding' + +import { CdpSegmentLookupResult, ICdpSegmentMatch, toCdpIntegrationState } from './requestResolver' async function toSegmentMatch( qx: QueryExecutor, diff --git a/services/apps/automatic_projects_discovery_worker/src/activities/requestClassificationTrace.test.ts b/services/libs/project-onboarding/src/classificationTrace.test.ts similarity index 97% rename from services/apps/automatic_projects_discovery_worker/src/activities/requestClassificationTrace.test.ts rename to services/libs/project-onboarding/src/classificationTrace.test.ts index 523a588dbc..f85687f03f 100644 --- a/services/apps/automatic_projects_discovery_worker/src/activities/requestClassificationTrace.test.ts +++ b/services/libs/project-onboarding/src/classificationTrace.test.ts @@ -1,13 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { - IOnboardingRequestLookups, - IParsedOnboardingRequest, - IPccCandidate, - OnboardingResolution, - PCC_MATCH_THRESHOLDS, -} from '@crowd/project-onboarding' - import { ClassificationNode, buildClassificationLogEntry, @@ -15,7 +7,14 @@ import { createClassificationTrace, toClassificationNode, traceLookups, -} from './requestClassificationTrace' +} from './classificationTrace' +import { IParsedOnboardingRequest } from './requestParser' +import { + IOnboardingRequestLookups, + IPccCandidate, + OnboardingResolution, + PCC_MATCH_THRESHOLDS, +} from './requestResolver' const pccProject: IPccCandidate = { projectId: 'pcc-1', diff --git a/services/apps/automatic_projects_discovery_worker/src/activities/requestClassificationTrace.ts b/services/libs/project-onboarding/src/classificationTrace.ts similarity index 97% rename from services/apps/automatic_projects_discovery_worker/src/activities/requestClassificationTrace.ts rename to services/libs/project-onboarding/src/classificationTrace.ts index 23f6d32d56..0b142e8507 100644 --- a/services/apps/automatic_projects_discovery_worker/src/activities/requestClassificationTrace.ts +++ b/services/libs/project-onboarding/src/classificationTrace.ts @@ -1,13 +1,12 @@ +import { IParsedOnboardingRequest } from './requestParser' import { CdpIntegrationAction, CdpSegmentLookupResult, IOnboardingRequestLookups, - IParsedOnboardingRequest, IPccCandidate, OnboardingResolution, assessPccCandidates, -} from '@crowd/project-onboarding' - +} from './requestResolver' export type ClassificationFailureStage = 'parse' | 'resolve' export interface IClassificationFailure { diff --git a/services/libs/project-onboarding/src/classifyRequest.test.ts b/services/libs/project-onboarding/src/classifyRequest.test.ts new file mode 100644 index 0000000000..cb766263f1 --- /dev/null +++ b/services/libs/project-onboarding/src/classifyRequest.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' + +import { classifyOnboardingRequest } from './classifyRequest' +import { IPccCandidate } from './requestResolver' + +const REQUEST_TEXT = 'Please onboard https://github.com/acme/tool' + +const PCC_CANDIDATE: IPccCandidate = { + projectId: 'pcc-1', + name: 'Tool', + slug: 'tool', + score: 1, + isLeaf: true, +} + +function llmAnswering(overrides: Record = {}) { + return vi.fn().mockResolvedValue( + JSON.stringify({ + repoUrls: ['https://github.com/acme/tool'], + linkUrls: [], + projectName: 'Tool', + declaredLf: null, + asksAboutHierarchy: false, + ...overrides, + }), + ) +} + +function lookupsFinding(candidates: IPccCandidate[]) { + return { + findPccCandidates: vi.fn().mockResolvedValue(candidates), + findCdpSegmentByPccProject: vi.fn().mockResolvedValue(null), + } +} + +describe('classifyOnboardingRequest', () => { + it('returns the node and the populated trace for a classified request', async () => { + const lookups = lookupsFinding([]) + + const result = await classifyOnboardingRequest(REQUEST_TEXT, { + queryLlm: llmAnswering(), + lookups, + }) + + expect(result.resolution).toEqual({ kind: 'non_lf_new_project', projectName: 'Tool' }) + expect(result.node).toBe('non_lf_create_in_external_group') + expect(result.trace).toMatchObject({ + parsed: { projectName: 'Tool', githubRepoUrls: ['https://github.com/acme/tool'] }, + pccLookup: { projectName: 'Tool', candidates: [] }, + failure: null, + }) + }) + + it('records the CDP lookup when a PCC project matches', async () => { + const lookups = lookupsFinding([PCC_CANDIDATE]) + + const result = await classifyOnboardingRequest(REQUEST_TEXT, { + queryLlm: llmAnswering(), + lookups, + }) + + expect(result.node).toBe('lf_in_pcc_not_in_cdp_human_review') + expect(lookups.findCdpSegmentByPccProject).toHaveBeenCalledWith('pcc-1') + expect(result.trace.cdpLookup).toEqual({ result: null }) + }) + + it('is ambiguous and traces a parse failure when the LLM answer is unusable', async () => { + const lookups = lookupsFinding([]) + + const result = await classifyOnboardingRequest(REQUEST_TEXT, { + queryLlm: vi.fn().mockResolvedValue('not json'), + lookups, + }) + + expect(result.node).toBe('ambiguous_human_review') + expect(result.resolution).toMatchObject({ + kind: 'ambiguous', + reason: expect.stringContaining('Request could not be parsed'), + }) + expect(result.trace.failure).toMatchObject({ stage: 'parse' }) + expect(result.trace.parsed).toBeNull() + expect(lookups.findPccCandidates).not.toHaveBeenCalled() + }) + + it('is ambiguous and traces a resolve failure when a lookup throws', async () => { + const lookups = { + findPccCandidates: vi.fn().mockRejectedValue(new Error('snowflake down')), + findCdpSegmentByPccProject: vi.fn(), + } + + const result = await classifyOnboardingRequest(REQUEST_TEXT, { + queryLlm: llmAnswering(), + lookups, + }) + + expect(result.node).toBe('ambiguous_human_review') + expect(result.resolution).toMatchObject({ + kind: 'ambiguous', + reason: 'Classification failed: snowflake down', + }) + expect(result.trace.failure).toEqual({ stage: 'resolve', reason: 'snowflake down' }) + expect(result.trace.parsed).toMatchObject({ projectName: 'Tool' }) + }) +}) diff --git a/services/libs/project-onboarding/src/classifyRequest.ts b/services/libs/project-onboarding/src/classifyRequest.ts new file mode 100644 index 0000000000..934c934739 --- /dev/null +++ b/services/libs/project-onboarding/src/classifyRequest.ts @@ -0,0 +1,61 @@ +import { getErrorMessage } from '@crowd/common' + +import { + ClassificationNode, + IClassificationTrace, + createClassificationTrace, + toClassificationNode, + traceLookups, +} from './classificationTrace' +import { OnboardingRequestLlm, parseOnboardingRequest } from './requestParser' +import { + IOnboardingRequestLookups, + OnboardingResolution, + resolveOnboardingRequest, +} from './requestResolver' + +export interface IRequestClassificationDeps { + queryLlm: OnboardingRequestLlm + lookups: IOnboardingRequestLookups +} + +export interface IRequestClassification { + resolution: OnboardingResolution + node: ClassificationNode + trace: IClassificationTrace +} + +function unresolved(reason: string): OnboardingResolution { + return { kind: 'ambiguous', reason, candidates: [] } +} + +async function resolveRequestText( + requestText: string, + deps: IRequestClassificationDeps, + trace: IClassificationTrace, +): Promise { + try { + const parsed = await parseOnboardingRequest(requestText, deps.queryLlm) + if (parsed.ok === false) { + trace.failure = { stage: 'parse', reason: parsed.reason } + return unresolved(`Request could not be parsed: ${parsed.reason}`) + } + + trace.parsed = parsed.request + return await resolveOnboardingRequest(parsed.request, traceLookups(deps.lookups, trace)) + } catch (err) { + const reason = getErrorMessage(err) + trace.failure = { stage: 'resolve', reason } + return unresolved(`Classification failed: ${reason}`) + } +} + +export async function classifyOnboardingRequest( + requestText: string, + deps: IRequestClassificationDeps, +): Promise { + const trace = createClassificationTrace() + const resolution = await resolveRequestText(requestText, deps, trace) + + return { resolution, node: toClassificationNode(resolution), trace } +} diff --git a/services/libs/project-onboarding/src/index.ts b/services/libs/project-onboarding/src/index.ts index 3a3f7c788d..8ea01d9871 100644 --- a/services/libs/project-onboarding/src/index.ts +++ b/services/libs/project-onboarding/src/index.ts @@ -1,3 +1,5 @@ +export * from './classificationTrace' +export * from './classifyRequest' export * from './onboarder' export * from './pccLookup' export * from './requestParser' diff --git a/services/apps/automatic_projects_discovery_worker/src/activities/requestClassificationDeps.ts b/services/libs/project-onboarding/src/requestClassifierDeps.ts similarity index 89% rename from services/apps/automatic_projects_discovery_worker/src/activities/requestClassificationDeps.ts rename to services/libs/project-onboarding/src/requestClassifierDeps.ts index e0b78c4bd4..b67a739d55 100644 --- a/services/apps/automatic_projects_discovery_worker/src/activities/requestClassificationDeps.ts +++ b/services/libs/project-onboarding/src/requestClassifierDeps.ts @@ -2,12 +2,12 @@ import { getErrorMessage } from '@crowd/common' import { LlmService } from '@crowd/common_services' import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' import { getServiceLogger } from '@crowd/logging' -import { createPccCandidatesLookup, IPccCandidateRow } from '@crowd/project-onboarding' import { SnowflakeClient } from '@crowd/snowflake' import { LlmQueryType } from '@crowd/types' import { createCdpSegmentLookup } from './cdpSegmentLookup' -import { IRequestClassificationDeps } from './requestClassification' +import { IRequestClassificationDeps } from './classifyRequest' +import { IPccCandidateRow, createPccCandidatesLookup } from './pccLookup' const log = getServiceLogger() @@ -47,7 +47,7 @@ function createSnowflakeClient(): SnowflakeClient | null { } } -export async function withRequestClassificationDeps( +export async function withRequestClassifierDeps( qx: QueryExecutor, run: (deps: IRequestClassificationDeps) => Promise, ): Promise { diff --git a/services/libs/project-onboarding/tsconfig.json b/services/libs/project-onboarding/tsconfig.json index a069387f2f..16a548dcf8 100644 --- a/services/libs/project-onboarding/tsconfig.json +++ b/services/libs/project-onboarding/tsconfig.json @@ -1,5 +1,24 @@ { "extends": "../../base.tsconfig.json", "include": ["src/**/*.ts"], - "references": [{ "path": "../common" }] + "references": [ + { + "path": "../common" + }, + { + "path": "../common_services" + }, + { + "path": "../data-access-layer" + }, + { + "path": "../logging" + }, + { + "path": "../snowflake" + }, + { + "path": "../types" + } + ] }