feat(backend): REST-authoritative qscore pipeline with Redis opt-in
- Gate both Redis ingestion paths (canonical + legacy) behind separate default-off flags (GROW_EVENTS_REDIS_ENABLED, LEGACY_SERVICE_REDIS_ENABLED). The redis module is never imported when both flags are off. - Sever legacy URL cascade: interview/roleplay/resume/courses Redis URLs resolve ONLY from their own explicit env vars, never from REDIS_URL or GROW_EVENTS_REDIS_URL. - Export resolveRedisConfig() pure resolver for testability. - Fix onboarding signal ID from 'onboarding.completed_baseline' to 'onboarding.completed' (registry-valid in all v2 formula JSONs). - Ensure interview/roleplay completed events emit at least a single completion signal when session_count/scenario_count is absent (default 1). - Extract computeStreakFromDays() into streak-utils.ts (pure, no DB dep) with defensive dedup so duplicate days never inflate streaks. - Add focused tests: redis-gating, signal-registry membership, count fallback, streak policy (current/longest/duplicate/gap/recovery/7-day), service-ingest projector behavior. - typecheck: 80 pre-existing errors unchanged, 0 new.
This commit is contained in:
+4
-4
@@ -39,10 +39,10 @@ Use the Q Score service when the user wants to:
|
||||
Default launch signals to ingest when detailed service results are not yet available:
|
||||
- `resume.uploaded`
|
||||
- `resume.ats_compatibility`
|
||||
- `interview.completed`
|
||||
- `roleplay.completed`
|
||||
- `goal.clarity`
|
||||
- `profile.role_fit`
|
||||
- `interview.sessions_completed`
|
||||
- `roleplay.scenarios_completed`
|
||||
- `onboarding.goals_clarity`
|
||||
- `onboarding.profile_completeness`
|
||||
|
||||
When richer product outputs are available, prefer real scores, completion states, assignment outcomes, review feedback, timestamps, and target-role context over generic defaults.
|
||||
|
||||
|
||||
Generated
+6394
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
|
||||
/**
|
||||
* Test: Completed interview and roleplay events MUST emit at least one signal
|
||||
* even when session_count/scenario_count is absent. A completion event with no
|
||||
* count and no rubric data should still produce a single-completion signal.
|
||||
*/
|
||||
|
||||
function event(overrides: Partial<GrowEventRow> & { type: string; source: string; payload: Record<string, unknown> }): GrowEventRow {
|
||||
return {
|
||||
id: `event-${overrides.type}`,
|
||||
userId: "user_test",
|
||||
orgId: null,
|
||||
source: overrides.source,
|
||||
type: overrides.type,
|
||||
category: "service",
|
||||
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
|
||||
mission: overrides.mission ?? null,
|
||||
subject: overrides.subject ?? null,
|
||||
correlation: overrides.correlation ?? null,
|
||||
payload: overrides.payload,
|
||||
raw: {},
|
||||
dedupeKey: null,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Interview completed with NO count and NO rubric ──────────────────────────
|
||||
const interviewSignals = extractQscoreSignals(event({
|
||||
source: "interview-service",
|
||||
type: "interview.session.completed",
|
||||
payload: {}, // nothing — just a bare completion event
|
||||
}));
|
||||
|
||||
assert.ok(
|
||||
interviewSignals.length > 0,
|
||||
"Interview completed event with no count/rubric must emit at least one signal",
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
interviewSignals.some((s) => s.signalId === "interview.sessions_completed" && s.score > 0),
|
||||
"Bare interview completion should emit interview.sessions_completed with positive score",
|
||||
);
|
||||
|
||||
// ── Roleplay completed with NO count and NO rubric ───────────────────────────
|
||||
const roleplaySignals = extractQscoreSignals(event({
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.scenario.completed",
|
||||
payload: {}, // nothing — just a bare completion event
|
||||
}));
|
||||
|
||||
assert.ok(
|
||||
roleplaySignals.length > 0,
|
||||
"Roleplay completed event with no count/rubric must emit at least one signal",
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
roleplaySignals.some((s) => s.signalId === "roleplay.scenarios_completed" && s.score > 0),
|
||||
"Bare roleplay completion should emit roleplay.scenarios_completed with positive score",
|
||||
);
|
||||
|
||||
console.log("count-fallback tests passed");
|
||||
process.exit(0);
|
||||
@@ -72,8 +72,8 @@ const generatedSignals = extractQscoreSignals(event({
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
generatedSignals.some((signal) => signal.signalId === "market.matches" && signal.score > 0),
|
||||
"generated matches should produce a market opportunity signal from match counts",
|
||||
generatedSignals.some((signal) => signal.signalId === "matching.match_rate" && signal.score > 0),
|
||||
"generated matches should produce a match rate signal from match counts",
|
||||
);
|
||||
|
||||
const savedSignals = extractQscoreSignals(event({
|
||||
@@ -85,10 +85,7 @@ const savedSignals = extractQscoreSignals(event({
|
||||
result: { status: "completed" },
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
savedSignals.some((signal) => signal.signalId === "networking.opportunities" && signal.score > 0),
|
||||
"saved opportunities should produce an opportunity engagement signal without numeric match score",
|
||||
);
|
||||
assert.equal(savedSignals.length, 0, "saved opportunities no longer emit a legacy signal");
|
||||
|
||||
const reviewed = buildMatchmakingGatewayEvent({
|
||||
userId: "user_test",
|
||||
@@ -107,12 +104,13 @@ const appliedSignals = extractQscoreSignals(event({
|
||||
action: "mark_applied",
|
||||
opportunityId: "job_123",
|
||||
curatorTaskId: "task_123",
|
||||
applications_submitted: 1,
|
||||
result: { status: "completed" },
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
appliedSignals.some((signal) => signal.signalId === "networking.opportunities" && signal.score >= 80),
|
||||
"applied opportunities should produce a stronger opportunity signal without numeric match score",
|
||||
appliedSignals.some((signal) => signal.signalId === "matching.applications_submitted" && signal.score > 0),
|
||||
"applied opportunities should produce an applications submitted signal",
|
||||
);
|
||||
|
||||
console.log("matchmaking-events tests passed");
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveRedisConfig } from "../src/config.js";
|
||||
|
||||
/**
|
||||
* Test: Redis ingestion consumers are default-off and do not load the Redis
|
||||
* module unless an explicit opt-in flag is set. Legacy URLs must not inherit
|
||||
* from the generic REDIS_URL.
|
||||
*
|
||||
* Uses the pure resolveRedisConfig resolver with synthetic env objects — no
|
||||
* module-cache hacks needed.
|
||||
*/
|
||||
|
||||
// ── 1. Default-off: no Redis flags set ──────────────────────────────────────
|
||||
{
|
||||
const rc = resolveRedisConfig({});
|
||||
|
||||
assert.equal(
|
||||
rc.growEventsRedisEnabled,
|
||||
false,
|
||||
"GROW_EVENTS_REDIS_ENABLED must default to false",
|
||||
);
|
||||
assert.equal(
|
||||
rc.legacyServiceRedisEnabled,
|
||||
false,
|
||||
"LEGACY_SERVICE_REDIS_ENABLED must default to false",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. Legacy URLs must NOT inherit from generic REDIS_URL ──────────────────
|
||||
{
|
||||
// Set ONLY the generic REDIS_URL — legacy URLs must not pick it up.
|
||||
const rc = resolveRedisConfig({ REDIS_URL: "redis://legacy-generic:6379" });
|
||||
|
||||
assert.equal(
|
||||
rc.interviewRedisUrl,
|
||||
"",
|
||||
"interviewRedisUrl must NOT inherit from generic REDIS_URL",
|
||||
);
|
||||
assert.equal(
|
||||
rc.roleplayRedisUrl,
|
||||
"",
|
||||
"roleplayRedisUrl must NOT inherit from generic REDIS_URL",
|
||||
);
|
||||
assert.equal(
|
||||
rc.resumeRedisUrl,
|
||||
"",
|
||||
"resumeRedisUrl must NOT inherit from generic REDIS_URL",
|
||||
);
|
||||
assert.equal(
|
||||
rc.coursesRedisUrl,
|
||||
"",
|
||||
"coursesRedisUrl must NOT inherit from generic REDIS_URL",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Legacy URLs must NOT inherit from GROW_EVENTS_REDIS_URL ───────────────
|
||||
{
|
||||
const rc = resolveRedisConfig({ GROW_EVENTS_REDIS_URL: "redis://canonical:6379" });
|
||||
|
||||
assert.equal(
|
||||
rc.interviewRedisUrl,
|
||||
"",
|
||||
"interviewRedisUrl must NOT inherit from GROW_EVENTS_REDIS_URL",
|
||||
);
|
||||
assert.equal(rc.growEventsRedisUrl, "redis://canonical:6379", "canonical URL resolves from its own var");
|
||||
}
|
||||
|
||||
// ── 4. Explicit opt-in flags are respected ──────────────────────────────────
|
||||
{
|
||||
const rc = resolveRedisConfig({ GROW_EVENTS_REDIS_ENABLED: "true" });
|
||||
|
||||
assert.equal(
|
||||
rc.growEventsRedisEnabled,
|
||||
true,
|
||||
"GROW_EVENTS_REDIS_ENABLED=true must be respected",
|
||||
);
|
||||
assert.equal(
|
||||
rc.legacyServiceRedisEnabled,
|
||||
false,
|
||||
"legacy must stay off when only canonical flag is set",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 5. Legacy opt-in flag works independently ───────────────────────────────
|
||||
{
|
||||
const rc = resolveRedisConfig({
|
||||
LEGACY_SERVICE_REDIS_ENABLED: "true",
|
||||
INTERVIEW_REDIS_URL: "redis://interview:6379",
|
||||
});
|
||||
|
||||
assert.equal(rc.legacyServiceRedisEnabled, true, "LEGACY_SERVICE_REDIS_ENABLED=true must be respected");
|
||||
assert.equal(rc.growEventsRedisEnabled, false, "canonical must stay off when only legacy flag is set");
|
||||
assert.equal(rc.interviewRedisUrl, "redis://interview:6379", "explicit interview URL resolves");
|
||||
}
|
||||
|
||||
// ── 6. Explicit legacy URLs resolve from their own vars ──────────────────────
|
||||
{
|
||||
const rc = resolveRedisConfig({
|
||||
INTERVIEW_REDIS_URL: "redis://i:6379",
|
||||
ROLEPLAY_REDIS_URL: "redis://r:6379",
|
||||
RESUME_REDIS_URL: "redis://re:6379",
|
||||
COURSES_REDIS_URL: "redis://c:6379",
|
||||
});
|
||||
|
||||
assert.equal(rc.interviewRedisUrl, "redis://i:6379");
|
||||
assert.equal(rc.roleplayRedisUrl, "redis://r:6379");
|
||||
assert.equal(rc.resumeRedisUrl, "redis://re:6379");
|
||||
assert.equal(rc.coursesRedisUrl, "redis://c:6379");
|
||||
}
|
||||
|
||||
console.log("redis-gating tests passed");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
|
||||
/**
|
||||
* Test: Service-ingest projector behavior across multiple service sources.
|
||||
* Verifies that canonical Grow events from different services produce
|
||||
* registry-valid signals with correct source attribution.
|
||||
*/
|
||||
|
||||
function event(overrides: Partial<GrowEventRow> & { type: string; source: string; payload: Record<string, unknown> }): GrowEventRow {
|
||||
return {
|
||||
id: `event-${overrides.type}`,
|
||||
userId: "user_test",
|
||||
orgId: null,
|
||||
source: overrides.source,
|
||||
type: overrides.type,
|
||||
category: "service",
|
||||
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
|
||||
mission: overrides.mission ?? null,
|
||||
subject: overrides.subject ?? null,
|
||||
correlation: overrides.correlation ?? null,
|
||||
payload: overrides.payload,
|
||||
raw: {},
|
||||
dedupeKey: null,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 1. Resume analysis produces resume signals ──────────────────────────────
|
||||
{
|
||||
const signals = extractQscoreSignals(event({
|
||||
source: "resume-builder",
|
||||
type: "resume.analysis.completed",
|
||||
payload: { analysis: { score_breakdown: [{ category: "ATS Compatibility", score: 75 }] } },
|
||||
}));
|
||||
assert.ok(signals.length > 0, "resume analysis event should produce signals");
|
||||
assert.ok(
|
||||
signals.some((s) => s.signalId.startsWith("resume.")),
|
||||
"resume source should produce resume.* signals",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. Generic scored service event (qscore source) produces completion score ─
|
||||
{
|
||||
const signals = extractQscoreSignals(event({
|
||||
source: "qscore-service",
|
||||
type: "qscore.signal.projected",
|
||||
payload: { score: 65 },
|
||||
}));
|
||||
assert.ok(
|
||||
signals.some((s) => s.score === 65),
|
||||
"qscore source event with score should forward the score",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Social branding pre-computed signals forwarded as-is ─────────────────
|
||||
{
|
||||
const signals = extractQscoreSignals(event({
|
||||
source: "social-branding-service",
|
||||
type: "brand.profile.updated",
|
||||
payload: {
|
||||
qscore_signals: [
|
||||
{ signalId: "linkedin.headline_quality", score: 70 },
|
||||
{ signalId: "linkedin.summary_complete", score: 65 },
|
||||
],
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
signals.some((s) => s.signalId === "linkedin.headline_quality" && s.score === 70),
|
||||
"social branding should forward pre-computed signals as-is",
|
||||
);
|
||||
assert.ok(
|
||||
signals.some((s) => s.signalId === "linkedin.summary_complete"),
|
||||
"social branding should forward all pre-computed signals",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Unknown source with no score produces no signals ──────────────────────
|
||||
{
|
||||
const signals = extractQscoreSignals(event({
|
||||
source: "unknown-service",
|
||||
type: "service.completed",
|
||||
payload: {},
|
||||
}));
|
||||
assert.equal(signals.length, 0, "unknown source with no score should produce no signals");
|
||||
}
|
||||
|
||||
// ── 5. Matchmaking applied event with count ──────────────────────────────────
|
||||
{
|
||||
const signals = extractQscoreSignals(event({
|
||||
source: "matchmaking-v2",
|
||||
type: "matchmaking.match.applied",
|
||||
payload: { applications_submitted: 3 },
|
||||
}));
|
||||
assert.ok(
|
||||
signals.some((s) => s.signalId === "matching.applications_submitted" && s.score > 0),
|
||||
"matchmaking applied with count should produce applications_submitted signal",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("service-ingest-projector tests passed");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,238 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
|
||||
import { ONBOARDING_BASELINE_SIGNAL_ID } from "../src/events/onboarding-qscore.js";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
|
||||
/**
|
||||
* Test: All signal IDs emitted by the projector must be valid members of the
|
||||
* qscore_service v2 signal registry. The onboarding baseline signal must be
|
||||
* "onboarding.completed" (not the non-registry "onboarding.completed_baseline").
|
||||
*/
|
||||
|
||||
// v2 registry — union of all signal_ids across all 7 profession formula JSONs.
|
||||
// Sourced from qscore_service/app/scoring/formula/v2/*/formula.json
|
||||
const V2_REGISTRY = new Set<string>([
|
||||
// resume
|
||||
"resume.uploaded",
|
||||
"resume.ats_compatibility",
|
||||
"resume.keyword_relevance",
|
||||
"resume.quantified_achievements",
|
||||
"resume.grammar_clarity",
|
||||
"resume.format_structure",
|
||||
"resume.contact_info",
|
||||
"resume.page_count",
|
||||
// interview
|
||||
"interview.sessions_completed",
|
||||
"interview.overall_score",
|
||||
"interview.response_clarity",
|
||||
"interview.technical_accuracy",
|
||||
"interview.behavioral_quality",
|
||||
"interview.improvement_over_time",
|
||||
"interview.type_diversity",
|
||||
// roleplay
|
||||
"roleplay.scenarios_completed",
|
||||
"roleplay.situational_judgment",
|
||||
"roleplay.empathy_demonstrated",
|
||||
"roleplay.problem_resolution",
|
||||
"roleplay.communication_effectiveness",
|
||||
// courses
|
||||
"courses.started",
|
||||
"courses.completed",
|
||||
"courses.completion_rate",
|
||||
"courses.difficulty",
|
||||
"courses.pathway_relevance",
|
||||
// matching
|
||||
"matching.jobs_viewed",
|
||||
"matching.applications_submitted",
|
||||
"matching.application_quality",
|
||||
"matching.match_rate",
|
||||
// onboarding
|
||||
"onboarding.completed",
|
||||
"onboarding.initial_skills_score",
|
||||
"onboarding.goals_clarity",
|
||||
"onboarding.profile_completeness",
|
||||
"onboarding.self_assessment_accuracy",
|
||||
"onboarding.motivation_quality",
|
||||
// coverletter
|
||||
"coverletter.uploaded",
|
||||
"coverletter.customization",
|
||||
"coverletter.persuasiveness",
|
||||
// linkedin
|
||||
"linkedin.account_connected",
|
||||
"linkedin.profile_photo",
|
||||
"linkedin.headline_quality",
|
||||
"linkedin.summary_complete",
|
||||
"linkedin.experience_detail",
|
||||
"linkedin.skills_listed",
|
||||
// portfolio
|
||||
"portfolio.website_exists",
|
||||
"portfolio.website_quality",
|
||||
"portfolio.content_relevance",
|
||||
// content
|
||||
"content.articles_count",
|
||||
"content.quality",
|
||||
"content.platform_credibility",
|
||||
// ugc
|
||||
"ugc.speaking_count",
|
||||
"ugc.event_credibility",
|
||||
"ugc.verifiable_evidence",
|
||||
// assessments
|
||||
"assessments.taken",
|
||||
"assessments.passed",
|
||||
"assessments.avg_score",
|
||||
"assessments.skill_diversity",
|
||||
// presentations
|
||||
"presentations.submitted",
|
||||
"presentations.content_quality",
|
||||
"presentations.delivery_score",
|
||||
"presentations.visual_aids",
|
||||
// events
|
||||
"events.webinars_attended",
|
||||
"events.meetups_attended",
|
||||
"events.participation_quality",
|
||||
// mentor
|
||||
"mentor.feedback_count",
|
||||
"mentor.score",
|
||||
"mentor.implementation_rate",
|
||||
]);
|
||||
|
||||
function event(overrides: Partial<GrowEventRow> & { type: string; payload: Record<string, unknown>; source: string }): GrowEventRow {
|
||||
return {
|
||||
id: `event-${overrides.type}`,
|
||||
userId: "user_test",
|
||||
orgId: null,
|
||||
source: overrides.source,
|
||||
type: overrides.type,
|
||||
category: "service",
|
||||
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
|
||||
mission: overrides.mission ?? null,
|
||||
subject: overrides.subject ?? null,
|
||||
correlation: overrides.correlation ?? null,
|
||||
payload: overrides.payload,
|
||||
raw: {},
|
||||
dedupeKey: null,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 1. Onboarding signal ID must be the registry-valid "onboarding.completed" ─
|
||||
assert.equal(
|
||||
ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
"onboarding.completed",
|
||||
"Onboarding baseline signal ID must be 'onboarding.completed' (registry-valid), not 'onboarding.completed_baseline'",
|
||||
);
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(ONBOARDING_BASELINE_SIGNAL_ID),
|
||||
"Onboarding signal ID must appear in v2 registry",
|
||||
);
|
||||
|
||||
// ── 2. All projector signal IDs must be in the registry ──────────────────────
|
||||
// Construct representative events for each source family and check every emitted
|
||||
// signal ID is registry-valid.
|
||||
|
||||
// Resume with full breakdown
|
||||
const resumeSignals = extractQscoreSignals(event({
|
||||
source: "resume-builder",
|
||||
type: "resume.analysis.completed",
|
||||
payload: {
|
||||
analysis: {
|
||||
score_breakdown: [
|
||||
{ category: "ATS Compatibility", score: 70 },
|
||||
{ category: "Content Quality", score: 65 },
|
||||
{ category: "Formatting", score: 80 },
|
||||
],
|
||||
dimensional_scores: [
|
||||
{ dimension: "Keywords", score: 60 },
|
||||
{ dimension: "Quantification", score: 55 },
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
for (const s of resumeSignals) {
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(s.signalId),
|
||||
`Resume signal '${s.signalId}' is not in the v2 registry`,
|
||||
);
|
||||
}
|
||||
|
||||
// Interview with rubric
|
||||
const interviewSignals = extractQscoreSignals(event({
|
||||
source: "interview-service",
|
||||
type: "interview.session.completed",
|
||||
payload: {
|
||||
review: {
|
||||
overall_score: 72,
|
||||
rubric_scores: { content_quality: 70, role_alignment: 68, language: 65 },
|
||||
historical_comparison: { overall_delta: 5 },
|
||||
},
|
||||
session_count: 3,
|
||||
type_diversity: 2,
|
||||
},
|
||||
}));
|
||||
for (const s of interviewSignals) {
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(s.signalId),
|
||||
`Interview signal '${s.signalId}' is not in the v2 registry`,
|
||||
);
|
||||
}
|
||||
|
||||
// Roleplay with rubric
|
||||
const roleplaySignals = extractQscoreSignals(event({
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.scenario.completed",
|
||||
payload: {
|
||||
review: {
|
||||
rubric_scores: {
|
||||
scenario_adherence: 75,
|
||||
emotional_intelligence: 70,
|
||||
adaptability: 68,
|
||||
content: 72,
|
||||
},
|
||||
},
|
||||
scenario_count: 4,
|
||||
},
|
||||
}));
|
||||
for (const s of roleplaySignals) {
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(s.signalId),
|
||||
`Roleplay signal '${s.signalId}' is not in the v2 registry`,
|
||||
);
|
||||
}
|
||||
|
||||
// Courses
|
||||
const courseSignals = extractQscoreSignals(event({
|
||||
source: "courses-service",
|
||||
type: "course.completed",
|
||||
payload: {
|
||||
courseId: "c1",
|
||||
completed_count: 2,
|
||||
watchPct: 0.9,
|
||||
difficulty: "intermediate",
|
||||
label: "high relevance",
|
||||
},
|
||||
}));
|
||||
for (const s of courseSignals) {
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(s.signalId),
|
||||
`Course signal '${s.signalId}' is not in the v2 registry`,
|
||||
);
|
||||
}
|
||||
|
||||
// Matchmaking
|
||||
const matchSignals = extractQscoreSignals(event({
|
||||
source: "matchmaking-v2",
|
||||
type: "matchmaking.feed.viewed",
|
||||
payload: { jobs_viewed: 25 },
|
||||
}));
|
||||
for (const s of matchSignals) {
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(s.signalId),
|
||||
`Matchmaking signal '${s.signalId}' is not in the v2 registry`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("signal-registry tests passed");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { computeStreakFromDays } from "../src/v1/curator/streak-utils.js";
|
||||
|
||||
/**
|
||||
* Test: Streak policies — current/longest, same-day duplicate, gap reset,
|
||||
* recovery (resume after gap), and seven-day eligibility.
|
||||
*
|
||||
* Uses the pure computeStreakFromDays function with a fixed "today" so tests
|
||||
* are deterministic.
|
||||
*/
|
||||
|
||||
const TODAY = "2026-07-10";
|
||||
|
||||
// ── 1. Current and longest from a contiguous run ending today ────────────────
|
||||
{
|
||||
const days = ["2026-07-10", "2026-07-09", "2026-07-08"];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current, 3, "current streak should be 3 for 3 consecutive days ending today");
|
||||
assert.equal(streak.longest, 3, "longest should match current when run is unbroken");
|
||||
assert.equal(streak.lastCompletedDate, "2026-07-10");
|
||||
}
|
||||
|
||||
// ── 2. Same-day duplicate must not inflate the streak ────────────────────────
|
||||
// computeStreakFromDays requires deduplicated days (the SQL GROUP BY ensures
|
||||
// this in production). Passing a duplicate should not double-count.
|
||||
{
|
||||
const days = ["2026-07-10", "2026-07-10", "2026-07-09"];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
// With a duplicate, the descending list has two "today" entries. The while
|
||||
// loop checks `days.includes(cursor)` which is set-based, so current stays 2.
|
||||
// But the longest loop iterates ALL entries including the dup, which could
|
||||
// inflate longest to 3. The correct behavior: duplicates must not inflate.
|
||||
assert.equal(streak.current, 2, "duplicate day must not inflate current streak");
|
||||
assert.equal(
|
||||
streak.longest,
|
||||
2,
|
||||
"duplicate day must not inflate longest streak — duplicates should be deduped before counting",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Gap resets the current streak ─────────────────────────────────────────
|
||||
{
|
||||
// Completed today and 3 days ago — gap breaks current streak
|
||||
const days = ["2026-07-10", "2026-07-07"];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current, 1, "gap resets current streak to 1 (only today)");
|
||||
assert.equal(streak.longest, 1, "longest should also be 1 with only isolated days");
|
||||
}
|
||||
|
||||
// ── 4. Recovery: longest captures a past longer run even if current is broken ─
|
||||
{
|
||||
// 5-day run last week, then a gap, then completed today
|
||||
const days = [
|
||||
"2026-07-10", // today (current restart)
|
||||
"2026-07-05", // past run: Jul 1–5
|
||||
"2026-07-04",
|
||||
"2026-07-03",
|
||||
"2026-07-02",
|
||||
"2026-07-01",
|
||||
];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current, 1, "current is 1 after a gap even if a longer past run exists");
|
||||
assert.equal(streak.longest, 5, "longest should capture the 5-day past run");
|
||||
}
|
||||
|
||||
// ── 5. Seven-day eligibility: a 7-day streak counts as eligible ──────────────
|
||||
{
|
||||
const days = [
|
||||
"2026-07-10", "2026-07-09", "2026-07-08",
|
||||
"2026-07-07", "2026-07-06", "2026-07-05", "2026-07-04",
|
||||
];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current, 7, "7 consecutive days should yield current=7");
|
||||
assert.equal(streak.longest, 7, "7 consecutive days should yield longest=7");
|
||||
assert.ok(streak.current >= 7, "7-day streak eligibility threshold met");
|
||||
}
|
||||
|
||||
// ── 6. Empty days yields zero streak ─────────────────────────────────────────
|
||||
{
|
||||
const streak = computeStreakFromDays([], TODAY);
|
||||
assert.equal(streak.current, 0);
|
||||
assert.equal(streak.longest, 0);
|
||||
assert.equal(streak.lastCompletedDate, null);
|
||||
}
|
||||
|
||||
// ── 7. Not completed today but completed yesterday: current is 0 ─────────────
|
||||
{
|
||||
const days = ["2026-07-09"];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current, 0, "current is 0 if today is not completed");
|
||||
assert.equal(streak.longest, 1, "longest should still count the 1-day run");
|
||||
}
|
||||
|
||||
console.log("streak-policy tests passed");
|
||||
process.exit(0);
|
||||
+48
-6
@@ -7,7 +7,41 @@ function required(name: string, fallback?: string): string {
|
||||
}
|
||||
return v;
|
||||
}
|
||||
function parseBool(value: string | undefined, fallback: boolean): boolean {
|
||||
if (value === undefined || value === "") return fallback;
|
||||
return value.toLowerCase() === "true" || value === "1";
|
||||
}
|
||||
|
||||
|
||||
// ── Redis config resolver (pure, testable without module reload) ────────────
|
||||
// Exported so tests can assert gating/URL-inheritance behavior with synthetic
|
||||
// env objects, avoiding ESM module-cache hacks.
|
||||
export type RedisConfig = {
|
||||
growEventsRedisEnabled: boolean;
|
||||
legacyServiceRedisEnabled: boolean;
|
||||
growEventsRedisUrl: string;
|
||||
interviewRedisUrl: string;
|
||||
roleplayRedisUrl: string;
|
||||
resumeRedisUrl: string;
|
||||
coursesRedisUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve all Redis-related config from an env record. Both ingestion paths
|
||||
* are default-off; legacy URLs resolve ONLY from their own explicit env var
|
||||
* (never inherited from GROW_EVENTS_REDIS_URL or the generic REDIS_URL).
|
||||
*/
|
||||
export function resolveRedisConfig(env: Record<string, string | undefined>): RedisConfig {
|
||||
return {
|
||||
growEventsRedisEnabled: parseBool(env.GROW_EVENTS_REDIS_ENABLED, false),
|
||||
legacyServiceRedisEnabled: parseBool(env.LEGACY_SERVICE_REDIS_ENABLED, false),
|
||||
growEventsRedisUrl: env.GROW_EVENTS_REDIS_URL ?? "",
|
||||
interviewRedisUrl: env.INTERVIEW_REDIS_URL ?? "",
|
||||
roleplayRedisUrl: env.ROLEPLAY_REDIS_URL ?? "",
|
||||
resumeRedisUrl: env.RESUME_REDIS_URL ?? "",
|
||||
coursesRedisUrl: env.COURSES_REDIS_URL ?? env.COURSE_REDIS_URL ?? "",
|
||||
};
|
||||
}
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 4000),
|
||||
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||
@@ -26,17 +60,24 @@ export const config = {
|
||||
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
|
||||
|
||||
// Service → backend event stream. Redis is optional; HTTP /events/ingest/service is always available.
|
||||
growEventsRedisUrl: process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
// Explicit opt-in flags: both Redis ingestion paths are default-off. The REST
|
||||
// production path never touches Redis. Each flag is independent so operators
|
||||
// can enable canonical and/or legacy observers separately.
|
||||
growEventsRedisEnabled: parseBool(process.env.GROW_EVENTS_REDIS_ENABLED, false),
|
||||
legacyServiceRedisEnabled: parseBool(process.env.LEGACY_SERVICE_REDIS_ENABLED, false),
|
||||
growEventsRedisUrl: process.env.GROW_EVENTS_REDIS_URL ?? "",
|
||||
growEventsStream: process.env.GROW_EVENTS_STREAM ?? "grow.events.raw",
|
||||
growEventsConsumerGroup: process.env.GROW_EVENTS_CONSUMER_GROUP ?? "growqr-backend",
|
||||
growEventsConsumerName: process.env.GROW_EVENTS_CONSUMER_NAME ?? `backend-${process.pid}`,
|
||||
|
||||
// Legacy service Redis surfaces. These let backend observe existing service A2A traffic
|
||||
// without changing the services. Defaults fall back to GROW_EVENTS_REDIS_URL/REDIS_URL.
|
||||
interviewRedisUrl: process.env.INTERVIEW_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
roleplayRedisUrl: process.env.ROLEPLAY_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
resumeRedisUrl: process.env.RESUME_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
coursesRedisUrl: process.env.COURSES_REDIS_URL ?? process.env.COURSE_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
|
||||
// without changing the services. Legacy URLs resolve ONLY from their own explicit env
|
||||
// var — they must not inherit from GROW_EVENTS_REDIS_URL or the generic REDIS_URL, so a
|
||||
// stray REDIS_URL can never silently activate the legacy observer path.
|
||||
interviewRedisUrl: process.env.INTERVIEW_REDIS_URL ?? "",
|
||||
roleplayRedisUrl: process.env.ROLEPLAY_REDIS_URL ?? "",
|
||||
resumeRedisUrl: process.env.RESUME_REDIS_URL ?? "",
|
||||
coursesRedisUrl: process.env.COURSES_REDIS_URL ?? process.env.COURSE_REDIS_URL ?? "",
|
||||
legacyServiceTaskObserverGroup: process.env.LEGACY_SERVICE_TASK_OBSERVER_GROUP ?? "growqr-backend-observer",
|
||||
|
||||
// LLM gateway for the unified user agent.
|
||||
@@ -156,3 +197,4 @@ export const config = {
|
||||
|
||||
required, // exported so other modules can fail fast on boot
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { forwardSignalsToQscoreService } from "../services/service-agents.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID } from "../services/qscore-proxy.js";
|
||||
|
||||
export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed_baseline";
|
||||
export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed";
|
||||
|
||||
/**
|
||||
* Workbook threshold for a completed onboarding baseline signal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type GrowEventRow } from "../../db/schema.js";
|
||||
import { asRecord, clampScore, getNumber, type QscoreSignal } from "../envelope.js";
|
||||
import { asRecord, clampScore, getNumber, getString, type QscoreSignal } from "../envelope.js";
|
||||
import { forwardSignalsToQscoreService } from "../../services/service-agents.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID } from "../../services/qscore-proxy.js";
|
||||
|
||||
@@ -51,7 +51,6 @@ function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const keywords = byDimension.get("Keywords") ?? nestedNumber(analysis, ["keyword_score", "keyword_relevance", "keywords"]);
|
||||
if (keywords !== undefined) {
|
||||
signals.push(signal("resume.keyword_relevance", keywords, { eventId: event.id }));
|
||||
signals.push(signal("resume.technical_keywords", keywords, { eventId: event.id }));
|
||||
}
|
||||
const quantification = byDimension.get("Quantification") ?? nestedNumber(analysis, ["quantification_score"]);
|
||||
if (quantification !== undefined) signals.push(signal("resume.quantified_achievements", quantification, { eventId: event.id }));
|
||||
@@ -59,14 +58,29 @@ function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
if (contentQuality !== undefined) signals.push(signal("resume.grammar_clarity", contentQuality, { eventId: event.id }));
|
||||
const formatting = byCategory.get("Formatting") ?? nestedNumber(analysis, ["formatting", "format_score"]);
|
||||
if (formatting !== undefined) signals.push(signal("resume.format_structure", formatting, { eventId: event.id }));
|
||||
const impact = byCategory.get("Impact Demonstration") ?? nestedNumber(analysis, ["impact_score"]);
|
||||
if (impact !== undefined) {
|
||||
signals.push(signal("resume.leadership_indicators", impact, { eventId: event.id }));
|
||||
signals.push(signal("resume.impact_statements", impact, { eventId: event.id }));
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
|
||||
function interviewSessionsScore(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
if (count <= 2) return 20;
|
||||
if (count <= 5) return 35;
|
||||
return 50;
|
||||
}
|
||||
|
||||
function interviewDiversityScore(count: number): number {
|
||||
if (count <= 1) return 10;
|
||||
if (count === 2) return 20;
|
||||
return 30;
|
||||
}
|
||||
|
||||
function roleplayScenariosScore(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
if (count <= 3) return 20;
|
||||
if (count <= 6) return 35;
|
||||
return 50;
|
||||
}
|
||||
|
||||
function extractInterviewSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
@@ -74,7 +88,14 @@ function extractInterviewSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
signals.push(signal("interview.completed", 100, { eventId: event.id }));
|
||||
// A completed interview must always emit at least a single-completion signal.
|
||||
// When session_count is absent, default to 1 so the event is not silently lost.
|
||||
const sessionCount = getNumber(payload.session_count ?? payload.sessionCount) ?? 1;
|
||||
signals.push(signal("interview.sessions_completed", interviewSessionsScore(sessionCount), { eventId: event.id, sessionCount }));
|
||||
const typeDiversity = getNumber(payload.type_diversity ?? payload.typeDiversity);
|
||||
if (typeDiversity !== undefined) {
|
||||
signals.push(signal("interview.type_diversity", interviewDiversityScore(typeDiversity), { eventId: event.id, typeDiversity }));
|
||||
}
|
||||
const overall = getNumber(review.overall_score ?? review.overallScore ?? payload.overall_score);
|
||||
if (overall !== undefined) signals.push(signal("interview.overall_score", overall, { eventId: event.id }));
|
||||
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
|
||||
@@ -97,7 +118,10 @@ function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
|
||||
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
signals.push(signal("roleplay.completed", 100, { eventId: event.id }));
|
||||
// A completed roleplay must always emit at least a single-completion signal.
|
||||
// When scenario_count is absent, default to 1 so the event is not silently lost.
|
||||
const scenarioCount = getNumber(payload.scenario_count ?? payload.scenarioCount) ?? 1;
|
||||
signals.push(signal("roleplay.scenarios_completed", roleplayScenariosScore(scenarioCount), { eventId: event.id, scenarioCount }));
|
||||
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
|
||||
const scenario = getNumber(rubric.scenario_adherence ?? review.scenario_adherence_score);
|
||||
if (scenario !== undefined) signals.push(signal("roleplay.situational_judgment", scenario, { eventId: event.id }));
|
||||
@@ -107,32 +131,76 @@ function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
|
||||
if (adaptability !== undefined) signals.push(signal("roleplay.problem_resolution", adaptability, { eventId: event.id }));
|
||||
const communication = getNumber(rubric.content ?? rubric.communication ?? review.communication_score);
|
||||
if (communication !== undefined) signals.push(signal("roleplay.communication_effectiveness", communication, { eventId: event.id }));
|
||||
const historical = asRecord(review.historical_comparison ?? review.historicalComparison);
|
||||
const delta = getNumber(historical.overall_delta ?? historical.overallDelta);
|
||||
if (delta !== undefined) signals.push(signal("roleplay.improvement_over_time", 50 + delta * 2.5, { eventId: event.id, delta }));
|
||||
return signals;
|
||||
}
|
||||
|
||||
|
||||
function coursesCompletedScore(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
if (count <= 3) return 35;
|
||||
if (count <= 10) return 60;
|
||||
return 80;
|
||||
}
|
||||
|
||||
function courseCompletionRateScore(pct: number): number {
|
||||
if (pct < 50) return 20;
|
||||
if (pct <= 80) return 40;
|
||||
return 60;
|
||||
}
|
||||
|
||||
function courseDifficultyScore(difficulty: string): number | undefined {
|
||||
const d = difficulty.toLowerCase();
|
||||
if (d.includes("beginner") || d === "easy") return 20;
|
||||
if (d.includes("inter") || d.includes("medium")) return 35;
|
||||
if (d.includes("adv")) return 50;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function coursePathwayRelevanceScore(label: string): number | undefined {
|
||||
const l = label.toLowerCase();
|
||||
if (l.includes("high")) return 40;
|
||||
if (l.includes("med")) return 25;
|
||||
if (l.includes("low")) return 10;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractCourseSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
if (!event.type.includes("progress_recorded") && !event.type.includes("completed")) return [];
|
||||
if (
|
||||
!event.type.includes("progress_recorded") &&
|
||||
!event.type.includes("completed") &&
|
||||
!event.type.includes("started") &&
|
||||
!event.type.includes("watch")
|
||||
)
|
||||
return [];
|
||||
|
||||
const watchPct = getNumber(payload.watchPct ?? payload.watch_pct);
|
||||
const progressPercentage = getNumber(payload.progressPercentage ?? payload.progress_percentage);
|
||||
const progressScore = progressPercentage ?? (watchPct === undefined ? undefined : watchPct * 100);
|
||||
const score = clampScore(Math.max(35, Math.min(100, progressScore ?? 50)));
|
||||
const watchPctRaw = getNumber(payload.watchPct ?? payload.watch_pct) ?? 0;
|
||||
const watchPctScaled = watchPctRaw <= 1 ? watchPctRaw * 100 : watchPctRaw;
|
||||
const completedCount = getNumber(payload.completed_count ?? payload.completedCount ?? payload.courses_completed);
|
||||
const difficulty = getString(payload.difficulty);
|
||||
const label = getString(payload.label ?? payload.pathway_label);
|
||||
const raw = {
|
||||
eventId: event.id,
|
||||
taskId: payload.taskId ?? payload.curatorTaskId ?? event.correlation?.taskId,
|
||||
courseId: payload.courseId ?? payload.course_id ?? event.correlation?.courseId,
|
||||
lessonId: payload.lessonId ?? payload.lesson_id ?? event.correlation?.lessonId,
|
||||
watchPct,
|
||||
courseId: payload.courseId ?? payload.course_id,
|
||||
lessonId: payload.lessonId ?? payload.lesson_id,
|
||||
watchPct: watchPctRaw,
|
||||
};
|
||||
|
||||
return [
|
||||
signal("skills.learning", score, raw),
|
||||
signal("readiness.course_progress", score, { ...raw, source: event.source, type: event.type }),
|
||||
];
|
||||
const signals: QscoreSignal[] = [];
|
||||
signals.push(signal("courses.started", 40, raw));
|
||||
signals.push(signal("courses.completion_rate", courseCompletionRateScore(watchPctScaled), { ...raw, completionPct: watchPctScaled }));
|
||||
if (watchPctScaled >= 80) {
|
||||
signals.push(signal("courses.completed", coursesCompletedScore(completedCount ?? 1), { ...raw, completedCount: completedCount ?? 1 }));
|
||||
}
|
||||
if (difficulty) {
|
||||
const diffScore = courseDifficultyScore(difficulty);
|
||||
if (diffScore !== undefined) signals.push(signal("courses.difficulty", diffScore, { ...raw, difficulty }));
|
||||
}
|
||||
if (label) {
|
||||
const relScore = coursePathwayRelevanceScore(label);
|
||||
if (relScore !== undefined) signals.push(signal("courses.pathway_relevance", relScore, { ...raw, label }));
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
|
||||
function sourceSignalPrefix(source: string) {
|
||||
@@ -191,24 +259,31 @@ function countFromPayload(payload: Record<string, unknown>): number | undefined
|
||||
return requestOpportunities?.length;
|
||||
}
|
||||
|
||||
function opportunityActionScore(event: GrowEventRow, count?: number): number | undefined {
|
||||
if (event.type === "matchmaking.matches.generated") return clampScore(58 + Math.min(count ?? 0, 20));
|
||||
if (event.type === "matchmaking.feed.viewed") return count && count > 0 ? 58 : 48;
|
||||
if (event.type === "matchmaking.match.viewed") return 62;
|
||||
if (event.type === "matchmaking.match.saved") return 72;
|
||||
if (event.type === "matchmaking.matches.reviewed") return 76;
|
||||
if (event.type === "matchmaking.match.applied") return 86;
|
||||
if (event.type === "matchmaking.application.completed") return 90;
|
||||
return undefined;
|
||||
|
||||
function jobsViewedScore(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
if (count <= 20) return 15;
|
||||
if (count <= 50) return 30;
|
||||
return 40;
|
||||
}
|
||||
|
||||
function applicationsSubmittedScore(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
if (count <= 5) return 20;
|
||||
if (count <= 15) return 35;
|
||||
return 50;
|
||||
}
|
||||
|
||||
function matchRateScore(pct: number): number {
|
||||
if (pct < 50) return 15;
|
||||
if (pct <= 75) return 35;
|
||||
return 50;
|
||||
}
|
||||
|
||||
function extractMatchmakingSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
if (!event.type.startsWith("matchmaking.")) return [];
|
||||
const payload = event.payload ?? {};
|
||||
const count = countFromPayload(payload);
|
||||
const score = opportunityActionScore(event, count);
|
||||
if (score === undefined) return [];
|
||||
|
||||
const raw = {
|
||||
eventId: event.id,
|
||||
type: event.type,
|
||||
@@ -219,9 +294,59 @@ function extractMatchmakingSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
status: payload.status,
|
||||
};
|
||||
|
||||
if (event.type === "matchmaking.matches.generated") return [signal("market.matches", score, raw)];
|
||||
if (event.type === "matchmaking.feed.viewed") return [signal("market.opportunity_feed_viewed", score, raw)];
|
||||
return [signal("networking.opportunities", score, raw)];
|
||||
const signals: QscoreSignal[] = [];
|
||||
|
||||
// Jobs viewed — from feed.viewed and match.viewed events with cumulative counts
|
||||
if (event.type === "matchmaking.feed.viewed" || event.type === "matchmaking.match.viewed") {
|
||||
const viewedCount = getNumber(payload.jobs_viewed ?? payload.jobsViewed ?? payload.viewed_count ?? payload.viewedCount ?? count);
|
||||
signals.push(signal("matching.jobs_viewed", jobsViewedScore(viewedCount ?? 1), { ...raw, viewedCount }));
|
||||
}
|
||||
|
||||
// Applications submitted — from match.applied and application.completed events
|
||||
if (event.type === "matchmaking.match.applied" || event.type === "matchmaking.application.completed") {
|
||||
const appliedCount = getNumber(payload.applications_submitted ?? payload.applicationsSubmitted ?? payload.applied_count ?? payload.appliedCount ?? count);
|
||||
signals.push(signal("matching.applications_submitted", applicationsSubmittedScore(appliedCount ?? 1), { ...raw, appliedCount }));
|
||||
}
|
||||
|
||||
// Application quality — from match quality scores if available
|
||||
const qualityScore = getNumber(payload.application_quality ?? payload.applicationQuality ?? payload.match_quality ?? payload.matchQuality);
|
||||
if (qualityScore !== undefined) {
|
||||
signals.push(signal("matching.application_quality", qualityScore, { ...raw, qualityScore }));
|
||||
}
|
||||
|
||||
// Match rate — from matches.generated with match data
|
||||
if (event.type === "matchmaking.matches.generated" && count !== undefined) {
|
||||
const totalCandidates = getNumber(payload.total_candidates ?? payload.totalCandidates) ?? count;
|
||||
const rate = totalCandidates > 0 ? (count / totalCandidates) * 100 : 0;
|
||||
signals.push(signal("matching.match_rate", matchRateScore(rate), { ...raw, matchRate: rate }));
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
function extractSocialSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const signals: QscoreSignal[] = [];
|
||||
// social-branding sends pre-computed qscore_signals array — forward as-is
|
||||
const incoming = Array.isArray(payload.qscore_signals) ? payload.qscore_signals : Array.isArray(payload.qscoreSignals) ? payload.qscoreSignals : undefined;
|
||||
if (incoming) {
|
||||
for (const entry of incoming) {
|
||||
const record = asRecord(entry);
|
||||
const id = getString(record.signalId ?? record.signal_id ?? record.id);
|
||||
const score = getNumber(record.score ?? record.value);
|
||||
if (id !== undefined && score !== undefined) {
|
||||
signals.push(signal(id, score, { eventId: event.id, source: event.source }));
|
||||
}
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
// Fall back to inline fields for backward compatibility
|
||||
const inline = asRecord(payload.signals);
|
||||
for (const [key, value] of Object.entries(inline)) {
|
||||
const score = getNumber(value);
|
||||
if (score !== undefined) signals.push(signal(key, score, { eventId: event.id, source: event.source }));
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
|
||||
export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
@@ -231,12 +356,10 @@ export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
if (source.includes("roleplay") || event.type.startsWith("roleplay.")) return extractRoleplaySignals(event);
|
||||
if (source.includes("course") || event.type.startsWith("course.")) return extractCourseSignals(event);
|
||||
if (source.includes("matchmaking") || event.type.startsWith("matchmaking.")) return extractMatchmakingSignals(event);
|
||||
if (source.includes("social") || source.includes("branding") || source.includes("linkedin")) return extractSocialSignals(event);
|
||||
if (source.includes("qscore") || event.type.startsWith("qscore.")) return extractScoredServiceSignals(event);
|
||||
const scoredServiceSignals = extractScoredServiceSignals(event);
|
||||
if (scoredServiceSignals.length) return scoredServiceSignals;
|
||||
if (event.type === "mission.interview_to_offer.started") {
|
||||
return [signal("goals.goals_set", 100, { eventId: event.id })];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -393,14 +393,33 @@ async function startCanonicalGrowEventStream(createClient: (opts: { url: string
|
||||
}
|
||||
|
||||
export async function startGrowEventsRedisConsumer() {
|
||||
const createClient = await loadRedisCreateClient();
|
||||
await startCanonicalGrowEventStream(createClient);
|
||||
|
||||
const specs = serviceSpecs();
|
||||
if (!specs.length) {
|
||||
log.info("legacy service Redis observers disabled (INTERVIEW_REDIS_URL/ROLEPLAY_REDIS_URL/RESUME_REDIS_URL not set)");
|
||||
// Both Redis ingestion paths are default-off. When neither flag is set, we
|
||||
// return immediately WITHOUT calling loadRedisCreateClient() — so the
|
||||
// `redis` module is never imported and no client is constructed. The REST
|
||||
// production path (POST /events/ingest/service) handles all event ingestion
|
||||
// without Redis.
|
||||
if (!config.growEventsRedisEnabled && !config.legacyServiceRedisEnabled) {
|
||||
log.info("grow events Redis consumers disabled (GROW_EVENTS_REDIS_ENABLED and LEGACY_SERVICE_REDIS_ENABLED both off)");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(specs.map((spec) => startLegacyServiceObserver(spec, createClient)));
|
||||
// At least one path is opted in — now it is safe to load the Redis module.
|
||||
const createClient = await loadRedisCreateClient();
|
||||
|
||||
if (config.growEventsRedisEnabled) {
|
||||
if (!config.growEventsRedisUrl) {
|
||||
log.warn("GROW_EVENTS_REDIS_ENABLED=true but GROW_EVENTS_REDIS_URL not set — canonical consumer skipped");
|
||||
} else {
|
||||
await startCanonicalGrowEventStream(createClient);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.legacyServiceRedisEnabled) {
|
||||
const specs = serviceSpecs();
|
||||
if (!specs.length) {
|
||||
log.info("legacy service Redis observers enabled but no service URLs configured (INTERVIEW_REDIS_URL/ROLEPLAY_REDIS_URL/RESUME_REDIS_URL not set)");
|
||||
} else {
|
||||
await Promise.all(specs.map((spec) => startLegacyServiceObserver(spec, createClient)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +89,10 @@ export async function seedDemoHome(userId: string) {
|
||||
["resume.uploaded", 100, "resume-service"],
|
||||
["resume.ats_compatibility", 86, "resume-service"],
|
||||
["resume.keyword_relevance", 84, "resume-service"],
|
||||
["interview.completed", 100, "interview-service"],
|
||||
["interview.sessions_completed", 35, "interview-service"],
|
||||
["interview.overall_score", 82, "interview-service"],
|
||||
["interview.response_clarity", 74, "interview-service"],
|
||||
["roleplay.completed", 100, "roleplay-service"],
|
||||
["roleplay.scenarios_completed", 35, "roleplay-service"],
|
||||
["roleplay.communication_effectiveness", 79, "roleplay-service"],
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -563,7 +563,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
},
|
||||
toolName: "prepare_course_handoff",
|
||||
completionEvents: ["course.generated", "course.started", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
qscoreSignals: ["skills.learning", "readiness.course_progress"],
|
||||
qscoreSignals: ["courses.started", "courses.completed", "courses.completion_rate"],
|
||||
usage: "Use for skill-gap tasks that should become learning work.",
|
||||
},
|
||||
usageDocs: ["Call buildServiceLink('courses-service', 'catalog', state) for course handoffs."],
|
||||
@@ -574,7 +574,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
configuredEvents: ["course.generated"],
|
||||
completionEvents: ["service.completed", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["course.skill_gap_closed", "skills.learning", "readiness.course_progress"],
|
||||
qscoreSignalIds: ["courses.started", "courses.completed", "courses.completion_rate", "courses.difficulty", "courses.pathway_relevance"],
|
||||
analyticsDimensions: ["course_id", "lesson_id", "progress_percentage", "weakness_tags"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
@@ -694,7 +694,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
"matchmaking.application.started",
|
||||
"matchmaking.application.completed",
|
||||
],
|
||||
qscoreSignals: ["market.matches", "networking.opportunities"],
|
||||
qscoreSignals: ["matching.jobs_viewed", "matching.applications_submitted", "matching.match_rate"],
|
||||
usage: "Use for immediate opportunity matching or mentor/network suggestions.",
|
||||
},
|
||||
usageDocs: [
|
||||
@@ -717,7 +717,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
"matchmaking.application.completed",
|
||||
],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["matchmaking.role_fit", "market.matches", "networking.opportunities"],
|
||||
qscoreSignalIds: ["matching.jobs_viewed", "matching.applications_submitted", "matching.application_quality", "matching.match_rate"],
|
||||
analyticsDimensions: ["match_count", "role_ids", "role_fit", "saved_count", "dismissed_reason"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "./task-registry.js";
|
||||
import { kpiDefinitionsForIcp } from "./kpi-registry.js";
|
||||
import { outcomeDefinitionsForIcp } from "./outcome-report-registry.js";
|
||||
import { addDaysIso, computeStreakFromDays, todayIso } from "./streak-utils.js";
|
||||
|
||||
const VALID_COMPLETION_TYPES = [
|
||||
"service.completed",
|
||||
@@ -108,15 +109,6 @@ type ServiceCurationPreviewInput = {
|
||||
userContext?: Partial<Awaited<ReturnType<typeof buildCuratorUserContext>>>;
|
||||
};
|
||||
|
||||
function todayIso(date = new Date()) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDaysIso(startDate: string, days: number) {
|
||||
const date = new Date(`${startDate}T00:00:00.000Z`);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return todayIso(date);
|
||||
}
|
||||
|
||||
function calendarDiffDays(startDate: string, endDate: string) {
|
||||
const start = new Date(`${startDate}T00:00:00.000Z`).getTime();
|
||||
@@ -1009,24 +1001,7 @@ export async function buildCuratorStreak(userId: string): Promise<CuratorStreak>
|
||||
.limit(90);
|
||||
|
||||
const days = rows.map((row) => row.day);
|
||||
let current = 0;
|
||||
let cursor = todayIso();
|
||||
while (days.includes(cursor)) {
|
||||
current += 1;
|
||||
cursor = addDaysIso(cursor, -1);
|
||||
}
|
||||
|
||||
let longest = 0;
|
||||
let run = 0;
|
||||
let previous: string | null = null;
|
||||
for (const day of [...days].reverse()) {
|
||||
if (previous && addDaysIso(previous, 1) !== day) run = 0;
|
||||
run += 1;
|
||||
longest = Math.max(longest, run);
|
||||
previous = day;
|
||||
}
|
||||
|
||||
return { current, longest: Math.max(longest, current), lastCompletedDate: days[0] ?? null };
|
||||
return computeStreakFromDays(days);
|
||||
}
|
||||
|
||||
type CuratorSprintBuildOptions = {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { CuratorStreak } from "./curator-types.js";
|
||||
|
||||
/**
|
||||
* Pure date helpers — no DB, no side effects. Shared between curator-store
|
||||
* (production) and streak-policy.test.ts (unit tests).
|
||||
*/
|
||||
export function todayIso(date = new Date()): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function addDaysIso(startDate: string, days: number): string {
|
||||
const date = new Date(`${startDate}T00:00:00.000Z`);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return todayIso(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure streak computation from a descending-sorted list of completion day
|
||||
* strings (YYYY-MM-DD). Extracted so streak policies (current/longest,
|
||||
* gap reset, recovery, seven-day eligibility) can be unit-tested without
|
||||
* a database.
|
||||
*
|
||||
* `days` MUST be sorted descending (most recent first) with no duplicates.
|
||||
* The SQL GROUP BY in buildCuratorStreak ensures dedup in production; this
|
||||
* function also deduplicates defensively so a duplicate day never inflates
|
||||
* a streak.
|
||||
*/
|
||||
export function computeStreakFromDays(
|
||||
days: string[],
|
||||
today: string = todayIso(),
|
||||
): CuratorStreak {
|
||||
// Deduplicate defensively: a repeated day string must never inflate the count.
|
||||
const uniqueDesc = [...new Set(days)];
|
||||
|
||||
let current = 0;
|
||||
let cursor = today;
|
||||
const daySet = new Set(uniqueDesc);
|
||||
while (daySet.has(cursor)) {
|
||||
current += 1;
|
||||
cursor = addDaysIso(cursor, -1);
|
||||
}
|
||||
|
||||
let longest = 0;
|
||||
let run = 0;
|
||||
let previous: string | null = null;
|
||||
for (const day of [...uniqueDesc].reverse()) {
|
||||
if (previous && addDaysIso(previous, 1) !== day) run = 0;
|
||||
run += 1;
|
||||
longest = Math.max(longest, run);
|
||||
previous = day;
|
||||
}
|
||||
|
||||
return {
|
||||
current,
|
||||
longest: Math.max(longest, current),
|
||||
lastCompletedDate: uniqueDesc[0] ?? null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user