Skip to content

Rank local campaign source units before extraction - #226

Open
lumenstech wants to merge 53 commits into
trycompai:mainfrom
lumenstech:local-lead-source-unit-ranking
Open

lumenstech wants to merge 53 commits into
trycompai:mainfrom
lumenstech:local-lead-source-unit-ranking

Conversation

@lumenstech

@lumenstech lumenstech commented Sep 17, 2026

Copy link
Copy Markdown

Summary

  • Adds deterministic source-unit ranking before local Ollama extraction.
  • Prioritizes company-level exhibitor rows with company names, external websites, exhibitor/profile links, booth or stand evidence, location, product/service descriptions, and exhibitor context.
  • Downranks event/title-only, venue-only, boilerplate, registration, and promotional text.
  • Preserves dedupe before model calls.
  • Stores source unit rank score and ranking reasons on staged local leads.

Pilot Result

Compared with the prior pilot:

  • Source units found: 131.
  • Ollama calls: 17.
  • Staged leads: 5.
  • Needs review: 3.
  • New status: 2.
  • Duplicates: 0.
  • Average accepted latency: about 15.6s.
  • Useful staged leads improved from 1 to 5.

Staged examples included:

  • Climax Metal Products Company.
  • XIAMEN INCHOL NEW ENERGY TECHNOLOGY CO., LTD.
  • ANHUI XINNING EQUIPMENT CO., LTD.
  • Eaton.

Safety

  • No CRM writes.
  • No Neon writes.
  • No migrations.
  • No launch-agent edits.
  • No outreach or email sending.
  • deploy/ excluded.
  • Local pilot artifacts excluded.

Tests

  • Focused tests: 53 passed.
  • Type check: passed.
  • Biome: passed, with existing agent/lib/tasks.ts warning.
  • git diff --check: passed.
  • Campaign validation: passed.
  • --sync-crm refusal: passed.
  • Forbidden import scan: passed for CRM/Neon/Prisma/database/outreach/email imports.

Known Follow-ups

  • Tighten false-positive rejection for event pages and vendor/marketing/footer links.
  • Improve handling for weak website-only leads, like the AIMG case.
  • Rerun a larger PartWall pilot after false-positive filtering.
  • Add approved review/import path only after lead quality is stable.

Summary by cubic

Ranks local campaign source units before Ollama extraction so the runner spends model calls on the most promising exhibitor rows and stages fewer, higher-quality leads. Previously source units went to the model without ranking; now each is scored, dedupe still runs before model calls, and staged leads include the rank score and ranking reasons.

  • The local campaign runner is dry-run only: pages, leads, and exports go under apps/agent/var/local-leads, and it never writes CRM or Neon data.
  • Also adds a signal ingest API (idempotent ingest, company resolution, qualification, promotion) and an Intelligence inbox UI for reviewing signals before they become CRM records.
  • The included Prisma migration adds business_unit, canonical_company, and optional businessUnitId on company, deal, and agent task.

Written for commit 9948864. Summary will update on new commits.

Review in cubic

carhartlewis and others added 30 commits August 7, 2026 11:38
@vercel

vercel Bot commented Sep 17, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the Comp AI - PoC Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions
github-actions Bot changed the base branch from release to main September 17, 2026 03:07
@github-actions

Copy link
Copy Markdown
Contributor

Retargeted this onto main.

release is the default branch so that a plain clone runs the last tagged release, but nothing merges into it — it is fast-forwarded onto the tag by the Release workflow and that is all. Changes go to main, and reach release when a release is cut.

Nothing is wrong with your branch. If the diff now shows commits that are already on main, rebase and force-push:

git fetch origin main
git rebase origin/main
git push --force-with-lease

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

40 issues found across 34 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/api/src/ingest/ingest.service.ts">

<violation number="1" location="apps/api/src/ingest/ingest.service.ts:114">
P1: When the same source tuple is ingested for another project, this conflict handler moves the existing signal to the second business unit. Scope the uniqueness and conflict handling by business unit, or reject cross-project duplicates instead of overwriting ownership.</violation>

<violation number="2" location="apps/api/src/ingest/ingest.service.ts:139">
P2: A malformed `signal_score` or legacy `fit_score` makes the inbox query fail because these text values are cast directly to `float`. Validate the value with a numeric-safe expression before casting.</violation>

