QA post-processing and citation repair
ENG-2934 moves the QA evaluation post-processing work that ran after the
native QA runner finished from Rails/GoodJob to API2/BullMQ. Production EU+US
rolling 12-hour evidence ending 2026-09-09 10:13 UTC:
SyncQAAgentEvaluationScorecardCriterionResultChunksJob 85 executions,
ReviewAICheckCitationsForOrganizationJob 54 executions but ~37 minutes of
Rails worker time (the single largest LLM-bound consumer left on GoodJob).
Class coverage
| Rails class | Producer(s) | API2 owner | State |
|---|---|---|---|
SyncQAAgentEvaluationScorecardCriterionResultChunksJob | QAAgentEvaluationScorecardCriterionResult after_commit (sync_chunks_later_if_needed → sync_chunks_later) | qa-criterion-result-chunk-sync queue, syncCriterionResultChunks | Migrated; GoodJob is fallback only |
ReviewAICheckCitationsJob | GoodJob cron review_ai_check_citations (0 5 * * *, ['yesterday']) | qa-citation-review queue, tick job, scheduler qa-citation-review-tick | Migrated; Rails cron disabled by default |
ReviewAICheckCitationsForOrganizationJob | fan-out from ReviewAICheckCitationsJob only (no other producer in app/) | qa-citation-review queue, organization job | Migrated |
GenerateQAAgentEvaluationCriterionFeedbackJob | api2 criterion_feedback diff job (per-differing-leaf fan-out) | qa-feedback-generation queue (criterion_result) | Migrated; api2 executes natively |
GenerateExpandedCommentJob | criterion-result comment correction callbacks | — | Deferred (see below) |
GenerateQAAgentEvaluationExpandedCommentJob | QAAgentEvaluation#generate_expanded_comment_later (admin regenerate + review flows) | qa-feedback-generation queue (expanded_comment) | Migrated; api2 executes natively |
GenerateCriterionFeedbackForDifferencesJob | QAAgentEvaluation#generate_criterion_feedback_for_differences_later (evaluation comparison callbacks) | qa-feedback-generation queue (criterion_feedback) | Migrated; api2 executes natively |
ComplaintSlackThreadFollowUpJob | complaint Slack thread producers | complaint-slack-follow-up queue, deliver job | Migrating behind the org-scoped PostHog gate; GoodJob is rollback/enqueue-rejection fallback |
The Rails job classes for the migrated work remain deployed so queued rows can drain and so the rollback path (below) works without a deploy.
Remaining deferred feedback generator
GenerateExpandedCommentJob (the comment-level expanded-comment generator
under EvaluationScorecardCriterionResultComment) stays on GoodJob — it was
not part of this migration’s job set.
The evaluation-level generators and the per-criterion generator
(GenerateQAAgentEvaluationCriterionFeedbackJob) are ported fully natively:
src/lib/qa-feedback-generation/ contains the prompt constants (copied
verbatim from the Rails GENERATE_EXPANDED_COMMENT_INSTRUCTIONS heredocs),
a Blueprinter-equivalent render layer under render/ that produces the same
JSON the *_Blueprint classes emit, the [KB_DOC ...]/[MSG_REF ...]
mention enrichment, and the OpenAI call (model_reasoning = gpt-5.2,
reasoning effort low, service_tier: flex, max_output_tokens: 2000,
same generate_expanded_comment prompt_id metadata). Deliberate render
differences, none of which affect prompt meaning: Active Storage URLs are
presigned object-storage URLs rather than rails_blob_url paths (same
convention as employee-payload.ts), Time.current in the metadata context
is volatile anyway, and mention spans are scanned with a targeted regex
instead of Nokogiri (the source HTML bytes are preserved rather than
re-serialized). The generate_expanded_comment! call sites on the models
remain in place for the GoodJob rollback path.
Contract
Criterion-result chunk/embedding sync
- Producer: the existing
after_commithook callsApi2QACriterionResultChunkSyncEnqueuer.enqueue!→POST /internal/qa_criterion_result_chunk_syncs/enqueue(bearerRulebaseConfig.internal_api_key, 1 s open / 3 s read timeout). A202means the BullMQ add was awaited, i.e. the job is durably in Redis. - Fallback: any non-2xx or transport error falls back to
SyncQAAgentEvaluationScorecardCriterionResultChunksJob.perform_laterand logsAPI2 QA criterion result chunk sync enqueue failed; falling back to GoodJob. Rails-side ineligible cleanup (delete chunks, clearchunks_synced_at) is unchanged and still runs inline. - Job id
qa-criterion-result-chunk-sync-<criterion_result_id>, 5 attempts, exponential backoff from 10 s. Every accepted enqueue is durable: the enqueue atomically sets a Redis dirty marker (qa-criterion-result-chunk-sync:dirty:<id>) and checks for an open run (qa-criterion-result-chunk-sync:open:<id>); an open run re-reads the row before its atomic final clean check closes it, a finished job under the deterministic id is removed and re-added, a waiting/delayed job is absorbed, and an id that is stillactiveafter its run closed gets a-successor-<n>job so the edit cannot be lost across BullMQ completion. - Worker semantics mirror
#sync_chunks+ChunkEmbeddingSync: missing row or blankexpanded_comment→ deletedocument_chunksforsource_type = 'QAAgentEvaluationScorecardCriterionResult'and clearchunks_synced_at; organization memory enabled → skip; otherwise chunk the comment as Markdown (chunkKnowledgeBaseMarkdown, same splitter the embed function used), embed with the"<scorecard name> > <criterion description>"context prefix, require 3072-dimension vectors, and replace chunks inside one transaction underSELECT … FOR UPDATEon the criterion result (parity withwith_lock). Chunk ids come fromdocument_chunks_id_seqso Rails and API2 never collide.
Daily citation review and repair
- Scheduler
qa-citation-review-tick, cron0 5 * * *UTC, data{ kind: "tick", date: "yesterday" }— identical to the GoodJob entry. - The tick resolves
today/yesterdayonce in UTC (Rails default zone), lists organizations, and enqueues oneorganizationjob per org with idqa-citation-review-<org_id>-<YYYY-MM-DD>(3 attempts, exponential backoff from 60 s). Tick retries dedupe against completed jobs and checkpointcompletedOrganizationIds, so a tick retry never replays an org. - Each organization job interprets the date in the org’s own timezone with
half-open bounds
[date 00:00 local, date+1 00:00 local), selects the same candidates asReviewAICheckCitationsForOrganizationJob(AI-source evaluations only; criterion reasons withPartLinkon fail/partial/auto_fail options with at least one kept check, then check results with fail/partial +PartLinksummaries; ordered, deduplicated), and processes each scorecard result in that order. - Per scorecard result: deterministic PartLink repair first (same-conversation
ids, unique Levenshtein ≤ 5 correction, ambiguous/unmatched stripped,
attribute rewritten as
part_id='…', scores untouched), then theverify_check_citationverifier (gpt-4.1, 4000 max output tokens, same prompt file, nullable structured output). Corrections replace the criterion result and check results, then re-run cross-criteria rules, parent criteria, scorecard score, evaluation score and conversation QA scores in that order. - Bounded/resumable:
processedScorecardResultIds,partLinkRepairsandmisattributionFixesare checkpointed into BullMQ job data after every scorecard result, so a retry resumes after the last completed result and cannot double-mutate. - Slack: repairs first, then misattributions; sections truncated at 3000
characters; no webhook or no changes → no send; the send is guarded by an
outbound_delivery_claimskey so a retry after an ambiguous outcome never re-posts (see “Resolvinguncertaindelivery claims” in the routine workload runbook). A definitive Slack rejection rethrows for BullMQ retry. - Admin re-drive:
POST /internal/qa_citation_reviews/enqueue{ organization_id, date }replaces a finished job for that org/date.
Evaluation feedback generation (fully native)
- Producer:
QAAgentEvaluation#generate_expanded_comment_later/#generate_criterion_feedback_for_differences_latercallApi2QAFeedbackGenerationEnqueuer.enqueue!→POST /internal/qa_feedback_generation/enqueue{ organization_id, evaluation_id, kind, force }(bearerRulebaseConfig.internal_api_key, 1 s open / 3 s read timeout). A202means the BullMQ add was awaited, i.e. the job is durably in Redis. The route only acceptsexpanded_commentandcriterion_feedback; thecriterion_resultkind is produced internally by the diff job. - Commit boundary:
enqueue_feedback_generationwraps the entire delivery — kill-switch check, API2 enqueue, and GoodJob fallback — inActiveRecord.after_all_transactions_commit. This fixes a production race observed on 2026-09-09 (~20:12 UTC):mark_as_completed!calledtrigger_feedback_generation_if_humaninside an open transaction, API2 picked the job up within milliseconds,loadEvaluationcould not see the uncommitted row, andUnrecoverableErrorsuppressed retries — six failed jobs (evaluation ids 255855, 255865, 255880, org 25) that had to be re-driven manually. Delivery now happens only after the outermost commit, and never after a rollback. - Snapshot render compatibility:
customer_churn_risk_levelis a Rails model method, not a database column. API2 derives it fromcustomer_churn_risk_score(nonefor null,highat 0.7,mediumat 0.4, otherwiselow) and tenant-qualifies the snapshot lookup. This fixes a second production failure observed after the commit-boundary release: new evaluation-level jobs could see their rows but exhausted retries while selecting the nonexistent method name as a column. - Fallback: any non-2xx or transport error falls back to the retained GoodJob
class (
GenerateQAAgentEvaluationExpandedCommentJob.perform_later/GenerateCriterionFeedbackForDifferencesJob.perform_later) and logs a warning; Flippergood_job_qa_feedback_generation(per-org actor supported) bypasses API2 entirely. - Job id
qa-feedback-generation-<kind>-<evaluation_id>-<normal|force>(and...-criterion_result-<criterion_result_id>-<ai_id|none>-<normal|force>for the per-criterion fan-out), 3 attempts, exponential backoff from 10 s.forceis part of the identity so an admin force re-drive never collapses onto a waiting normal job; a finished job under the id is removed and re-added, a waiting/delayed job is absorbed. - Worker semantics:
expanded_commentrunsgenerateEvaluationExpandedComment(port ofQAAgentEvaluation#generate_expanded_comment!) andcriterion_feedbackrunsgenerateCriterionFeedbackForDifferences(port of#generate_criterion_feedback_for_differences), which diffs each leaf criterion result against the AI criterion-results map (latest AI evaluation for the same subject employee, else the snapshot’s own results) and enqueues onecriterion_resultjob per difference — the same per-criterion isolationGenerateQAAgentEvaluationCriterionFeedbackJobprovided. Eachcriterion_resultjob runsgenerateCriterionResultExpandedComment(port ofQAAgentEvaluationScorecardCriterionResult#generate_expanded_comment!). - Error parity: a missing evaluation/criterion result completes
unrecoverable, matching
discard_on ActiveRecord::RecordNotFound; the eval-level flow logs and swallows OpenAI errors (its Railsrescuedid the same), while the criterion flow raises on empty responses and API errors so BullMQ retries (mirroring the retired job’sretry_onlist). Both flows short-circuit onqa_organization_memory_enabled?and on an existingexpanded_commentunlessforce.
Rollout controls
| Control | Store | Safe state | Effect |
|---|---|---|---|
pause_api2_qa_citation_review | Flipper (API2 reads flipper_features) | off | Tick completes as paused without fan-out. Owner: platform engineering; created 2026-09-09; review/remove by 2026-12-08. |
good_job_qa_criterion_result_chunk_sync | Flipper (Rails, per-org actor supported) | off | sync_chunks_later bypasses API2 and enqueues the GoodJob class directly. Owner: platform engineering; created 2026-09-09; review/remove by 2026-12-08. |
good_job_qa_feedback_generation | Flipper (Rails, per-org actor supported) | off | generate_expanded_comment_later / generate_criterion_feedback_for_differences_later bypass API2 and enqueue the retained GoodJob classes. Owner: QA engineering; created 2026-09-09; review/remove by 2026-12-08. |
review_ai_check_citations cron | good_job.rb, enabled_by_default: false | disabled | Enable in the GoodJob dashboard (or revert the line) only after pausing API2. |
Rollback order
- Citation review: enable
pause_api2_qa_citation_review, confirm the next tick logspaused, then enable thereview_ai_check_citationscron in the GoodJob dashboard. Organization jobs already in flight finish their current scorecard result and checkpoint; they do not re-run under Rails because Rails selects candidates bycreated_atwindow and the repaired rows no longer contain broken links. - Chunk sync: enable
good_job_qa_criterion_result_chunk_sync(globally or for one organization). API2 jobs already queued still run; they are idempotent against the same row. - Re-drive a missed citation day after rollback with
ReviewAICheckCitationsJob.perform_later('YYYY-MM-DD')(Rails) or onePOST /internal/qa_citation_reviews/enqueueper org (API2). - Feedback generation: enable
good_job_qa_feedback_generation(globally or for one organization). API2 jobs already queued still run and execute the same Rails code path, so there is no double-write risk.
Bake gates
Ownership is asserted only after, in both regions:
- Two consecutive 05:00 UTC ticks complete with
enqueued = organizationsand zero failed organization jobs (or every failure is a tenant-isolated Sentry event with a checkpoint that resumed on retry). - Zero new normal-path GoodJob rows for
SyncQAAgentEvaluationScorecardCriterionResultChunksJob,ReviewAICheckCitationsJob,ReviewAICheckCitationsForOrganizationJob,GenerateQAAgentEvaluationExpandedCommentJobandGenerateCriterionFeedbackForDifferencesJobfor 24 hours (retained rows inside the 12-hour cleanup window are historical; fallback rows are identified by the warning log above). - Zero
UnrecoverableError: qa_agent_evaluation <id> not foundfailures on theqa-feedback-generationqueue since the commit-boundary fix deployed — that error signature means the enqueue still outran the producing transaction and the fix is incomplete. - No
uncertainoutbound delivery claims withqa-citation-reviewkeys, and the team Slack channel shows one post per org/day at most. chunks_synced_atcontinues to advance for eligible criterion results anddocument_chunkscounts per result match the Markdown chunker output.
After 24 clean hours, remove the GoodJob fallback branches in
sync_chunks_later and QAAgentEvaluation#enqueue_feedback_generation,
delete SyncQAAgentEvaluationScorecardCriterionResultChunksJob,
GenerateQAAgentEvaluationExpandedCommentJob,
GenerateCriterionFeedbackForDifferencesJob, both citation job classes, the
cron entry and the flags.
Observation queries
GoodJob normal-path traffic (must trend to zero):
SELECT serialized_params->>'job_class' AS job_class,
count(*) AS executions,
sum(extract(epoch FROM (finished_at - performed_at))) / 60 AS worker_minutes
FROM good_jobs
WHERE serialized_params->>'job_class' IN (
'SyncQAAgentEvaluationScorecardCriterionResultChunksJob',
'ReviewAICheckCitationsJob',
'ReviewAICheckCitationsForOrganizationJob',
'GenerateQAAgentEvaluationExpandedCommentJob',
'GenerateCriterionFeedbackForDifferencesJob')
AND created_at > now() - interval '24 hours'
GROUP BY 1;Chunk-sync health (results with a comment but stale/missing chunks):
SELECT count(*) FILTER (WHERE chunks_synced_at IS NULL) AS never_synced,
count(*) FILTER (WHERE chunks_synced_at < updated_at - interval '10 minutes') AS stale
FROM qa_agent_evaluation_scorecard_criterion_results
WHERE expanded_comment IS NOT NULL AND btrim(expanded_comment) <> ''
AND updated_at > now() - interval '24 hours';Citation repair effect (broken links remaining for yesterday’s evaluations):
SELECT count(*)
FROM qa_agent_evaluation_scorecard_criterion_check_results ccr
JOIN qa_agent_evaluation_scorecard_criterion_results cr
ON cr.id = ccr.qa_agent_evaluation_scorecard_criterion_result_id
JOIN qa_agent_evaluation_scorecard_results sr
ON sr.id = cr.qa_agent_evaluation_scorecard_result_id
JOIN qa_agent_evaluations e ON e.id = sr.qa_agent_evaluation_id
WHERE e.source = 'ai'
AND ccr.result IN ('failed', 'partial')
AND ccr.summary LIKE '%PartLink%'
AND ccr.created_at >= (current_date - 1)::timestamp AT TIME ZONE 'UTC'
AND ccr.created_at < current_date::timestamp AT TIME ZONE 'UTC';Slack delivery claims for the scan:
SELECT delivery_key, claimed_at, delivered_at
FROM outbound_delivery_claims
WHERE delivery_key LIKE 'qa-citation-review:slack:%'
AND claimed_at > now() - interval '48 hours'
ORDER BY claimed_at DESC;BullMQ (redis-cli): ZCARD bull:qa-citation-review:completed,
ZCARD bull:qa-citation-review:failed,
ZCARD bull:qa-criterion-result-chunk-sync:failed; failed counts must stay
at zero or be explained by Sentry events tagged with the organization id.
Verification
API2 unit tests cover PartLink parsing/repair parity with the Rails specs
(including the part_id='…' rewrite), verifier prompt byte-equality with
config/prompts/verify_check_citation.txt, model/token/schema settings,
suspect classification and 500-character truncation, Slack ordering and 3000
character truncation, organization checkpointing/resume/dedupe, tick date
resolution, pause gate, fan-out and deterministic job options, chunk-sync
context prefix and Markdown chunking, internal route auth/enqueue, and worker
and scheduler registration in worker.ts. A PGlite test runs the real
candidate SQL against org-local and UTC day bounds. Rails specs cover the
enqueuer, the API2-first callback, the GoodJob fallback and the Flipper
rollback, and the disabled cron entry. pnpm typecheck and scoped RuboCop
are required before push.