<violation number="3" location="apps/api/src/ingest/ingest.service.ts:181">
P2: When a source record has no `observedAt`, the inbox mapper throws on `.toISOString()`. Either require the column or return a nullable timestamp consistently in the query result and output contract.</violation>
</file>

<file name="apps/agent/agent/cli/local-campaign-worker.ts">

<violation number="1" location="apps/agent/agent/cli/local-campaign-worker.ts:23">
P1: When an optional flag is omitted, `argument()` returns the command name instead of `undefined`. `run`, `list`, and `export` then fail before doing their work. Guard the fallback with `index >= 0`.</violation>

<violation number="2" location="apps/agent/agent/cli/local-campaign-worker.ts:56">
P1: The CLI overrides bypass `localCampaignSchema`, so callers can exceed source, timeout, and Ollama-call caps or turn a positive value into zero. Parse the merged campaign with `localCampaignSchema` before calling `runLocalCampaign`.</violation>
</file>

<file name="apps/agent/agent/lib/local-lead-extraction.ts">

<violation number="1" location="apps/agent/agent/lib/local-lead-extraction.ts:105">
P1: When Ollama returns a fabricated lead plus a matching fabricated excerpt, validation accepts it and stages it because the excerpt is never checked against `text`. Pass source text into validation and require `evidence_excerpt` to occur in that source before accepting the lead.</violation>

<violation number="2" location="apps/agent/agent/lib/local-lead-extraction.ts:154">
P1: When a public seed URL redirects or DNS-rebinds to a private address, `fetchImpl` can read that internal endpoint after the one-time hostname check. Validate every destination and pin the validated address, or reject redirects and use a pinned client.</violation>

<violation number="3" location="apps/agent/agent/lib/local-lead-extraction.ts:181">
P2: When a server sends headers and stalls the body, `reader.read()` can wait indefinitely because the fetch timeout was cleared before body consumption. Keep the abort timer active through the body or time-limit each read.</violation>
</file>

<file name="packages/db/prisma/schema.prisma">

<violation number="1" location="packages/db/prisma/schema.prisma:1700">
P1: An opportunity, source, or person link can combine records from different business units. Add composite same-unit foreign keys or database checks for every canonical relationship.</violation>

<violation number="2" location="packages/db/prisma/schema.prisma:1782">
P2: A queued campaign member can reference a deleted or nonexistent target. Add optional canonical company and person relations, then enforce that each target belongs to the campaign’s business unit.</violation>

<violation number="3" location="packages/db/prisma/schema.prisma:1790">
P1: This constraint permits duplicate recipients and members with no recipient. Require one target and add null-safe partial unique indexes for company and person members.</violation>

<violation number="4" location="packages/db/prisma/schema.prisma:1829">
P2: `AgentRunContext.agentRunId` accepts orphan run IDs even though `AgentRun` exists in this schema. Add an `AgentRun` relation, inverse field, and explicit delete policy.</violation>
</file>

<file name="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:69">
P2: When more than 100 signals exist, this inbox silently hides every signal after the first 100 because it has no paging or load-more path. Reps cannot review the remaining signals or projects from this screen; add pagination or an explicit load-more control.</violation>

<violation number="2" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:216">
P1: When a rep opens signal B after editing signal A, `SignalReviewSheet` retains both `score` and `ownerId` because it is not keyed or reset by `sourceRecordId`. Saving B can submit A’s scoring components or assign A’s owner to B. Reset these fields when the row changes, or key the sheet by the selected source record.</violation>

<violation number="3" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:219">
P2: After resolve or qualify succeeds, `SignalReviewSheet` keeps the old `selected` snapshot because `onChanged` only refetches `signals`. The sheet then uses stale `row.mapped` and `row.signalScore`, so promotion remains disabled or unresolved status remains visible. Replace `selected` with the refetched row, or close the sheet when it disappears.</violation>

<violation number="4" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:347">
P1: `row.sourceUrl` is rendered directly as an anchor, while the ingest contract accepts a generic URL rather than restricting schemes to HTTP(S). A crafted `javascript:` source URL executes in the CRM origin when a user clicks Open source; allow only `http:` and `https:` URLs before rendering.</violation>
</file>

<file name="apps/api/src/ingest/signal-qualification.service.ts">

<violation number="1" location="apps/api/src/ingest/signal-qualification.service.ts:108">
P2: Validate the resolved amount before either write. Negative source estimates currently reach canonical and visible deal amounts, while oversized `amountUsd` values bypass `dealCreateInput` because `promote` invokes `DealsService.create` directly.</violation>

<violation number="2" location="apps/api/src/ingest/signal-qualification.service.ts:113">
P1: Concurrent `promote` calls can both observe no mapping here, create duplicate canonical opportunities and deals, and then let the final upsert retain only one mapping. Serialize promotion per source or make opportunity, deal, and mapping creation one idempotent transaction.</violation>

<violation number="3" location="apps/api/src/ingest/signal-qualification.service.ts:131">
P1: When `createDeal` is requested but the score, company link, or owner validation fails, this insert commits before the later checks and leaves an orphan canonical opportunity even though `promote` returns 400. Validate all promotion prerequisites first and wrap the canonical, deal, and mapping writes in one transaction.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-source-units.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-source-units.ts:82">
P1: Every valid anchor becomes a source unit, including ordinary navigation and footer links. Restrict link extraction to company/profile links with meaningful context, or extract the enclosing card or row, before ranking and sending candidates to Ollama.</violation>

<violation number="2" location="apps/agent/agent/lib/local-campaign-source-units.ts:209">
P2: The dedupe key requires the website, company text, and content hash to all match, so changed descriptions create duplicate Ollama calls for the same exhibitor. Treat matching website or company keys as duplicates, using the content hash only when neither identifier exists.</violation>

<violation number="3" location="apps/agent/agent/lib/local-campaign-source-units.ts:271">
P2: Exhibitor rows without a legal suffix, contact, or URL are dropped before ranking, even when they contain booth and product evidence. Include those company-context signals in this gate so names such as `Eaton` reach extraction.</violation>
</file>

<file name="packages/db/prisma/migrations/20260902184536_sequencenow_crm_foundation/migration.sql">

<violation number="1" location="packages/db/prisma/migrations/20260902184536_sequencenow_crm_foundation/migration.sql:11">
P1: On a fresh database, every ingest request fails because the migration creates no business-unit records and the repository seed does not add one. Seed the supported project keys or provide the required provisioning path before enabling this endpoint.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-cache.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-cache.ts:23">
P2: Every cache lookup scans the entire append-only `pages.jsonl`. Larger campaigns therefore incur quadratic I/O and can exhaust memory; use an indexed or bounded cache.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-runner.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-runner.ts:396">
P2: When fetch, extraction, or staging runs past `perSeedTimeoutMs`, the runner only checked the deadline beforehand and still accepts the result. Enforce the deadline across each awaited operation and stop the seed when it expires.</violation>

<violation number="2" location="apps/agent/agent/lib/local-campaign-runner.ts:409">
P2: When the extractor repairs a schema-invalid response, it makes a second Ollama request, but this counter advances only once. Count both attempts before enforcing per-seed and global call caps.</violation>

<violation number="3" location="apps/agent/agent/lib/local-campaign-runner.ts:454">
P2: When `required_fields` contains a typo or unsupported field, this lookup returns `undefined`, which is not `null`, so the lead is marked `new`. Validate required field names or treat unknown fields as missing.</violation>
</file>

<file name="apps/api/src/ingest/ingest.module.ts">

<violation number="1" location="apps/api/src/ingest/ingest.module.ts:9">
P1: The ingest procedures cannot resolve their `AuthMiddleware` through this module because `IngestModule` does not import `TrpcModule`. Import `TrpcModule` here, as every other router-bearing feature module does, so the authenticated ingest routes are registered and executable.</violation>
</file>

<file name="apps/agent/agent/lib/local-ollama.ts">

<violation number="1" location="apps/agent/agent/lib/local-ollama.ts:62">
P2: When Ollama sends headers but stalls the response body, this call waits indefinitely because the timeout is cleared before `response.json()` runs. Keep the same deadline active through body consumption and abort or race `response.json()` so the per-call timeout bounds the full request.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-scoring.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-scoring.ts:21">
P2: `project_fit_reason` is model-authored and not checked against source text, but this line lets it supply keywords and exclusions. A model can therefore raise a fit score or trigger an exclusion without source evidence; score only source-grounded fields or validate this field against the excerpt.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-extraction.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-extraction.ts:89">
P2: When a model returns the wrong `source_url` or a scalar value missing from `evidence_excerpt`, `extractLocalCampaignLead` drops the candidate without using its repair attempt. Include these validation failures in `needsSchemaRepair` so the repair prompt can correct the response.</violation>
</file>

<file name="apps/agent/agent/lib/local-lead-staging.ts">

<violation number="1" location="apps/agent/agent/lib/local-lead-staging.ts:17">
P2: When `var/local-leads` contains a symlink, this lexical check accepts it and `appendFile` follows it, allowing `--staging-path` to write outside the staging root. Resolve and validate the real parent path, or open the file without following symlinks.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-staging.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-staging.ts:26">
P2: `URL.pathname` is percent-encoded and uses URL-style Windows paths. Repositories with spaces or Windows checkouts resolve `AGENT_ROOT` incorrectly; use `fileURLToPath(import.meta.url)`.</violation>

<violation number="2" location="apps/agent/agent/lib/local-campaign-staging.ts:44">
P2: On Windows, `resolved` contains backslashes, so this check allows `var/local-leads/.git/config`. Use a separator-independent Git path check.</violation>

<violation number="3" location="apps/agent/agent/lib/local-campaign-staging.ts:69">
P2: The `domain:` key in dedupeKeys makes every lead from the same page domain a duplicate of every other lead from that domain. `runLocalCampaign` sets `source_domain` to the seed *page* domain (`local-campaign-runner.ts`: `source_domain: domain`, derived from `new URL(url).hostname`), and `dedupeAgainst` treats any single shared key as a match. On a multi-exhibitor directory page, the first staged lead makes every subsequent same-page lead a duplicate, so only one company survives per page despite the ranked-unit design (`max_ollama_calls_per_seed` up to 5). Remove the `domain:` key (the `hash:`, `company:`, `email:` and `phone:` keys already cover source content and identity), or derive it from the company website host instead of the source page. Verified by simulation: lead B (Beta Fixtures, same page domain) is flagged duplicate against lead A (Acme Displays).</violation>

<violation number="4" location="apps/agent/agent/lib/local-campaign-staging.ts:71">
P2: `normalizeText` strips email punctuation, so `a.b@example.com` and `a-b@example.com` produce the same duplicate key. Lowercase and trim email addresses without removing punctuation.</violation>
</file>

<file name="apps/agent/agent/cli/local-lead-worker.ts">

<violation number="1" location="apps/agent/agent/cli/local-lead-worker.ts:21">
P2: When an option has no value, `valueAfter` consumes the next flag or returns `undefined`, so malformed commands can extract flag text or silently use fallback values. Reject missing and empty option values before extraction and staging.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-lead-schema.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-lead-schema.ts:6">
P2: When source evidence contains a bare domain such as `acme.com`, this validation rejects the extraction before staging, and the repair prompt does not require a scheme. Accept non-empty website values or normalize bare domains before validating them.</violation>
</file>

<file name="apps/agent/test/local-lead-staging.spec.ts">

<violation number="1" location="apps/agent/test/local-lead-staging.spec.ts:54">
P2: The test does not await this asynchronous assertion. Await the matcher so a regression that accepts `.env` fails this test reliably.</violation>
</file>

<file name="apps/agent/campaigns/partwall-2027.json">

<violation number="1" location="apps/agent/campaigns/partwall-2027.json:11">
P2: When this campaign is run without `--seed-file`, it silently processes zero seeds because `seed_urls` is empty. Populate `seed_urls` with the campaign's source URLs or provide a checked-in/manual-list input and make that invocation explicit.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

CURRENT_TIMESTAMP
)
ON CONFLICT ("sourceSystem", "sourceType", "sourceId") DO UPDATE SET
"businessUnitId" = EXCLUDED."businessUnitId",

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the same source tuple is ingested for another project, this conflict handler moves the existing signal to the second business unit. Scope the uniqueness and conflict handling by business unit, or reject cross-project duplicates instead of overwriting ownership.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/ingest/ingest.service.ts, line 114:

<comment>When the same source tuple is ingested for another project, this conflict handler moves the existing signal to the second business unit. Scope the uniqueness and conflict handling by business unit, or reject cross-project duplicates instead of overwriting ownership.</comment>

<file context>
@@ -0,0 +1,415 @@
+				CURRENT_TIMESTAMP
+			)
+			ON CONFLICT ("sourceSystem", "sourceType", "sourceId") DO UPDATE SET
+				"businessUnitId" = EXCLUDED."businessUnitId",
+				"sourceUrl" = EXCLUDED."sourceUrl",
+				"observedAt" = EXCLUDED."observedAt",
</file context>
Fix with cubic

return (
args
.find((value) => value.startsWith(`${flag}=`))
?.slice(flag.length + 1) ?? args[index + 1]

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When an optional flag is omitted, argument() returns the command name instead of undefined. run, list, and export then fail before doing their work. Guard the fallback with index >= 0.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/cli/local-campaign-worker.ts, line 23:

<comment>When an optional flag is omitted, `argument()` returns the command name instead of `undefined`. `run`, `list`, and `export` then fail before doing their work. Guard the fallback with `index >= 0`.</comment>

<file context>
@@ -0,0 +1,126 @@
+	return (
+		args
+			.find((value) => value.startsWith(`${flag}=`))
+			?.slice(flag.length + 1) ?? args[index + 1]
+	);
+}
</file context>
Suggested change
?.slice(flag.length + 1) ?? args[index + 1]
?.slice(flag.length + 1) ?? (index >= 0 ? args[index + 1] : undefined)
Fix with cubic

let response: Response;
try {
response = await Promise.race([
fetchImpl(url, {

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a public seed URL redirects or DNS-rebinds to a private address, fetchImpl can read that internal endpoint after the one-time hostname check. Validate every destination and pin the validated address, or reject redirects and use a pinned client.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-lead-extraction.ts, line 154:

<comment>When a public seed URL redirects or DNS-rebinds to a private address, `fetchImpl` can read that internal endpoint after the one-time hostname check. Validate every destination and pin the validated address, or reject redirects and use a pinned client.</comment>

<file context>
@@ -0,0 +1,301 @@
+	let response: Response;
+	try {
+		response = await Promise.race([
+			fetchImpl(url, {
+				signal: controller.signal,
+				headers: { accept: "text/html,text/plain;q=0.9" },
</file context>
Fix with cubic

businessUnitId String
businessUnit BusinessUnit @relation(fields: [businessUnitId], references: [id], onDelete: Restrict)
companyId String?
company CanonicalCompany? @relation(fields: [companyId], references: [id], onDelete: SetNull)

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: An opportunity, source, or person link can combine records from different business units. Add composite same-unit foreign keys or database checks for every canonical relationship.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/prisma/schema.prisma, line 1700:

<comment>An opportunity, source, or person link can combine records from different business units. Add composite same-unit foreign keys or database checks for every canonical relationship.</comment>

<file context>
@@ -1583,3 +1592,249 @@ model Apikey {
+  businessUnitId String
+  businessUnit   BusinessUnit @relation(fields: [businessUnitId], references: [id], onDelete: Restrict)
+  companyId      String?
+  company        CanonicalCompany? @relation(fields: [companyId], references: [id], onDelete: SetNull)
+  pipelineId     String?
+  pipeline       Pipeline? @relation(fields: [pipelineId], references: [id], onDelete: SetNull)
</file context>
Fix with cubic

</table>
</div>

<SignalReviewSheet

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a rep opens signal B after editing signal A, SignalReviewSheet retains both score and ownerId because it is not keyed or reset by sourceRecordId. Saving B can submit A’s scoring components or assign A’s owner to B. Reset these fields when the row changes, or key the sheet by the selected source record.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx, line 216:

<comment>When a rep opens signal B after editing signal A, `SignalReviewSheet` retains both `score` and `ownerId` because it is not keyed or reset by `sourceRecordId`. Saving B can submit A’s scoring components or assign A’s owner to B. Reset these fields when the row changes, or key the sheet by the selected source record.</comment>

<file context>
@@ -0,0 +1,650 @@
+				</table>
+			</div>
+
+			<SignalReviewSheet
+				row={selected}
+				onClose={() => setSelected(null)}
</file context>
Suggested change
<SignalReviewSheet
<SignalReviewSheet key={selected?.sourceRecordId ?? "empty"}
Fix with cubic

) {
throw new Error("Campaign path must stay under var/local-leads.");
}
if (basename(resolved).startsWith(".env") || resolved.includes("/.git/")) {

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On Windows, resolved contains backslashes, so this check allows var/local-leads/.git/config. Use a separator-independent Git path check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-campaign-staging.ts, line 44:

<comment>On Windows, `resolved` contains backslashes, so this check allows `var/local-leads/.git/config`. Use a separator-independent Git path check.</comment>

<file context>
@@ -0,0 +1,118 @@
+	) {
+		throw new Error("Campaign path must stay under var/local-leads.");
+	}
+	if (basename(resolved).startsWith(".env") || resolved.includes("/.git/")) {
+		throw new Error("Campaign path cannot target configuration or Git files.");
+	}
</file context>
Fix with cubic

return [
`domain:${normalizeDomain(lead.source_domain)}`,
lead.company_name ? `company:${normalizeText(lead.company_name)}` : null,
lead.email ? `email:${normalizeText(lead.email)}` : null,

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: normalizeText strips email punctuation, so a.b@example.com and a-b@example.com produce the same duplicate key. Lowercase and trim email addresses without removing punctuation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-campaign-staging.ts, line 71:

<comment>`normalizeText` strips email punctuation, so `a.b@example.com` and `a-b@example.com` produce the same duplicate key. Lowercase and trim email addresses without removing punctuation.</comment>

<file context>
@@ -0,0 +1,118 @@
+	return [
+		`domain:${normalizeDomain(lead.source_domain)}`,
+		lead.company_name ? `company:${normalizeText(lead.company_name)}` : null,
+		lead.email ? `email:${normalizeText(lead.email)}` : null,
+		lead.phone ? `phone:${normalizePhone(lead.phone)}` : null,
+		`hash:${lead.content_hash}`,
</file context>
Suggested change
lead.email ? `email:${normalizeText(lead.email)}` : null,
lead.email ? `email:${lead.email.trim().toLocaleLowerCase()}` : null,
Fix with cubic

export const localCampaignLeadSchema = z
.object({
company_name: z.string().trim().min(1).nullable(),
website: z.string().trim().url().nullable(),

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When source evidence contains a bare domain such as acme.com, this validation rejects the extraction before staging, and the repair prompt does not require a scheme. Accept non-empty website values or normalize bare domains before validating them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-campaign-lead-schema.ts, line 6:

<comment>When source evidence contains a bare domain such as `acme.com`, this validation rejects the extraction before staging, and the repair prompt does not require a scheme. Accept non-empty website values or normalize bare domains before validating them.</comment>

<file context>
@@ -0,0 +1,29 @@
+export const localCampaignLeadSchema = z
+	.object({
+		company_name: z.string().trim().min(1).nullable(),
+		website: z.string().trim().url().nullable(),
+		contact_name: z.string().trim().min(1).nullable(),
+		role: z.string().trim().min(1).nullable(),
</file context>
Suggested change
website: z.string().trim().url().nullable(),
website: z.string().trim().min(1).nullable(),
Fix with cubic

"https://example.com/acme",
{ generate: async () => ({ text: response, latencyMs: 5 }) },
);
expect(stageLocalLead(lead, ".env")).rejects.toThrow("var/local-leads");

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The test does not await this asynchronous assertion. Await the matcher so a regression that accepts .env fails this test reliably.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/test/local-lead-staging.spec.ts, line 54:

<comment>The test does not await this asynchronous assertion. Await the matcher so a regression that accepts `.env` fails this test reliably.</comment>

<file context>
@@ -0,0 +1,56 @@
+			"https://example.com/acme",
+			{ generate: async () => ({ text: response, latencyMs: 5 }) },
+		);
+		expect(stageLocalLead(lead, ".env")).rejects.toThrow("var/local-leads");
+	});
+});
</file context>
Suggested change
expect(stageLocalLead(lead, ".env")).rejects.toThrow("var/local-leads");
await expect(stageLocalLead(lead, ".env")).rejects.toThrow("var/local-leads");
Fix with cubic

"end": "2027-06-30"
},
"source_types": ["seed_url", "manual_list"],
"seed_urls": [],

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When this campaign is run without --seed-file, it silently processes zero seeds because seed_urls is empty. Populate seed_urls with the campaign's source URLs or provide a checked-in/manual-list input and make that invocation explicit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/campaigns/partwall-2027.json, line 11:

<comment>When this campaign is run without `--seed-file`, it silently processes zero seeds because `seed_urls` is empty. Populate `seed_urls` with the campaign's source URLs or provide a checked-in/manual-list input and make that invocation explicit.</comment>

<file context>
@@ -0,0 +1,29 @@
+		"end": "2027-06-30"
+	},
+	"source_types": ["seed_url", "manual_list"],
+	"seed_urls": [],
+	"keywords": [
+		"exhibitor",
</file context>
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants