Compare commits
46 Commits
fix/backen
...
fix/curato
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e444d1be15 | ||
|
|
fdf4f6468d | ||
|
|
8617be7e7c | ||
|
|
8cb5413766 | ||
|
|
2410a4800b | ||
|
|
f848f45429 | ||
|
|
5f5622b3a0 | ||
|
|
c7c6f1b8cc | ||
|
|
e7546142b6 | ||
|
|
78860ecf5e | ||
|
|
e50be827ca | ||
|
|
c54e12972c | ||
|
|
ad1318424b | ||
|
|
62208d385f | ||
|
|
a1de355ea0 | ||
|
|
411ad591bf | ||
|
|
63619f5047 | ||
|
|
b2283476d1 | ||
|
|
09d3da0d7a | ||
|
|
c715171aa2 | ||
|
|
f4964e5030 | ||
|
|
5f5b08bfda | ||
|
|
3f2dabd23d | ||
|
|
ac7bdca912 | ||
|
|
d3a4e63e4a | ||
|
|
722d5825bf | ||
|
|
389a9fb014 | ||
|
|
689bfb6c52 | ||
|
|
c3e7b00250 | ||
|
|
7bcd664afb | ||
|
|
64c245535f | ||
|
|
237c65f328 | ||
|
|
ee964a5d13 | ||
|
|
f61742b38f | ||
|
|
da065d1077 | ||
|
|
38c147b331 | ||
|
|
8314ffbe75 | ||
|
|
ee304b0016 | ||
|
|
0c9ddd3b25 | ||
|
|
098056458e | ||
|
|
6eeaf50423 | ||
|
|
4b0bf4379f | ||
|
|
6d996882bf | ||
|
|
82859d8d08 | ||
|
|
7b4f6f7d94 | ||
|
|
7142c8f7e8 |
@@ -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.
|
||||
|
||||
|
||||
@@ -5,5 +5,50 @@ services:
|
||||
backend:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
default:
|
||||
interview-service:
|
||||
roleplay-service:
|
||||
resume-builder-staging:
|
||||
course-service:
|
||||
qscore-service-staging:
|
||||
matchmaking-v2-staging:
|
||||
user-service-staging:
|
||||
social-branding-staging:
|
||||
environment:
|
||||
SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:8015
|
||||
DATABASE_URL: postgres://growqr:growqr@growqr-postgres:5432/growqr
|
||||
SOCIAL_BRANDING_SERVICE_URL: http://social-branding-api-1:8011
|
||||
SOCIAL_BRANDING_PUBLIC_URL: https://social-staging.gqr.puter.wtf
|
||||
PRODUCT_SERVICE_TIMEOUT_MS: 10000
|
||||
PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS: 240000
|
||||
LLM_PROVIDER: opencode
|
||||
LLM_BASE_URL: https://opencode.ai/zen/v1
|
||||
LLM_MODEL: minimax-m3
|
||||
GROW_AGENT_MODEL: minimax-m3
|
||||
CONVERSATION_ACTOR_MODEL: minimax-m3
|
||||
|
||||
networks:
|
||||
interview-service:
|
||||
external: true
|
||||
name: interview-service_default
|
||||
roleplay-service:
|
||||
external: true
|
||||
name: roleplay-service_default
|
||||
resume-builder-staging:
|
||||
external: true
|
||||
name: resume-builder_default
|
||||
course-service:
|
||||
external: true
|
||||
name: courses_service_default
|
||||
qscore-service-staging:
|
||||
external: true
|
||||
name: qscore-service_default
|
||||
matchmaking-v2-staging:
|
||||
external: true
|
||||
name: matchmaking-service_default
|
||||
user-service-staging:
|
||||
external: true
|
||||
name: growqr-app_growqr-net
|
||||
social-branding-staging:
|
||||
external: true
|
||||
name: social-branding_default
|
||||
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-growqr}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-growqr}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:15445:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -33,8 +33,8 @@ services:
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||
ports:
|
||||
- "3001:3000" # HTTP (Gitea listens on 3000 internally)
|
||||
- "2222:2222" # SSH
|
||||
- "127.0.0.1:13001:3000" # HTTP (Gitea listens on 3000 internally)
|
||||
- "127.0.0.1:12222:2222" # SSH
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
healthcheck:
|
||||
@@ -50,8 +50,8 @@ services:
|
||||
image: rivetdev/engine:latest
|
||||
container_name: growqr-rivet
|
||||
ports:
|
||||
- "6420:6420" # API
|
||||
- "6421:6421" # Guard/edge
|
||||
- "127.0.0.1:16420:6420" # API
|
||||
- "127.0.0.1:16421:6421" # Guard/edge
|
||||
environment:
|
||||
RIVET__FILE_SYSTEM__PATH: /data
|
||||
RIVET__AUTH__ADMIN_TOKEN: ${RIVET_ADMIN_TOKEN:-dev-admin-token}
|
||||
@@ -74,7 +74,7 @@ services:
|
||||
rivet-engine:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "4000:4000"
|
||||
- "127.0.0.1:18012:4000"
|
||||
environment:
|
||||
PORT: 4000
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
@@ -122,6 +122,8 @@ services:
|
||||
COURSES_SERVICE_URL: ${COURSES_SERVICE_URL:-http://host.docker.internal:8060}
|
||||
ASSESSMENT_SERVICE_URL: ${ASSESSMENT_SERVICE_URL:-http://host.docker.internal:8070}
|
||||
MATCHMAKING_SERVICE_URL: ${MATCHMAKING_SERVICE_URL:-http://host.docker.internal:8006}
|
||||
SOCIAL_BRANDING_SERVICE_URL: ${SOCIAL_BRANDING_SERVICE_URL:-http://host.docker.internal:8011}
|
||||
SOCIAL_BRANDING_PUBLIC_URL: ${SOCIAL_BRANDING_PUBLIC_URL:-http://host.docker.internal:8011}
|
||||
PATHWAYS_SERVICE_URL: ${PATHWAYS_SERVICE_URL:-http://host.docker.internal:8009}
|
||||
# Frontend
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
|
||||
13
drizzle/0013_onboarding.sql
Normal file
13
drizzle/0013_onboarding.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "plan" text DEFAULT 'free' NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "onboarding" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "onboarding_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "onboarding_user_idx" ON "onboarding" USING btree ("user_id");
|
||||
@@ -92,6 +92,13 @@
|
||||
"when": 1780481700000,
|
||||
"tag": "0012_system_notifications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1783801800000,
|
||||
"tag": "0013_onboarding",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
6394
package-lock.json
generated
Normal file
6394
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
"test:missions": "tsx scripts/mission-lifecycle.test.ts",
|
||||
"test:passive-actions": "tsx scripts/passive-actions.test.ts",
|
||||
"test:curator-static": "tsx scripts/curator-static-registry.test.ts",
|
||||
"test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Curator V1 Conversation Prompt
|
||||
|
||||
You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
|
||||
|
||||
## Responsibilities
|
||||
- Own 30 day direction, streak continuity, and service handoff decisions.
|
||||
- Carry state from the conversation history and captured task memory.
|
||||
- If the user gives a short answer like a role name, accept it and ask for the next missing slot.
|
||||
|
||||
## Guardrails
|
||||
- Do not ask the same question twice.
|
||||
- Do not output checklist items as separate baked chat messages.
|
||||
- Never say: What should I capture next.
|
||||
- Do not ask about another subtask, another mission, another service, or a later checklist item from this modal.
|
||||
- When the user has answered the focused subtask enough, summarize what was captured and stop.
|
||||
- If more detail is needed, ask exactly one follow-up question for the focused subtask only.
|
||||
- Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.
|
||||
|
||||
## Task Guidance
|
||||
- For target-role tasks, collect target role, current background, constraints, then offer a resume or interview handoff.
|
||||
- For service work, prepare preview-oriented handoffs once the focused subtask has enough context.
|
||||
- Interview preview defaults: type behavioral, difficulty medium, duration 5.
|
||||
- Roleplay preview should open the builder as the preview surface.
|
||||
- Keep the tone concise, warm, and practical.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Curator Streak Chat Prompt
|
||||
|
||||
You are the GrowQR V1 Curator in a daily or weekly streak chat modal.
|
||||
|
||||
Your job is to move the user from a focused streak suggestion to a service preview CTA quickly. The dashboard renders the actual CTA card, so your chat text must never include internal URLs, route names, API paths, setup paths, JSON, or tool names.
|
||||
|
||||
## Conversation Rules
|
||||
|
||||
- Focus only on the clicked subtask.
|
||||
- Ask at most one clarifying question before a handoff is ready.
|
||||
- If the target role is known from onboarding, do not ask for it again.
|
||||
- If no target role is known and the service is interview or roleplay, ask exactly: `What role are you targeting?`
|
||||
- If the user gives a vague answer after one question, use your best guess and proceed.
|
||||
- When enough context exists, summarize the captured intent in one short sentence and stop.
|
||||
- Do not ask the next subtask question.
|
||||
- Do not mention setup screens, preview URLs, backend services, actors, tools, or route paths.
|
||||
- Use ASCII punctuation only.
|
||||
|
||||
## Service Defaults
|
||||
|
||||
- Interview defaults: `type=behavioral`, `difficulty=medium`, `duration=5`.
|
||||
- Roleplay defaults: custom scenario, `difficulty=medium`, `duration=5`.
|
||||
- Prefer onboarding-derived role when available.
|
||||
- If no role is available after the single follow-up, use `Product Manager` as the MVP fallback.
|
||||
|
||||
## Tone
|
||||
|
||||
Be concise, calm, and action-oriented. The user should feel like the preview is prepared, not like they are filling out a form.
|
||||
68
scripts/count-fallback.test.ts
Normal file
68
scripts/count-fallback.test.ts
Normal file
@@ -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);
|
||||
58
scripts/curator-access-choice.test.ts
Normal file
58
scripts/curator-access-choice.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { planSeedsForVariant, resolveCuratorSprintAccess } from "../src/v1/curator/curator-store.js";
|
||||
import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js";
|
||||
|
||||
const variant = "fresher_early_professional" as const;
|
||||
const template = templateSetFor(variant);
|
||||
const trial = trialDaysFor(variant);
|
||||
|
||||
// Persisted choice is authoritative within the user's entitlement: a paid user
|
||||
// may choose trial, but a free user may not self-upgrade through "full".
|
||||
assert.deepEqual(resolveCuratorSprintAccess({ accessChoice: "trial", plan: "pro" }), {
|
||||
access: "trial",
|
||||
durationDays: 2,
|
||||
});
|
||||
assert.deepEqual(resolveCuratorSprintAccess({ accessChoice: "full", plan: "pro" }), {
|
||||
access: "full",
|
||||
durationDays: 7,
|
||||
});
|
||||
assert.deepEqual(resolveCuratorSprintAccess({ accessChoice: "full", plan: "free" }), {
|
||||
access: "trial",
|
||||
durationDays: 2,
|
||||
});
|
||||
assert.deepEqual(resolveCuratorSprintAccess({ plan: "pro" }), {
|
||||
access: "full",
|
||||
durationDays: 7,
|
||||
});
|
||||
assert.deepEqual(resolveCuratorSprintAccess({ plan: "free" }), {
|
||||
access: "trial",
|
||||
durationDays: 2,
|
||||
});
|
||||
|
||||
const trialDays = planSeedsForVariant(template, "2026-07-13", 2, "trial");
|
||||
assert.deepEqual(
|
||||
trialDays[0]?.plannedTasks.map((task) => task.title),
|
||||
[trial[0]!.socialTitle, trial[0]!.measurementTitle, trial[0]!.proofTitle, trial[0]!.practiceTitle],
|
||||
"trial Day 1 must use the ICP trial registry",
|
||||
);
|
||||
assert.deepEqual(
|
||||
trialDays[1]?.plannedTasks.map((task) => task.title),
|
||||
[trial[1]!.socialTitle, trial[1]!.measurementTitle, trial[1]!.proofTitle, trial[1]!.practiceTitle],
|
||||
"trial Day 2 must use the ICP trial registry",
|
||||
);
|
||||
|
||||
const fullDays = planSeedsForVariant(template, "2026-07-13", 7, "full");
|
||||
assert.equal(fullDays.length, 7);
|
||||
assert.deepEqual(
|
||||
fullDays[0]?.plannedTasks.map((task) => task.title),
|
||||
[
|
||||
template.weeks[0]!.days[0]!.measurement.title,
|
||||
template.weeks[0]!.days[0]!.proof.title,
|
||||
template.weeks[0]!.days[0]!.practice.title,
|
||||
template.weeks[0]!.days[0]!.roleplay!.title,
|
||||
],
|
||||
"authorized full Day 1 must use the normal 7-day registry",
|
||||
);
|
||||
assert.equal(fullDays[0]?.isTrialDay, false);
|
||||
|
||||
console.log("curator access-choice tests passed");
|
||||
77
scripts/curator-onboarding-reconcile.test.ts
Normal file
77
scripts/curator-onboarding-reconcile.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
curatorSprintPlanFingerprint,
|
||||
curatorSprintTaskPlan,
|
||||
decideCuratorSprintReconciliation,
|
||||
resolveCuratorSprintAccess,
|
||||
shouldReconcileCuratorSprint,
|
||||
} from "../src/v1/curator/curator-store.js";
|
||||
import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js";
|
||||
|
||||
const usersRoute = readFileSync(new URL("../src/routes/users.ts", import.meta.url), "utf8");
|
||||
const ledgerSource = readFileSync(new URL("../src/events/onboarding-ledger.ts", import.meta.url), "utf8");
|
||||
const patchStart = usersRoute.indexOf('app.patch("/onboarding"');
|
||||
const persistIndex = usersRoute.indexOf("const updateRes = await fetchUserService", patchStart);
|
||||
const completionIndex = usersRoute.indexOf("recordAndProcessOnboardingCompletion", persistIndex);
|
||||
assert.ok(persistIndex >= patchStart, "PATCH /onboarding must persist preferences");
|
||||
assert.ok(completionIndex > persistIndex, "PATCH /onboarding must run completion reconciliation after persistence");
|
||||
const completionCall = usersRoute.slice(completionIndex, completionIndex + 500);
|
||||
assert.match(completionCall, /preferences: nextPreferences/,
|
||||
"PATCH completion must pass freshly persisted preferences");
|
||||
assert.match(completionCall, /onboarding: nextData/,
|
||||
"PATCH completion must pass freshly persisted onboarding choice");
|
||||
assert.match(ledgerSource, /ensureOnboardingSideEffectsForEvent\(event, context\)/,
|
||||
"deduped completion processing must forward fresh context to curator loop");
|
||||
|
||||
// persisted chooser is changed to trial. The stale started event must not be
|
||||
// treated as final; the next load must deterministically use trial/2.
|
||||
const staleFull = { access: "full" as const, durationDays: 7 as const };
|
||||
const desiredTrial = resolveCuratorSprintAccess({ plan: "pro", accessChoice: "trial" });
|
||||
assert.deepEqual(desiredTrial, { access: "trial", durationDays: 2 });
|
||||
assert.equal(shouldReconcileCuratorSprint(staleFull, desiredTrial), true);
|
||||
|
||||
// A matching trial event is idempotent and must not create a replacement
|
||||
// started/ready lineage on repeated completion processing.
|
||||
const existingTrial = { access: "trial" as const, durationDays: 2 as const };
|
||||
assert.deepEqual(decideCuratorSprintReconciliation(staleFull, desiredTrial), {
|
||||
action: "invalidateAndRebuild",
|
||||
access: "trial",
|
||||
durationDays: 2,
|
||||
});
|
||||
assert.equal(shouldReconcileCuratorSprint(existingTrial, desiredTrial), false);
|
||||
assert.equal(shouldReconcileCuratorSprint(existingTrial, resolveCuratorSprintAccess({ plan: "pro", accessChoice: "trial" })), false);
|
||||
|
||||
// Full is entitlement-gated: a free user cannot escalate by choosing full.
|
||||
const unauthorizedFull = resolveCuratorSprintAccess({ plan: "free", accessChoice: "full" });
|
||||
assert.deepEqual(unauthorizedFull, { access: "trial", durationDays: 2 });
|
||||
assert.equal(shouldReconcileCuratorSprint(existingTrial, unauthorizedFull), false);
|
||||
|
||||
const startDate = "2026-07-13";
|
||||
const studentFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "student_recent_grad", durationDays: 2, access: "trial" });
|
||||
const experiencedFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" });
|
||||
const experiencedPlan = curatorSprintTaskPlan({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" });
|
||||
assert.notEqual(studentFingerprint, experiencedFingerprint, "ICP changes must change the compatibility fingerprint");
|
||||
assert.equal(shouldReconcileCuratorSprint(
|
||||
{ access: "trial", durationDays: 2, variantId: "student_recent_grad", planFingerprint: studentFingerprint },
|
||||
{ access: "trial", durationDays: 2, variantId: "experienced_professional", planFingerprint: experiencedFingerprint },
|
||||
), true, "student started/ready lineage must reconcile to experienced trial");
|
||||
const experiencedTrial = trialDaysFor("experienced_professional");
|
||||
assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.title), [
|
||||
experiencedTrial[0]!.socialTitle,
|
||||
experiencedTrial[0]!.measurementTitle,
|
||||
experiencedTrial[0]!.proofTitle,
|
||||
experiencedTrial[0]!.practiceTitle,
|
||||
]);
|
||||
assert.deepEqual(experiencedPlan[1]?.tasks.map((task) => task.title), [
|
||||
experiencedTrial[1]!.socialTitle,
|
||||
experiencedTrial[1]!.measurementTitle,
|
||||
experiencedTrial[1]!.proofTitle,
|
||||
experiencedTrial[1]!.practiceTitle,
|
||||
]);
|
||||
assert.equal(experiencedPlan[0]?.tasks.length, 4);
|
||||
assert.equal(experiencedPlan[1]?.tasks.length, 4);
|
||||
assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.id), experiencedPlan[0]?.tasks.map((task) => task.id));
|
||||
assert.equal(templateSetFor("experienced_professional").id, "experienced_professional");
|
||||
|
||||
console.log("curator onboarding reconciliation tests passed");
|
||||
133
scripts/curator-persisted-reconcile.test.ts
Normal file
133
scripts/curator-persisted-reconcile.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { db } from "../src/db/client.js";
|
||||
import { growEvents, onboarding, users } from "../src/db/schema.js";
|
||||
import { buildCuratorSprint, curatorSprintTaskPlan } from "../src/v1/curator/curator-store.js";
|
||||
import { runCuratorOnboardingLoop } from "../src/v1/curator/curator-onboarding-loop.js";
|
||||
import { CURATOR_TRIAL_TASK_REGISTRY } from "../src/v1/curator/task-registry.js";
|
||||
|
||||
let databaseAvailable = Boolean(process.env.DATABASE_URL);
|
||||
if (databaseAvailable) {
|
||||
try {
|
||||
await db.execute(sql`select 1`);
|
||||
} catch {
|
||||
databaseAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!databaseAvailable) {
|
||||
console.log("curator persisted reconciliation integration skipped: DATABASE_URL is not reachable");
|
||||
} else {
|
||||
const userId = `curator-reconcile-regression-${Date.now()}`;
|
||||
const startDate = new Date().toISOString().slice(0, 10);
|
||||
const sprintId = `curator-sprint:icp-v10-static:${startDate}`;
|
||||
const staleFingerprint = JSON.stringify({ variantId: "student_recent_grad", taskIds: [] });
|
||||
const staleReadyPayload = {
|
||||
sprintId,
|
||||
startDate,
|
||||
durationDays: 2,
|
||||
access: "trial",
|
||||
variantId: "student_recent_grad",
|
||||
planFingerprint: staleFingerprint,
|
||||
};
|
||||
|
||||
try {
|
||||
await db.insert(users).values({ id: userId, email: `${userId}@example.test`, plan: "pro" });
|
||||
await db.insert(onboarding).values({
|
||||
userId,
|
||||
data: {
|
||||
status: "completed",
|
||||
access_choice: "trial",
|
||||
profile: { icp: "experienced" },
|
||||
},
|
||||
payload: {
|
||||
onboarding_icp: "experienced",
|
||||
curator_registry_icp: "experienced_professional",
|
||||
},
|
||||
});
|
||||
await db.insert(growEvents).values([
|
||||
{
|
||||
userId,
|
||||
source: "curator-v1",
|
||||
type: "curator.sprint.started",
|
||||
category: "mission",
|
||||
occurredAt: new Date(),
|
||||
dedupeKey: `${userId}:curator.sprint.started:stale`,
|
||||
payload: {
|
||||
sprintId,
|
||||
startDate,
|
||||
durationDays: 2,
|
||||
access: "trial",
|
||||
version: "icp-v10-static",
|
||||
variantId: "student_recent_grad",
|
||||
},
|
||||
},
|
||||
{
|
||||
userId,
|
||||
source: "curator-v1",
|
||||
type: "curator.onboarding_plan.ready",
|
||||
category: "mission",
|
||||
occurredAt: new Date(),
|
||||
dedupeKey: `${userId}:curator.onboarding_plan.ready:stale`,
|
||||
payload: staleReadyPayload,
|
||||
},
|
||||
]);
|
||||
|
||||
const context = {
|
||||
onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } },
|
||||
preferences: { onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } } },
|
||||
};
|
||||
const first = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
|
||||
assert.equal(first.status, "ready", "stale ready event must be replaced");
|
||||
|
||||
const day1 = await buildCuratorSprint(userId, startDate);
|
||||
assert.deepEqual(day1.plan.days[0]?.tasks.map(({ id, title }) => ({ id, title })),
|
||||
expectedTasks(startDate, 1), "Day 1 must expose experienced trial tasks");
|
||||
assert.deepEqual(day1.plan.days[1]?.tasks, [], "Day 1 must hide future Day 2 tasks");
|
||||
|
||||
const day2 = await buildCuratorSprint(userId, addDays(startDate, 1));
|
||||
assert.deepEqual(day2.plan.days[1]?.tasks.map(({ id, title }) => ({ id, title })),
|
||||
expectedTasks(startDate, 2), "Day 2 must expose experienced trial tasks after advance");
|
||||
|
||||
const second = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
|
||||
assert.equal(second.status, "already_ready", "compatible replacement ready event must be reused");
|
||||
|
||||
const rows = await db.select({ type: growEvents.type, payload: growEvents.payload })
|
||||
.from(growEvents).where(eq(growEvents.userId, userId));
|
||||
assert.equal(rows.filter((row) => row.type === "curator.sprint.invalidated").length, 1);
|
||||
assert.equal(rows.filter((row) => row.type === "curator.onboarding_plan.invalidated").length, 1);
|
||||
const starts = rows.filter((row) => row.type === "curator.sprint.started");
|
||||
const readies = rows.filter((row) => row.type === "curator.onboarding_plan.ready");
|
||||
assert.equal(starts.length, 2, "one stale and one replacement started lineage");
|
||||
assert.equal(readies.length, 2, "one stale and one replacement ready lineage");
|
||||
const replacementStart = starts.find((row) => row.payload?.variantId === "experienced_professional");
|
||||
const replacementReady = readies.find((row) => row.payload?.planFingerprint === day1.plan.planFingerprint);
|
||||
assert.ok(replacementStart, "experienced replacement started event must be persisted");
|
||||
assert.ok(replacementReady, "experienced replacement ready event must be persisted");
|
||||
|
||||
console.log("curator persisted reconciliation integration passed");
|
||||
} finally {
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
await db.execute(sql`select 1`);
|
||||
}
|
||||
}
|
||||
|
||||
function addDays(date: string, days: number) {
|
||||
const value = new Date(`${date}T00:00:00.000Z`);
|
||||
value.setUTCDate(value.getUTCDate() + days);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function expectedTasks(startDate: string, dayIndex: 1 | 2) {
|
||||
const plan = curatorSprintTaskPlan({
|
||||
startDate,
|
||||
variantId: "experienced_professional",
|
||||
durationDays: 2,
|
||||
access: "trial",
|
||||
});
|
||||
const registry = CURATOR_TRIAL_TASK_REGISTRY.experienced_professional[dayIndex - 1]!;
|
||||
return plan[dayIndex - 1]!.tasks.map(({ id }, index) => ({
|
||||
id,
|
||||
title: [registry.socialTitle, registry.measurementTitle, registry.proofTitle, registry.practiceTitle][index]!,
|
||||
}));
|
||||
}
|
||||
@@ -13,10 +13,12 @@ const ICP_IDS: CuratorIcpId[] = [
|
||||
|
||||
const LIVE_ROUTE_PREFIX: Record<string, string> = {
|
||||
"courses-service": "/agents/courses",
|
||||
"qscore-service": "/agents/qscore",
|
||||
"resume-service": "/agents/resume",
|
||||
"interview-service": "/agents/interview/preview",
|
||||
"roleplay-service": "/agents/roleplay/builder",
|
||||
"interview-service": "/agents/interview",
|
||||
"roleplay-service": "/agents/roleplay",
|
||||
"matchmaking-service": "/agents/matchmaking",
|
||||
"social-branding-service": "/agents/social-branding",
|
||||
};
|
||||
|
||||
const EXPECTED_FOCUS: Record<CuratorIcpId, string[]> = {
|
||||
@@ -60,14 +62,12 @@ async function main() {
|
||||
});
|
||||
|
||||
try {
|
||||
assert(preview.version === "icp-v7-static", `${icpId}: expected static plan version`);
|
||||
assert(preview.idempotency.promptHash === "static-task-registry", `${icpId}: prompt hash should prove static registry mode`);
|
||||
assert(preview.plan.days.length === 30, `${icpId}: expected 30 generated days`);
|
||||
assert(preview.plan.calendarWeeks.length === 5, `${icpId}: expected 5 sprint-relative calendar weeks`);
|
||||
assert(preview.version === "icp-v10-static", `${icpId}: expected static plan version`);
|
||||
assert(preview.plan.days.length === 7, `${icpId}: expected 7 generated days`);
|
||||
assert(preview.plan.calendarWeeks.length === 1, `${icpId}: expected one sprint-relative calendar week`);
|
||||
|
||||
const firstSevenFocus = preview.plan.days.slice(0, 7).map((day) => day.focus);
|
||||
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(EXPECTED_FOCUS[icpId]), `${icpId}: first 7 focus labels must match 7-day sprint doc`);
|
||||
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(preview.plan.days.slice(7, 14).map((day) => day.focus)), `${icpId}: 7-day sprint should repeat after day 7 for now`);
|
||||
|
||||
const dayOneTitles = preview.plan.days[0]?.tasks.map((task) => task.title);
|
||||
assert(JSON.stringify(dayOneTitles) === JSON.stringify(EXPECTED_DAY_ONE_TITLES[icpId]), `${icpId}: day 1 task titles must match docs`);
|
||||
@@ -84,7 +84,7 @@ async function main() {
|
||||
|
||||
for (const task of day.tasks) {
|
||||
assert(task.missionId === "curator-sprint", `${icpId} ${task.id}: missionId mismatch`);
|
||||
assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v7-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`);
|
||||
assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v10-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`);
|
||||
assert(task.stageId?.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: stageId should include service segment`);
|
||||
assert(task.id.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: taskId should include service segment`);
|
||||
assert(task.actorName !== "Vera" && task.actorName !== "Kai" && task.actorName !== "Mira" && task.actorName !== "Lyra", `${icpId} ${task.id}: agent persona leaked into actorName`);
|
||||
@@ -102,8 +102,20 @@ async function main() {
|
||||
assert(url.searchParams.get("missionId") === task.missionId, `${icpId} ${task.id}: route missionId mismatch`);
|
||||
assert(url.searchParams.get("missionInstanceId") === task.missionInstanceId, `${icpId} ${task.id}: route missionInstanceId mismatch`);
|
||||
assert(url.searchParams.get("stageId") === task.stageId, `${icpId} ${task.id}: route stageId mismatch`);
|
||||
assert(url.searchParams.get("taskTitle") === task.title, `${icpId} ${task.id}: route taskTitle mismatch`);
|
||||
assert(url.searchParams.get("taskSubtitle") === task.subtitle, `${icpId} ${task.id}: route taskSubtitle mismatch`);
|
||||
assert(url.searchParams.get("taskType") === task.taskType, `${icpId} ${task.id}: route taskType mismatch`);
|
||||
|
||||
if (task.serviceId === "interview-service" || task.serviceId === "roleplay-service") {
|
||||
if (task.serviceId === "interview-service") {
|
||||
const taskText = `${task.title} ${task.subtitle}`.toLowerCase();
|
||||
const isIntroductionTask = /(?:60|90)[ -]?second|\bintroduction\b|\bpitch\b|\bpresentation\b/.test(taskText);
|
||||
assert(url.searchParams.get("type") === (isIntroductionTask ? "warm_up" : "behavioral"), `${icpId} ${task.id}: interview type must match task intent`);
|
||||
assert(url.searchParams.get("role") === (isIntroductionTask ? null : "Product Manager"), `${icpId} ${task.id}: interview role must match task intent`);
|
||||
assert(url.searchParams.get("difficulty") === (isIntroductionTask ? "easy" : "medium"), `${icpId} ${task.id}: interview difficulty must match task intent`);
|
||||
assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: interview route should carry duration`);
|
||||
}
|
||||
|
||||
if (task.serviceId === "roleplay-service") {
|
||||
assert(url.searchParams.get("role") === "Product Manager", `${icpId} ${task.id}: practice route should preserve target role`);
|
||||
assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: practice route should carry duration`);
|
||||
}
|
||||
@@ -119,7 +131,7 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 30 days each, 4 doc-backed tasks per day`);
|
||||
console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 7 days each, 4 doc-backed tasks per day`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
105
scripts/event-dedupe-routing.test.ts
Normal file
105
scripts/event-dedupe-routing.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { shouldRouteGrowEvent } from "../src/events/record-grow-event.js";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
|
||||
/**
|
||||
* Regression: duplicate/idempotent ingest must not enqueue the same event twice.
|
||||
*
|
||||
* Before the fix, recordGrowEvent returned the existing row on a dedupe hit,
|
||||
* and routeGrowEventToUserActor was called unconditionally — so every duplicate
|
||||
* ingest re-enqueued the event to the actor queue, causing saturation.
|
||||
*
|
||||
* After the fix, routing is gated by shouldRouteGrowEvent(event, inserted):
|
||||
* only newly inserted, user-resolved, pending events are routed.
|
||||
*/
|
||||
|
||||
// ── 1. Freshly inserted, user-resolved, pending → route ──────────────────────
|
||||
{
|
||||
const event = { userId: "user_a", processingStatus: "pending" } as Pick<GrowEventRow, "userId" | "processingStatus">;
|
||||
assert.equal(
|
||||
shouldRouteGrowEvent(event, true),
|
||||
true,
|
||||
"newly inserted pending event with a userId must route",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. Dedupe hit (inserted=false) → DO NOT route ────────────────────────────
|
||||
// This is the core regression: a duplicate ingest returns the existing row
|
||||
// with inserted=false. The gate must refuse to re-enqueue.
|
||||
{
|
||||
const event = { userId: "user_a", processingStatus: "processed" } as Pick<GrowEventRow, "userId" | "processingStatus">;
|
||||
assert.equal(
|
||||
shouldRouteGrowEvent(event, false),
|
||||
false,
|
||||
"dedupe hit (inserted=false) must NOT route even if status looks processable",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Freshly inserted but already processed → DO NOT route ─────────────────
|
||||
// Edge case: the row was just inserted but somehow already processed (e.g. the
|
||||
// caller did in-process projection synchronously). No re-enqueue.
|
||||
{
|
||||
const event = { userId: "user_a", processingStatus: "processed" } as Pick<GrowEventRow, "userId" | "processingStatus">;
|
||||
assert.equal(
|
||||
shouldRouteGrowEvent(event, true),
|
||||
false,
|
||||
"inserted but already-processed event must NOT route",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Freshly inserted but unresolved (no userId) → DO NOT route ────────────
|
||||
{
|
||||
const event = { userId: null, processingStatus: "unresolved" } as Pick<GrowEventRow, "userId" | "processingStatus">;
|
||||
assert.equal(
|
||||
shouldRouteGrowEvent(event, true),
|
||||
false,
|
||||
"unresolved event (no userId) must NOT route",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 5. Freshly inserted, user-resolved, but failed → DO NOT route ────────────
|
||||
// A terminal-failed event should not be re-enqueued by a duplicate ingest.
|
||||
{
|
||||
const event = { userId: "user_a", processingStatus: "failed" } as Pick<GrowEventRow, "userId" | "processingStatus">;
|
||||
assert.equal(
|
||||
shouldRouteGrowEvent(event, true),
|
||||
false,
|
||||
"failed event must NOT route on replay",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 6. Dedupe hit on a pending event → DO NOT route ──────────────────────────
|
||||
// Critical: even if the existing row is still pending (not yet processed), a
|
||||
// dedupe hit means another caller already inserted it and likely already routed
|
||||
// it. Re-routing would create a duplicate queue message.
|
||||
{
|
||||
const event = { userId: "user_a", processingStatus: "pending" } as Pick<GrowEventRow, "userId" | "processingStatus">;
|
||||
assert.equal(
|
||||
shouldRouteGrowEvent(event, false),
|
||||
false,
|
||||
"dedupe hit on a pending event must NOT re-route (already enqueued by first caller)",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 7. Idempotent double-ingest simulation ───────────────────────────────────
|
||||
// Simulate two calls to recordGrowEventWithResult for the same event:
|
||||
// first returns inserted=true, second returns inserted=false.
|
||||
// The first routes, the second does not — proving no double-enqueue.
|
||||
{
|
||||
const event = { userId: "user_a", processingStatus: "pending" } as Pick<GrowEventRow, "userId" | "processingStatus">;
|
||||
|
||||
// First ingest: newly inserted
|
||||
const firstRoute = shouldRouteGrowEvent(event, true);
|
||||
// Second ingest: dedupe hit
|
||||
const secondRoute = shouldRouteGrowEvent(event, false);
|
||||
|
||||
assert.equal(firstRoute, true, "first ingest must route");
|
||||
assert.equal(secondRoute, false, "second (dedupe) ingest must NOT route");
|
||||
assert.ok(
|
||||
firstRoute && !secondRoute,
|
||||
"duplicate ingest must not enqueue twice",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("event-dedupe-routing tests passed");
|
||||
process.exit(0);
|
||||
131
scripts/event-ingress-route-integration.test.ts
Normal file
131
scripts/event-ingress-route-integration.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { routeGrowEventToUserActor } from "../src/events/route-to-user-actor.js";
|
||||
import { shouldRouteGrowEvent } from "../src/events/record-grow-event.js";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
|
||||
/**
|
||||
* Cross-file integration test: exercises the full ingress → route → actor-enqueue
|
||||
* side-effect chain for the duplicate-routing regression.
|
||||
*
|
||||
* The persistence layer (recordGrowEventWithResult) requires Postgres and cannot
|
||||
* run in a tsx test. Instead we simulate its {event, inserted} output contract —
|
||||
* the same contract the three ingress callers (redis-consumer recordAndRoute,
|
||||
* routes/events.ts ingest, v1/events/events-routes.ts /track) consume — and prove
|
||||
* that the routing layer enqueues exactly once across a duplicate ingest pair.
|
||||
*
|
||||
* routeGrowEventToUserActor accepts an injectable `enqueue`, so we can observe
|
||||
* real side effects (the actor queue send) without a live Rivet cluster.
|
||||
*/
|
||||
|
||||
type RouteDecision = { routed: true } | { routed: false; reason: string };
|
||||
|
||||
// Simulate the ingress caller's routing block (mirrors redis-consumer.recordAndRoute
|
||||
// and routes/events.ts.ingest): gate on shouldRouteGrowEvent, then call route.
|
||||
async function ingressRoute(
|
||||
event: Pick<GrowEventRow, "id" | "userId" | "processingStatus">,
|
||||
inserted: boolean,
|
||||
enqueueCalls: Array<{ userId: string; eventId: string }>,
|
||||
): Promise<RouteDecision> {
|
||||
if (!shouldRouteGrowEvent(event, inserted)) {
|
||||
return { routed: false, reason: "dedupe_or_unresolved" };
|
||||
}
|
||||
return routeGrowEventToUserActor(event, {
|
||||
timeoutMs: 500,
|
||||
enqueue: async (input) => {
|
||||
enqueueCalls.push({ userId: input.userId, eventId: input.eventId });
|
||||
return { queued: true };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeEvent(overrides: Partial<GrowEventRow> = {}): Pick<GrowEventRow, "id" | "userId" | "processingStatus"> {
|
||||
return {
|
||||
id: "evt-cross-file-test",
|
||||
userId: "user-cross-file",
|
||||
processingStatus: "pending",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 1. First ingest routes, second (dedupe) does NOT — proving no double-enqueue ─
|
||||
{
|
||||
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
|
||||
const event = makeEvent();
|
||||
|
||||
// First ingest: recordGrowEventWithResult returns inserted=true
|
||||
const first = await ingressRoute(event, true, enqueueCalls);
|
||||
// Second ingest: same event, dedupe hit → inserted=false
|
||||
const second = await ingressRoute(event, false, enqueueCalls);
|
||||
|
||||
assert.equal(first.routed, true, "first ingest must route to actor");
|
||||
assert.equal(second.routed, false, "dedupe ingest must NOT route");
|
||||
assert.equal(enqueueCalls.length, 1, "exactly one enqueue call — duplicate must not double-enqueue");
|
||||
assert.deepEqual(enqueueCalls[0], { userId: "user-cross-file", eventId: "evt-cross-file-test" });
|
||||
}
|
||||
|
||||
// ── 2. Unresolved event (no userId) never enqueues ────────────────────────────
|
||||
{
|
||||
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
|
||||
const event = makeEvent({ userId: null, processingStatus: "unresolved" });
|
||||
|
||||
const result = await ingressRoute(event, true, enqueueCalls);
|
||||
|
||||
assert.equal(result.routed, false, "unresolved event must not route");
|
||||
assert.equal(enqueueCalls.length, 0, "unresolved event must not enqueue");
|
||||
}
|
||||
|
||||
// ── 3. Three distinct events all route (no false-negative) ────────────────────
|
||||
{
|
||||
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
|
||||
const events = [
|
||||
makeEvent({ id: "evt-a", userId: "user-1" }),
|
||||
makeEvent({ id: "evt-b", userId: "user-1" }),
|
||||
makeEvent({ id: "evt-c", userId: "user-2" }),
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
await ingressRoute(event, true, enqueueCalls);
|
||||
}
|
||||
|
||||
assert.equal(enqueueCalls.length, 3, "three distinct inserted events must each enqueue once");
|
||||
assert.deepEqual(
|
||||
enqueueCalls.map((c) => c.eventId),
|
||||
["evt-a", "evt-b", "evt-c"],
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Same event ingested 5 times enqueues exactly once ──────────────────────
|
||||
{
|
||||
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
|
||||
const event = makeEvent({ id: "evt-repeated" });
|
||||
|
||||
// First is inserted, remaining 4 are dedupe hits
|
||||
await ingressRoute(event, true, enqueueCalls);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await ingressRoute(event, false, enqueueCalls);
|
||||
}
|
||||
|
||||
assert.equal(enqueueCalls.length, 1, "5 duplicate ingests must enqueue exactly once");
|
||||
}
|
||||
|
||||
// ── 5. Mixed: event A first-insert, event B dedupe, event C first-insert ──────
|
||||
// Proves the gate doesn't carry state between decisions.
|
||||
{
|
||||
const enqueueCalls: Array<{ userId: string; eventId: string }> = [];
|
||||
const eventA = makeEvent({ id: "evt-mixed-a" });
|
||||
const eventB = makeEvent({ id: "evt-mixed-b" });
|
||||
const eventC = makeEvent({ id: "evt-mixed-c" });
|
||||
|
||||
await ingressRoute(eventA, true, enqueueCalls);
|
||||
await ingressRoute(eventB, false, enqueueCalls);
|
||||
await ingressRoute(eventC, true, enqueueCalls);
|
||||
|
||||
assert.equal(enqueueCalls.length, 2, "only first-insert events A and C enqueue; B (dedupe) skipped");
|
||||
assert.deepEqual(
|
||||
enqueueCalls.map((c) => c.eventId),
|
||||
["evt-mixed-a", "evt-mixed-c"],
|
||||
);
|
||||
}
|
||||
|
||||
console.log("event-ingress-route-integration tests passed");
|
||||
process.exit(0);
|
||||
17
scripts/event-route-timeout.test.ts
Normal file
17
scripts/event-route-timeout.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { routeGrowEventToUserActor } from "../src/events/route-to-user-actor.js";
|
||||
|
||||
const startedAt = Date.now();
|
||||
const result = await routeGrowEventToUserActor(
|
||||
{ id: "event-timeout-test", userId: "user-timeout-test" },
|
||||
{
|
||||
timeoutMs: 25,
|
||||
enqueue: () => new Promise<never>(() => undefined),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.routed, false);
|
||||
assert.equal(result.reason, "actor_route_timeout");
|
||||
assert.ok(Date.now() - startedAt < 500, "actor routing must not block REST ingestion");
|
||||
|
||||
console.log("event-route-timeout tests passed");
|
||||
117
scripts/matchmaking-events.test.ts
Normal file
117
scripts/matchmaking-events.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
|
||||
import { buildMatchmakingGatewayEvent } from "../src/services/matchmaking-events.js";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
|
||||
function event(overrides: Partial<GrowEventRow> & { type: string; payload: Record<string, unknown> }): GrowEventRow {
|
||||
return {
|
||||
id: `event-${overrides.type}`,
|
||||
userId: "user_test",
|
||||
orgId: null,
|
||||
source: "matchmaking-v2",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const saved = buildMatchmakingGatewayEvent({
|
||||
userId: "user_test",
|
||||
action: "mark_saved",
|
||||
body: {
|
||||
action: "mark_saved",
|
||||
params: {
|
||||
opportunity_id: "job_123",
|
||||
curatorTaskId: "task_123",
|
||||
missionId: "curator-sprint",
|
||||
missionInstanceId: "sprint_123",
|
||||
stageId: "stage_123",
|
||||
source: "curator-v1",
|
||||
},
|
||||
},
|
||||
result: { task_id: "a2a_123", status: "completed", messages: [] },
|
||||
});
|
||||
|
||||
assert.equal(saved.type, "matchmaking.match.saved");
|
||||
assert.deepEqual(saved.mission, {
|
||||
missionId: "curator-sprint",
|
||||
instanceId: "sprint_123",
|
||||
stageId: "stage_123",
|
||||
source: "curator-v1",
|
||||
});
|
||||
assert.deepEqual(saved.subject, {
|
||||
serviceId: "matchmaking",
|
||||
kind: "opportunity",
|
||||
id: "job_123",
|
||||
externalId: "job_123",
|
||||
});
|
||||
assert.equal(saved.correlation?.taskId, "task_123");
|
||||
assert.equal(saved.correlation?.curatorTaskId, "task_123");
|
||||
assert.equal(saved.correlation?.opportunityId, "job_123");
|
||||
assert.equal(saved.payload.curatorTaskId, "task_123");
|
||||
assert.equal(saved.payload.opportunityId, "job_123");
|
||||
assert.equal(saved.payload.action, "mark_saved");
|
||||
|
||||
const generatedSignals = extractQscoreSignals(event({
|
||||
type: "matchmaking.matches.generated",
|
||||
payload: {
|
||||
action: "run_search",
|
||||
matchCount: 6,
|
||||
scanned: 120,
|
||||
result: { status: "completed" },
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
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({
|
||||
type: "matchmaking.match.saved",
|
||||
payload: {
|
||||
action: "mark_saved",
|
||||
opportunityId: "job_123",
|
||||
curatorTaskId: "task_123",
|
||||
result: { status: "completed" },
|
||||
},
|
||||
}));
|
||||
assert.equal(savedSignals.length, 0, "saved opportunities no longer emit a legacy signal");
|
||||
|
||||
const reviewed = buildMatchmakingGatewayEvent({
|
||||
userId: "user_test",
|
||||
action: "record_feedback",
|
||||
body: {
|
||||
action: "record_feedback",
|
||||
params: { opportunity_id: "job_123", curatorTaskId: "task_123" },
|
||||
},
|
||||
result: { task_id: "a2a_456", status: "completed", messages: [] },
|
||||
});
|
||||
assert.equal(reviewed.type, "matchmaking.matches.reviewed");
|
||||
|
||||
const appliedSignals = extractQscoreSignals(event({
|
||||
type: "matchmaking.match.applied",
|
||||
payload: {
|
||||
action: "mark_applied",
|
||||
opportunityId: "job_123",
|
||||
curatorTaskId: "task_123",
|
||||
applications_submitted: 1,
|
||||
result: { status: "completed" },
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
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");
|
||||
process.exit(0);
|
||||
194
scripts/onboarding-bulk-reset.ts
Executable file
194
scripts/onboarding-bulk-reset.ts
Executable file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Staging-only bulk onboarding reset CLI.
|
||||
*
|
||||
* Resets onboarding for EVERY user in the backend `users` table (the
|
||||
* Clerk-mirrored enrollment source) in two phases:
|
||||
*
|
||||
* Phase 1 — user-service preferences reset (A2A).
|
||||
* Calls POST /api/v1/users/onboarding-reset on user-service with the A2A
|
||||
* key. This resets preferences.onboarding to the default in-progress v3
|
||||
* doc, preserving all unrelated preference keys. It targets users by
|
||||
* clerk_id WITHOUT impersonating a Clerk session. A 404 means the user has
|
||||
* no user-service profile (backend-only user) — a documented skip that
|
||||
* never blocks Phase 2.
|
||||
*
|
||||
* Phase 2 — backend completion ledger reset (direct, idempotent).
|
||||
* Calls resetOnboardingLedger(userId) for every backend user whose Phase 1
|
||||
* succeeded OR was skipped (404 backend-only). A Phase 1 error (non-404
|
||||
* failure) blocks Phase 2 for that user to avoid inconsistent state
|
||||
* (preferences say "completed" but ledger says not). This deletes only
|
||||
* completion-type ledger rows (dotted AND underscore aliases) plus
|
||||
* completion-bearing snapshots, preserving intermediate snapshots,
|
||||
* missions, curator context, and qscore baselines.
|
||||
*
|
||||
* GUARDS — all three must hold or the script aborts before any network call:
|
||||
* 1. NODE_ENV=staging (refuses production/development)
|
||||
* 2. ONBOARDING_BULK_RESET_ALLOWED=true (explicit opt-in flag)
|
||||
* 3. --confirm on the CLI (typed acknowledgement)
|
||||
*
|
||||
* Usage:
|
||||
* NODE_ENV=staging ONBOARDING_BULK_RESET_ALLOWED=true \
|
||||
* npx tsx scripts/onboarding-bulk-reset.ts --confirm
|
||||
*
|
||||
* Output: aggregate-only audit counts (no per-user PII) and a summary.
|
||||
*
|
||||
* This script is INTENTIONALLY not wired to any HTTP surface. It is a
|
||||
* staging-only operations tool; bulk reset must never be exposed as a public
|
||||
* endpoint. Per-user reset is the authenticated DELETE /users/onboarding route.
|
||||
*/
|
||||
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { db } from "../src/db/client.js";
|
||||
import { users } from "../src/db/schema.js";
|
||||
import { config } from "../src/config.js";
|
||||
import { log } from "../src/log.js";
|
||||
import { resetOnboardingLedger } from "../src/events/onboarding-ledger.js";
|
||||
|
||||
type PrefOutcome =
|
||||
| { userId: string; ok: true }
|
||||
| { userId: string; ok: false; skipped: true; reason: string; status: number }
|
||||
| { userId: string; ok: false; skipped: false; error: string; status: number };
|
||||
|
||||
type LedgerOutcome =
|
||||
| { userId: string; ok: true; ledgerRowsDeleted: number }
|
||||
| { userId: string; ok: false; error: string };
|
||||
|
||||
function assertStagingGuard(argv: string[]): void {
|
||||
const isStaging = config.nodeEnv === "staging";
|
||||
const allowed = process.env.ONBOARDING_BULK_RESET_ALLOWED === "true";
|
||||
const confirmed = argv.includes("--confirm");
|
||||
|
||||
const failures: string[] = [];
|
||||
if (!isStaging) failures.push(`NODE_ENV must be "staging" (got "${config.nodeEnv}")`);
|
||||
if (!allowed) failures.push("ONBOARDING_BULK_RESET_ALLOWED must be set to \"true\"");
|
||||
if (!confirmed) failures.push("--confirm argument is required");
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("onboarding-bulk-reset: ABORTED — guard checks failed:");
|
||||
for (const f of failures) console.error(` - ${f}`);
|
||||
console.error("");
|
||||
console.error("This script is staging-only and will reset onboarding for EVERY user.");
|
||||
console.error("Re-run with all guards satisfied to proceed.");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
function userServiceUrl(): string {
|
||||
return config.userServiceUrl.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: reset preferences.onboarding via the user-service A2A endpoint.
|
||||
* A 404 means the user has no user-service profile (backend-only) — a clean
|
||||
* skip that does NOT block the backend ledger reset.
|
||||
*/
|
||||
async function resetPreferences(clerkId: string, a2aKey: string): Promise<PrefOutcome> {
|
||||
const res = await fetch(`${userServiceUrl()}/api/v1/users/onboarding-reset`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${a2aKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ clerk_id: clerkId }),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
return { userId: clerkId, ok: false, skipped: true, reason: "no user-service profile (backend-only)", status: 404 };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
return { userId: clerkId, ok: false, skipped: false, error: text || res.statusText, status: res.status };
|
||||
}
|
||||
return { userId: clerkId, ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: directly reset the backend completion ledger for a user.
|
||||
* Always runs after Phase 1 (success or skip) — backend-only users still need
|
||||
* their ledger cleared. Idempotent.
|
||||
*/
|
||||
async function resetLedger(userId: string): Promise<LedgerOutcome> {
|
||||
try {
|
||||
const ledgerRowsDeleted = await resetOnboardingLedger(userId);
|
||||
return { userId, ok: true, ledgerRowsDeleted };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { userId, ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertStagingGuard(process.argv.slice(2));
|
||||
|
||||
const a2aKey = config.a2aAllowedKey;
|
||||
if (!a2aKey) {
|
||||
console.error("onboarding-bulk-reset: ABORTED — A2A_ALLOWED_KEY is not configured");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const allUsers = await db.select({ id: users.id }).from(users).orderBy(asc(users.id));
|
||||
const total = allUsers.length;
|
||||
|
||||
console.log(`onboarding-bulk-reset: targeting ${total} user(s)`);
|
||||
console.log(`onboarding-bulk-reset: NODE_ENV=${config.nodeEnv}, guard=on`);
|
||||
console.log(`onboarding-bulk-reset: user-service=${userServiceUrl()}`);
|
||||
|
||||
let prefOk = 0;
|
||||
let prefSkipped = 0;
|
||||
let prefErrors = 0;
|
||||
|
||||
let ledgerOk = 0;
|
||||
let ledgerErrors = 0;
|
||||
let ledgerTotal = 0;
|
||||
|
||||
let ledgerBlocked = 0;
|
||||
|
||||
for (const u of allUsers) {
|
||||
// Phase 1: user-service preferences reset (A2A).
|
||||
const pref = await resetPreferences(u.id, a2aKey);
|
||||
if (pref.ok) {
|
||||
prefOk += 1;
|
||||
} else if (pref.skipped) {
|
||||
prefSkipped += 1;
|
||||
} else {
|
||||
prefErrors += 1;
|
||||
}
|
||||
|
||||
// Phase 2: backend ledger reset — ONLY after Phase 1 succeeds or is
|
||||
// skipped (404 / backend-only). A pref error (non-404 failure) leaves
|
||||
// preferences.onboarding untouched, so clearing the ledger here would
|
||||
// create an inconsistent state (prefs say "completed", ledger says not).
|
||||
// Skip the ledger for errored users so an operator can retry after fix.
|
||||
if (!pref.ok && !pref.skipped) {
|
||||
ledgerBlocked += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ledger = await resetLedger(u.id);
|
||||
if (ledger.ok) {
|
||||
ledgerOk += 1;
|
||||
ledgerTotal += ledger.ledgerRowsDeleted;
|
||||
} else {
|
||||
ledgerErrors += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("onboarding-bulk-reset: summary (aggregate only — no per-user PII)");
|
||||
console.log(` total users: ${total}`);
|
||||
console.log(` preferences reset: ${prefOk} ok, ${prefSkipped} skipped (backend-only), ${prefErrors} error(s)`);
|
||||
console.log(` ledger reset: ${ledgerOk} ok, ${ledgerErrors} error(s), ${ledgerBlocked} blocked by pref error, ${ledgerTotal} row(s) deleted`);
|
||||
|
||||
const hadErrors = prefErrors > 0 || ledgerErrors > 0;
|
||||
|
||||
log.info(
|
||||
{ total, prefOk, prefSkipped, prefErrors, ledgerOk, ledgerErrors, ledgerBlocked, ledgerTotal },
|
||||
"onboarding bulk reset complete",
|
||||
);
|
||||
process.exit(hadErrors ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("onboarding-bulk-reset: fatal", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -70,5 +70,45 @@ assert.equal(
|
||||
"invalid completion timestamps should normalize to a valid ISO timestamp",
|
||||
);
|
||||
|
||||
// ── Regression: underscore completion aliases must be valid (reset deletes them) ──
|
||||
// The reset only reopens the gate if EVERY alias form that isValidOnboardingLedgerEvent
|
||||
// accepts is also in the reset delete scope. Pin both sides agree per alias.
|
||||
for (const underscore of ["onboarding_completed", "user_onboarding_completed", "profile_onboarding_completed"]) {
|
||||
const dotted = normalizeOnboardingEventType(underscore);
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({ type: underscore, payload: {} }),
|
||||
true,
|
||||
`${underscore} should satisfy onboarding status (normalized to ${dotted})`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Regression: completedAtFromOnboardingPayload paths mirrored by reset SQL ──
|
||||
// resetOnboardingLedger's jsonb predicate checks top-level completed_at/completedAt
|
||||
// and onboarding.completed_at/completedAt. Pin that the JS helper (used for status
|
||||
// validity) agrees a snapshot carrying any of these is a completion snapshot.
|
||||
for (const [label, payload] of [
|
||||
["top-level completed_at", { completed_at: now }],
|
||||
["top-level completedAt", { completedAt: now }],
|
||||
["onboarding.completed_at", { onboarding: { completed_at: now } }],
|
||||
["onboarding.completedAt", { onboarding: { completedAt: now } }],
|
||||
] as const) {
|
||||
assert.equal(
|
||||
completedAtFromOnboardingPayload(payload),
|
||||
now,
|
||||
`completion timestamp extracted from ${label}`,
|
||||
);
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({ type: "onboarding.snapshot.saved", payload }),
|
||||
true,
|
||||
`snapshot with ${label} is a completion snapshot`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Regression: intermediate snapshots (no completion marker) stay invalid ──
|
||||
assert.equal(
|
||||
completedAtFromOnboardingPayload({ onboarding: { current_step: 2 } }),
|
||||
undefined,
|
||||
"intermediate snapshot has no completion timestamp",
|
||||
);
|
||||
console.log("onboarding-ledger tests passed");
|
||||
process.exit(0);
|
||||
|
||||
160
scripts/onboarding-preferences.test.ts
Normal file
160
scripts/onboarding-preferences.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
DEFAULT_ONBOARDING_DATA,
|
||||
defaultOnboardingData,
|
||||
extractOnboardingData,
|
||||
assertOnboardingRevision,
|
||||
deriveOnboardingPayload,
|
||||
mergeOnboardingPatch,
|
||||
OnboardingRevisionConflict,
|
||||
} from "../src/events/onboarding-ledger.js";
|
||||
|
||||
/**
|
||||
* Focused tests for the onboarding preference helpers (pure — no DB/network).
|
||||
* Covers: default v3 shape, revision conflict, partial update merge,
|
||||
* status/completed_at consistency + payload derivation, and unrelated
|
||||
* preference preservation. Matches the scripts/X.test.ts convention
|
||||
* (node:assert over pure functions).
|
||||
*/
|
||||
|
||||
// ── 1. default v3 response ───────────────────────────────────────────────────
|
||||
{
|
||||
const def = defaultOnboardingData();
|
||||
assert.equal(def.schema_version, 3, "default is v3");
|
||||
assert.equal(def.revision, 0);
|
||||
assert.equal(def.status, "in_progress");
|
||||
assert.equal(def.access_choice, null);
|
||||
assert.equal(def.qx_estimate, null);
|
||||
assert.equal(def.completed_at, null);
|
||||
assert.equal(def.consent.privacy_accepted, false);
|
||||
assert.equal(def.progress.stage, "consent");
|
||||
// DEFAULT constant must match the factory.
|
||||
assert.deepEqual(def, DEFAULT_ONBOARDING_DATA);
|
||||
// Factory returns a fresh clone, not the shared constant.
|
||||
assert.notEqual(def, DEFAULT_ONBOARDING_DATA);
|
||||
}
|
||||
|
||||
// extractOnboardingData returns the default when nothing is stored.
|
||||
{
|
||||
const empty = extractOnboardingData(undefined);
|
||||
assert.equal(empty.schema_version, 3);
|
||||
assert.equal(empty.revision, 0);
|
||||
const fromNonObject = extractOnboardingData("nonsense");
|
||||
assert.equal(fromNonObject.status, "in_progress");
|
||||
}
|
||||
|
||||
// ── 2. revision conflict (optimistic concurrency) ───────────────────────────
|
||||
{
|
||||
const current = extractOnboardingData({ revision: 5 });
|
||||
// Matching revision is accepted (no throw).
|
||||
assert.doesNotThrow(() => assertOnboardingRevision(5, current));
|
||||
|
||||
// Mismatched revision throws the typed conflict.
|
||||
assert.throws(
|
||||
() => assertOnboardingRevision(4, current),
|
||||
(err) => err instanceof OnboardingRevisionConflict && err.expected === 4 && err.actual === 5,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. partial update merge (revision bump + progress stamp) ────────────────
|
||||
{
|
||||
const stored = extractOnboardingData({ revision: 2 });
|
||||
const incoming = { ...stored, revision: 2, profile: { ...stored.profile, mode: "founder" } };
|
||||
const merged = mergeOnboardingPatch({ onboarding: stored }, incoming);
|
||||
const next = extractOnboardingData(merged.onboarding);
|
||||
|
||||
assert.equal(next.revision, 3, "revision bumps by one from the stored value");
|
||||
assert.equal(next.profile.mode, "founder", "incoming profile change is applied");
|
||||
assert.ok(next.progress.updated_at, "progress.updated_at is stamped on save");
|
||||
}
|
||||
|
||||
// ── 4. status/completed_at consistency + payload derivation ─────────────────
|
||||
{
|
||||
const base = defaultOnboardingData();
|
||||
const completed = {
|
||||
...base,
|
||||
revision: 0,
|
||||
status: "completed" as const,
|
||||
profile: { ...base.profile, intent: "land-a-role", mode: "student", icp: "intern", question_branch: "student" },
|
||||
responses: {
|
||||
...base.responses,
|
||||
career_barriers: ["no-network"],
|
||||
desired_outcomes: ["interviews", "offer"],
|
||||
target_role: "Product Intern",
|
||||
target_field: "technical",
|
||||
weekly_time_commitment: "5-10",
|
||||
experience_level: "0-2",
|
||||
work_context: "audience",
|
||||
},
|
||||
// deliberately omit completed_at; merge must stamp it.
|
||||
};
|
||||
|
||||
const merged = mergeOnboardingPatch({}, completed);
|
||||
const next = extractOnboardingData(merged.onboarding);
|
||||
assert.equal(next.status, "completed");
|
||||
assert.ok(next.completed_at, "completed status without completed_at is stamped");
|
||||
|
||||
// Payload is a faithful projection — no invented completion math.
|
||||
const payload = deriveOnboardingPayload(next);
|
||||
assert.equal(payload.schema_version, 1);
|
||||
assert.equal(payload.source_revision, next.revision);
|
||||
assert.equal(payload.mode, "student");
|
||||
assert.equal(payload.onboarding_icp, "intern");
|
||||
assert.equal(payload.target_role, "Product Intern");
|
||||
assert.equal(payload.target_field, "technical");
|
||||
assert.deepEqual(payload.goals, ["interviews", "offer"]);
|
||||
assert.deepEqual(payload.barriers, ["no-network"]);
|
||||
assert.equal(payload.weekly_time_commitment, "5-10");
|
||||
assert.equal(payload.experience_context, "0-2 · audience");
|
||||
}
|
||||
|
||||
// completed_at is cleared when regressing to in_progress with an explicit null.
|
||||
{
|
||||
const completed = { ...defaultOnboardingData(), status: "completed" as const, completed_at: "2026-07-10T00:00:00.000Z" };
|
||||
const regressed = { ...completed, status: "in_progress" as const, completed_at: null };
|
||||
const merged = mergeOnboardingPatch({}, regressed);
|
||||
const next = extractOnboardingData(merged.onboarding);
|
||||
assert.equal(next.status, "in_progress");
|
||||
assert.equal(next.completed_at, null, "stale completed_at is cleared on regression");
|
||||
}
|
||||
|
||||
// ── 5. unrelated preference preservation ────────────────────────────────────
|
||||
{
|
||||
const preferences = {
|
||||
onboarding: { revision: 1, status: "in_progress" },
|
||||
interview_preferences: { focus_areas: ["behavioral"] },
|
||||
resume_preferences: { target_title: "Data Scientist" },
|
||||
mission_preferences: { active_goal: "land-offer" },
|
||||
target_roles: ["Product Intern"],
|
||||
target_companies: ["Acme"],
|
||||
};
|
||||
const incoming = { ...extractOnboardingData(preferences.onboarding), revision: 1, profile: { intent: "x", mode: "student", icp: "intern", question_branch: "student" } };
|
||||
const merged = mergeOnboardingPatch(preferences, incoming);
|
||||
|
||||
// The onboarding blob is updated.
|
||||
assert.equal(extractOnboardingData(merged.onboarding).revision, 2);
|
||||
// Every unrelated preference key is preserved verbatim.
|
||||
assert.deepEqual(merged.interview_preferences, { focus_areas: ["behavioral"] });
|
||||
assert.deepEqual(merged.resume_preferences, { target_title: "Data Scientist" });
|
||||
assert.deepEqual(merged.mission_preferences, { active_goal: "land-offer" });
|
||||
assert.deepEqual(merged.target_roles, ["Product Intern"]);
|
||||
assert.deepEqual(merged.target_companies, ["Acme"]);
|
||||
}
|
||||
|
||||
// ── 6. access_choice persistence (trial | full | null) ───────────────────────
|
||||
{
|
||||
const base = defaultOnboardingData();
|
||||
const trial = { ...base, revision: 0, access_choice: "trial" as const };
|
||||
const mergedTrial = mergeOnboardingPatch({}, trial);
|
||||
assert.equal(extractOnboardingData(mergedTrial.onboarding).access_choice, "trial");
|
||||
|
||||
const full = { ...base, revision: 0, access_choice: "full" as const };
|
||||
const mergedFull = mergeOnboardingPatch({}, full);
|
||||
assert.equal(extractOnboardingData(mergedFull.onboarding).access_choice, "full");
|
||||
|
||||
// Garbage access_choice is rejected back to null by extraction.
|
||||
const garbage = extractOnboardingData({ access_choice: "pro" });
|
||||
assert.equal(garbage.access_choice, null);
|
||||
}
|
||||
|
||||
console.log("onboarding-preferences: all assertions passed");
|
||||
176
scripts/onboarding-reset.test.ts
Normal file
176
scripts/onboarding-reset.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
COMPLETION_EVENT_TYPES,
|
||||
COMPLETION_EVENT_TYPE_ALIASES,
|
||||
SNAPSHOT_EVENT_TYPE_ALIASES,
|
||||
resetOnboardingPreferences,
|
||||
defaultOnboardingData,
|
||||
extractOnboardingData,
|
||||
ONBOARDING_LEDGER_QUERY_TYPES,
|
||||
} from "../src/events/onboarding-ledger.js";
|
||||
|
||||
/**
|
||||
* Focused tests for onboarding reset helpers (pure — no DB/network).
|
||||
* Covers: per-user reset state (preferences reset to default, unrelated keys
|
||||
* preserved), ledger type scope (only completion types deleted, snapshots
|
||||
* preserved), and bulk guard behavior (assertStagingGuard invariants).
|
||||
*
|
||||
* The async resetOnboardingLedger is exercised in integration; here we assert
|
||||
* its type-scope contract via the exported COMPLETION_EVENT_TYPES set, which is
|
||||
* exactly the WHERE clause it builds.
|
||||
*/
|
||||
|
||||
// ── 1. resetOnboardingPreferences: resets onboarding to default v3 ──────────
|
||||
{
|
||||
const preferences = {
|
||||
onboarding: {
|
||||
schema_version: 3,
|
||||
revision: 5,
|
||||
status: "completed",
|
||||
completed_at: "2026-07-10T00:00:00.000Z",
|
||||
progress: { stage: "done", branch_index: 3, updated_at: "2026-07-10T00:00:00.000Z" },
|
||||
consent: { privacy_accepted: true, accepted_at: "2026-07-09T00:00:00.000Z", terms_version: "2026-07" },
|
||||
profile: { intent: "land-a-role", mode: "student", icp: "intern", question_branch: "student" },
|
||||
responses: { career_barriers: ["x"], desired_outcomes: ["y"], current_situation: null, target_milestone: null, weekly_time_commitment: "5-10", target_role: "Intern", target_field: "tech", venture_industry: null, experience_level: "0-2", work_context: "audience" },
|
||||
import: { method: "manual", status: "not_started", resume_id: null, resume_filename: null, resume_summary: null, linkedin_profile_id: null, linkedin_url: null },
|
||||
access_choice: "trial",
|
||||
qx_estimate: 42,
|
||||
},
|
||||
interview_preferences: { focus_areas: ["behavioral"] },
|
||||
resume_preferences: { target_title: "Data Scientist" },
|
||||
target_roles: ["Product Intern"],
|
||||
};
|
||||
|
||||
const reset = resetOnboardingPreferences(preferences);
|
||||
const next = extractOnboardingData(reset.onboarding);
|
||||
|
||||
// Onboarding blob is back to defaults.
|
||||
assert.equal(next.schema_version, 3);
|
||||
assert.equal(next.revision, 0, "revision resets to 0");
|
||||
assert.equal(next.status, "in_progress", "status reverts to in_progress");
|
||||
assert.equal(next.completed_at, null, "completed_at is cleared");
|
||||
assert.equal(next.progress.stage, "consent", "progress stage resets to consent");
|
||||
assert.equal(next.access_choice, null);
|
||||
assert.equal(next.qx_estimate, null);
|
||||
assert.deepEqual(next, defaultOnboardingData());
|
||||
|
||||
// Unrelated preference keys are preserved verbatim.
|
||||
assert.deepEqual(reset.interview_preferences, { focus_areas: ["behavioral"] });
|
||||
assert.deepEqual(reset.resume_preferences, { target_title: "Data Scientist" });
|
||||
assert.deepEqual(reset.target_roles, ["Product Intern"]);
|
||||
}
|
||||
|
||||
// ── 2. resetOnboardingPreferences: idempotent on already-default prefs ─────
|
||||
{
|
||||
const empty = {};
|
||||
const reset = resetOnboardingPreferences(empty);
|
||||
assert.deepEqual(reset.onboarding, defaultOnboardingData());
|
||||
// Original object is not mutated.
|
||||
assert.equal(Object.keys(empty).length, 0, "original preferences not mutated");
|
||||
}
|
||||
// ── 3. Ledger reset scope: completion aliases (both forms) + completion snapshots
|
||||
// resetOnboardingLedger builds: DELETE FROM grow_events WHERE userId = ? AND (
|
||||
// type IN (COMPLETION_EVENT_TYPE_ALIASES)
|
||||
// OR (type IN (SNAPSHOT_EVENT_TYPE_ALIASES) AND payload-has-completion)
|
||||
// ). This pins the contract: (a) all completion aliases — dotted AND underscore —
|
||||
// are deleted so a legacy underscore completion row can't keep the gate shut;
|
||||
// (b) snapshot types are NOT blanket completion types (only completion-bearing
|
||||
// snapshots are deleted via the jsonb predicate); (c) intermediate snapshots are
|
||||
// preserved because they never satisfy the completion predicate.
|
||||
{
|
||||
// (a) Completion aliases cover BOTH dotted and underscore forms — 6 total.
|
||||
assert.equal(COMPLETION_EVENT_TYPE_ALIASES.length, 6, "3 dotted + 3 underscore completion aliases");
|
||||
for (const dotted of Object.keys(COMPLETION_EVENT_TYPES)) {
|
||||
const underscore = dotted.replaceAll(".", "_");
|
||||
assert.ok(COMPLETION_EVENT_TYPE_ALIASES.includes(dotted), `${dotted} in reset scope`);
|
||||
assert.ok(COMPLETION_EVENT_TYPE_ALIASES.includes(underscore), `${underscore} alias in reset scope`);
|
||||
}
|
||||
|
||||
// (b) Snapshot types are handled in a SEPARATE phase, never as completion types.
|
||||
assert.equal(COMPLETION_EVENT_TYPES["onboarding.snapshot.saved"], undefined,
|
||||
"snapshot must never be a blanket completion type");
|
||||
assert.equal(SNAPSHOT_EVENT_TYPE_ALIASES.includes("onboarding.snapshot.saved"), true,
|
||||
"dotted snapshot alias is in the snapshot reset phase");
|
||||
assert.equal(SNAPSHOT_EVENT_TYPE_ALIASES.includes("onboarding_snapshot_saved"), true,
|
||||
"underscore snapshot alias is in the snapshot reset phase");
|
||||
|
||||
// (c) The reset completion set is a strict subset of the status-query list:
|
||||
// status reads ALL aliases + snapshots; reset deletes only completions and
|
||||
// completion-bearing snapshots, preserving intermediate saves.
|
||||
for (const alias of COMPLETION_EVENT_TYPE_ALIASES) {
|
||||
assert.ok((ONBOARDING_LEDGER_QUERY_TYPES as readonly string[]).includes(alias),
|
||||
`${alias} is also queryable for status`);
|
||||
}
|
||||
}
|
||||
// ── 4. Bulk guard behavior: real spawn of assertStagingGuard ───────────────
|
||||
// Spawns the actual CLI with controlled env and asserts exit code 2 (abort)
|
||||
// for each independent failure leg. We only assert abort cases — the
|
||||
// all-flags-pass case would proceed past the guard and hit the network/DB.
|
||||
{
|
||||
const script = "scripts/onboarding-bulk-reset.ts";
|
||||
|
||||
// Leg 1: production env (even with all other flags) must abort.
|
||||
const prod = spawnSync("npx", ["tsx", script, "--confirm"], {
|
||||
env: { ...process.env, NODE_ENV: "production", ONBOARDING_BULK_RESET_ALLOWED: "true" },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(prod.status, 2, "production must abort even with all flags");
|
||||
assert.match(prod.stderr, /NODE_ENV must be "staging"/, "production abort names the env failure");
|
||||
|
||||
// Leg 2: staging + allowed but missing --confirm must abort.
|
||||
const noConfirm = spawnSync("npx", ["tsx", script], {
|
||||
env: { ...process.env, NODE_ENV: "staging", ONBOARDING_BULK_RESET_ALLOWED: "true" },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(noConfirm.status, 2, "staging without --confirm must abort");
|
||||
assert.match(noConfirm.stderr, /--confirm/, "missing-confirm abort names the confirm failure");
|
||||
|
||||
// Leg 3: staging + confirm but no opt-in flag must abort.
|
||||
const noFlag = spawnSync("npx", ["tsx", script, "--confirm"], {
|
||||
env: { ...process.env, NODE_ENV: "staging", ONBOARDING_BULK_RESET_ALLOWED: "false" },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(noFlag.status, 2, "staging without opt-in flag must abort");
|
||||
assert.match(noFlag.stderr, /ONBOARDING_BULK_RESET_ALLOWED/, "missing-flag abort names the flag failure");
|
||||
}
|
||||
|
||||
// ── 5. Bulk CLI structure: A2A endpoint + Phase 2 gating (source read) ──────
|
||||
// The CLI's network/DB behavior is exercised in integration. Here we assert
|
||||
// the structural contract by reading the script source directly: (a) it calls
|
||||
// the user-service A2A onboarding-reset endpoint (not the backend DELETE
|
||||
// route), (b) it authenticates with a2aAllowedKey (not serviceToken), (c) it
|
||||
// treats 404 as a skip, and (d) it gates the backend ledger reset on Phase 1
|
||||
// success-or-skip so a pref error never leaves inconsistent state.
|
||||
{
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, "onboarding-bulk-reset.ts"), "utf8");
|
||||
|
||||
// (a) Calls the user-service A2A onboarding-reset endpoint by path.
|
||||
assert.match(src, /\/api\/v1\/users\/onboarding-reset/, "CLI must call the user-service A2A reset endpoint");
|
||||
// Must NOT call the backend DELETE /users/onboarding route (old impersonation path).
|
||||
assert.doesNotMatch(src, /method:\s*["']DELETE["']/, "CLI must not use the backend DELETE route");
|
||||
|
||||
// (b) Authenticates with the A2A key, not the per-user service token.
|
||||
assert.match(src, /a2aAllowedKey/, "CLI must use config.a2aAllowedKey for A2A auth");
|
||||
assert.doesNotMatch(src, /serviceToken/, "CLI must not reference serviceToken (per-user impersonation path)");
|
||||
|
||||
// (c) Treats a 404 from user-service as a documented skip (backend-only user).
|
||||
assert.match(src, /status === 404/, "CLI must detect 404 from user-service");
|
||||
assert.match(src, /backend-only/, "CLI must document 404 as backend-only skip");
|
||||
|
||||
// (d) Phase 2 ledger reset is gated on Phase 1 success-or-skip.
|
||||
assert.match(src, /!pref\.ok && !pref\.skipped/, "CLI must skip ledger when pref errored (not ok, not skipped)");
|
||||
assert.match(src, /ledgerBlocked/, "CLI must count pref-blocked ledger skips");
|
||||
|
||||
// (e) Calls resetOnboardingLedger directly (not via HTTP).
|
||||
assert.match(src, /import \{[^}]*resetOnboardingLedger[^}]*\} from/, "CLI must import resetOnboardingLedger directly");
|
||||
|
||||
// (f) Aggregate-only output — no per-user email/PII in the loop.
|
||||
assert.doesNotMatch(src, /u\.email/, "CLI must not emit per-user email (PII)");
|
||||
}
|
||||
console.log("onboarding-reset tests passed");
|
||||
process.exit(0);
|
||||
112
scripts/redis-gating.test.ts
Normal file
112
scripts/redis-gating.test.ts
Normal file
@@ -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);
|
||||
106
scripts/service-ingest-projector.test.ts
Normal file
106
scripts/service-ingest-projector.test.ts
Normal file
@@ -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);
|
||||
238
scripts/signal-registry.test.ts
Normal file
238
scripts/signal-registry.test.ts
Normal file
@@ -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);
|
||||
95
scripts/streak-policy.test.ts
Normal file
95
scripts/streak-policy.test.ts
Normal file
@@ -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);
|
||||
97
scripts/user-event-actor-advance.test.ts
Normal file
97
scripts/user-event-actor-advance.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { isProcessableStatus } from "../src/actors/events/user-event-actor.js";
|
||||
|
||||
/**
|
||||
* Regression: one failed event must not block the next event in the queue.
|
||||
*
|
||||
* Before the fix, the actor's run loop used loopCtx.step + manual try/catch
|
||||
* that rethrew on failure. The rethrow stalled the ctx.loop, preventing
|
||||
* subsequent queued messages from being processed.
|
||||
*
|
||||
* After the fix, the loop uses tryStep (failures caught, event marked terminal)
|
||||
* and a DB status guard (isProcessableStatus) that only skips truly inert rows.
|
||||
*
|
||||
* Dual-path rationale: the synchronous ingress paths (routes/events.ts,
|
||||
* redis-consumer.ts, v1/events/events-routes.ts) run projections and mark the
|
||||
* event "processed". The actor runs mission reducers (stage patches, artifacts,
|
||||
* actions) that ONLY exist in the actor. So the actor must still process
|
||||
* "processed" events to run mission reducers — projections are idempotent so
|
||||
* re-running them is safe, and the in-memory processedEventIds guard prevents
|
||||
* double-processing within one actor lifetime. The routing gate
|
||||
* (shouldRouteGrowEvent) prevents saturation from duplicate ingests; this guard
|
||||
* is defense-in-depth that only blocks "failed" and "unresolved".
|
||||
*/
|
||||
|
||||
// ── 1. Pending → processable (first attempt) ─────────────────────────────────
|
||||
assert.equal(isProcessableStatus("pending"), true, "pending events must be processable");
|
||||
|
||||
// ── 2. Processing → processable (crash recovery: retry idempotent work) ──────
|
||||
// A prior attempt called markGrowEventProcessing then crashed before completing.
|
||||
// The actor restarts, replays the queue message, and must retry.
|
||||
assert.equal(isProcessableStatus("processing"), true, "processing events must be retryable after a crash");
|
||||
|
||||
// ── 3. Processed → processable (actor must run mission reducers) ─────────────
|
||||
// The sync ingress path marks "processed" after projections. The actor still
|
||||
// needs to run mission reducers — the only place they exist. Projections
|
||||
// re-running is safe (idempotent). processedEventIds prevents in-memory dupes.
|
||||
assert.equal(
|
||||
isProcessableStatus("processed"),
|
||||
true,
|
||||
"processed events must be processable — actor must run mission reducers that only exist in the actor",
|
||||
);
|
||||
|
||||
// ── 4. Failed → NOT processable (terminal, do not retry) ─────────────────────
|
||||
// A per-event failure was already marked terminal by the record-failure step.
|
||||
assert.equal(isProcessableStatus("failed"), false, "failed events must be skipped");
|
||||
|
||||
// ── 5. Unresolved → NOT processable (no userId, nothing to do) ───────────────
|
||||
assert.equal(isProcessableStatus("unresolved"), false, "unresolved events must be skipped");
|
||||
|
||||
// ── 6. Null/undefined → NOT processable (defensive) ──────────────────────────
|
||||
assert.equal(isProcessableStatus(null), false, "null status must be skipped");
|
||||
assert.equal(isProcessableStatus(undefined), false, "undefined status must be skipped");
|
||||
|
||||
// ── 7. Unknown status → NOT processable (defensive) ──────────────────────────
|
||||
assert.equal(isProcessableStatus("unknown"), false, "unknown status must be skipped");
|
||||
|
||||
// ── 8. Queue advancement simulation ──────────────────────────────────────────
|
||||
// Queue: [pending, processed, pending]. Actor processes ALL three — the
|
||||
// "processed" one still gets mission reducers. The key point: none of them
|
||||
// block the next. This proves one event does not stall the queue.
|
||||
{
|
||||
const statuses = ["pending", "processed", "pending"] as const;
|
||||
const processable = statuses.map((s) => isProcessableStatus(s));
|
||||
assert.deepEqual(
|
||||
processable,
|
||||
[true, true, true],
|
||||
"actor must process all three — processed event still runs mission reducers, none block the next",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 9. Failed event does not block the next ──────────────────────────────────
|
||||
// Queue: [failed, pending]. Actor skips the failed one and processes the next.
|
||||
// This is the core regression: one failed event must not block subsequent events.
|
||||
{
|
||||
const statuses = ["failed", "pending"] as const;
|
||||
const processable = statuses.map((s) => isProcessableStatus(s));
|
||||
assert.deepEqual(
|
||||
processable,
|
||||
[false, true],
|
||||
"actor must skip failed event and process the next pending event — failed does not block",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 10. Crash-recovery queue simulation ──────────────────────────────────────
|
||||
// Queue: [processing (crashed), pending]. Actor retries #1 and processes #2.
|
||||
{
|
||||
const statuses = ["processing", "pending"] as const;
|
||||
const processable = statuses.map((s) => isProcessableStatus(s));
|
||||
assert.deepEqual(
|
||||
processable,
|
||||
[true, true],
|
||||
"actor must retry a crashed 'processing' event AND process the next pending event",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("user-event-actor-advance tests passed");
|
||||
process.exit(0);
|
||||
165
scripts/user-profile.test.ts
Normal file
165
scripts/user-profile.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js";
|
||||
|
||||
/**
|
||||
* Regression test for the PATCH /onboarding 500.
|
||||
*
|
||||
* Root cause: the PATCH handler parsed its JSON body via `c.req.json()`
|
||||
* (consuming `c.req.raw` / setting bodyUsed), then called fetchUserProfile
|
||||
* with the same Request. fetchUserProfile replayed the inbound method+body to
|
||||
* user-service /me, so fetchUserService did `await req.arrayBuffer()` on an
|
||||
* already-consumed body → `TypeError: Body is unusable` → uncaught → global
|
||||
* onError → {"error":"internal"}, 500. GET /onboarding worked only because it
|
||||
* never reads a body, so fetchUserService skipped arrayBuffer.
|
||||
*
|
||||
* This test reproduces the exact precondition — consume the body first — and
|
||||
* asserts the fix: fetchUserProfile issues an explicit GET that never touches
|
||||
* the inbound Request's body. It would have thrown before the fix and passes
|
||||
* after.
|
||||
*/
|
||||
|
||||
// Capture the outgoing fetch call.
|
||||
type FetchCall = { method: string; url: string; bodyTouched: boolean };
|
||||
|
||||
async function runFetchUserProfileAfterBodyConsumed(): Promise<{
|
||||
profile: Record<string, unknown>;
|
||||
call: FetchCall;
|
||||
}> {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured: FetchCall | null = null;
|
||||
|
||||
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer test" },
|
||||
body: JSON.stringify({ data: { access_choice: "trial" }, expectedRevision: 0 }),
|
||||
});
|
||||
|
||||
// Simulate what the PATCH handler does: parse the JSON body. This marks
|
||||
// req.bodyUsed = true — the precondition that triggered the 500.
|
||||
await req.json();
|
||||
|
||||
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
||||
const target = input instanceof URL ? input : new URL(input instanceof Request ? input.url : String(input));
|
||||
// Detect if the caller tried to re-read the original (consumed) Request body.
|
||||
let bodyTouched = false;
|
||||
if (input instanceof Request) {
|
||||
try {
|
||||
await input.clone().arrayBuffer();
|
||||
bodyTouched = input.bodyUsed;
|
||||
} catch {
|
||||
bodyTouched = true; // Body is unusable — the original bug.
|
||||
}
|
||||
}
|
||||
captured = { method: init?.method ?? input.method ?? "GET", url: target.href, bodyTouched };
|
||||
return new Response(JSON.stringify({ preferences: { onboarding: { access_choice: "trial" } } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const profile = await fetchUserProfile(req);
|
||||
assert(captured, "fetch was never called");
|
||||
return { profile, call: captured };
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. no throw when inbound body was already consumed ──────────────────────
|
||||
{
|
||||
const { call } = await runFetchUserProfileAfterBodyConsumed();
|
||||
assert.equal(call.bodyTouched, false, "fetchUserProfile must not re-read the inbound Request body");
|
||||
}
|
||||
|
||||
// ── 2. outgoing method is GET (a read, never PATCH) ─────────────────────────
|
||||
{
|
||||
const { call } = await runFetchUserProfileAfterBodyConsumed();
|
||||
assert.equal(call.method, "GET", "fetchUserProfile must issue GET /me regardless of inbound method");
|
||||
}
|
||||
|
||||
// ── 3. target is /api/v1/users/me and profile parses ───────────────────────
|
||||
{
|
||||
const { profile, call } = await runFetchUserProfileAfterBodyConsumed();
|
||||
assert.ok(call.url.endsWith("/api/v1/users/me"), `expected /me URL, got ${call.url}`);
|
||||
assert.deepEqual(profile, { preferences: { onboarding: { access_choice: "trial" } } });
|
||||
}
|
||||
|
||||
// ── 4. content-type header is stripped (GET has no body) ────────────────────
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedHeaders: Headers | null = null;
|
||||
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
||||
capturedHeaders = new Headers(init?.headers ?? {});
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
await req.json();
|
||||
await fetchUserProfile(req);
|
||||
assert.ok(capturedHeaders, "fetch was never called");
|
||||
assert.equal(capturedHeaders!.get("content-type"), null, "content-type must be stripped from GET /me");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. non-2xx surfaces a thrown error (caller handles) ─────────────────────
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => new Response("nope", { status: 503 })) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/api/growqr/users/onboarding", { method: "GET" });
|
||||
await assert.rejects(
|
||||
() => fetchUserProfile(req),
|
||||
/user-service \/me fetch failed: 503/,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. synthetic PATCH /me must not carry a stale content-length ───────────
|
||||
// Regression for the second PATCH 500: patchPreferencesRequest copied inbound
|
||||
// headers including content-length, then set a DIFFERENT body — undici kept the
|
||||
// stale length, user-service waited for bytes that never arrive, and closed
|
||||
// the socket (~2.8s "other side closed"). The Request constructor does NOT
|
||||
// recompute content-length when an explicit body is provided alongside
|
||||
// forwarded headers, so the deletes inside patchPreferencesRequest are
|
||||
// load-bearing. This tests the REAL exported function.
|
||||
{
|
||||
// Inbound request with a body whose length differs from the synthetic body.
|
||||
const inboundBody = JSON.stringify({ data: { access_choice: "trial" }, expectedRevision: 0 });
|
||||
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(Buffer.byteLength(inboundBody)),
|
||||
"transfer-encoding": "chunked",
|
||||
},
|
||||
body: inboundBody,
|
||||
});
|
||||
|
||||
const preferences = { onboarding: { access_choice: "trial" } };
|
||||
const synthetic = patchPreferencesRequest(req, preferences);
|
||||
|
||||
// The synthetic request must not carry the stale inbound length/transfer headers.
|
||||
assert.equal(synthetic.headers.get("content-length"), null, "stale content-length must be stripped from synthetic PATCH /me");
|
||||
assert.equal(synthetic.headers.get("transfer-encoding"), null, "transfer-encoding must be stripped from synthetic PATCH /me");
|
||||
assert.equal(synthetic.headers.get("host"), null, "host must be stripped");
|
||||
assert.equal(synthetic.headers.get("cookie"), null, "cookie must be stripped");
|
||||
assert.equal(synthetic.headers.get("content-type"), "application/json", "content-type must be set");
|
||||
assert.equal(synthetic.method, "PATCH", "method must be PATCH");
|
||||
assert.ok(synthetic.url.endsWith("/api/v1/users/me"), `target must be /me, got ${synthetic.url}`);
|
||||
|
||||
// The body must be the synthetic preferences blob, not the inbound payload.
|
||||
const sentBody = await synthetic.text();
|
||||
assert.deepEqual(JSON.parse(sentBody), { preferences }, "synthetic body must carry only { preferences }");
|
||||
assert.notEqual(sentBody.length, Buffer.byteLength(inboundBody), "synthetic body length must differ from inbound (else the test proves nothing)");
|
||||
}
|
||||
|
||||
console.log("user-profile: all assertions passed");
|
||||
@@ -4,13 +4,16 @@ import { db } from "../../db/client.js";
|
||||
import {
|
||||
growActiveMissions,
|
||||
growEvents,
|
||||
growQscoreLatest,
|
||||
growQscoreProjectionState,
|
||||
growQscoreSignals,
|
||||
missionActions,
|
||||
} from "../../db/schema.js";
|
||||
import { listActiveMissionsPg } from "../../grow/persistence.js";
|
||||
import { listMissionActions } from "../../missions/actions.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function scalarCount(table: any, where?: any) {
|
||||
const query = db.select({ value: count() }).from(table);
|
||||
@@ -69,37 +72,37 @@ async function platformAnalytics() {
|
||||
}
|
||||
|
||||
async function userQscoreAnalytics(userId: string) {
|
||||
const [projection] = await db
|
||||
.select()
|
||||
.from(growQscoreProjectionState)
|
||||
.where(eq(growQscoreProjectionState.userId, userId))
|
||||
.limit(1);
|
||||
const result = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
|
||||
|
||||
const latestSignals = await db
|
||||
.select()
|
||||
.from(growQscoreLatest)
|
||||
.where(eq(growQscoreLatest.userId, userId))
|
||||
.orderBy(desc(growQscoreLatest.updatedAt))
|
||||
.limit(50);
|
||||
|
||||
const signalTimeline = await db
|
||||
.select()
|
||||
.from(growQscoreSignals)
|
||||
.where(eq(growQscoreSignals.userId, userId))
|
||||
.orderBy(desc(growQscoreSignals.occurredAt))
|
||||
.limit(100);
|
||||
const breakdown = result?.breakdown ?? {};
|
||||
const latestSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
|
||||
const timelineRaw = Array.isArray(breakdown.signalTimeline) ? breakdown.signalTimeline : latestSignals;
|
||||
const signalTimeline = timelineRaw.map((item) => {
|
||||
const s = isRecord(item) ? item : {};
|
||||
return {
|
||||
signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "",
|
||||
score: typeof s.score === "number" ? s.score : 0,
|
||||
present: typeof s.present === "boolean" ? s.present : true,
|
||||
source: typeof s.source === "string" ? s.source : "",
|
||||
occurredAt: typeof s.occurredAt === "string" ? s.occurredAt : result?.calculated_at ?? new Date().toISOString(),
|
||||
updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : result?.calculated_at ?? new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "user-qscore",
|
||||
userId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
current: projection
|
||||
current: result
|
||||
? {
|
||||
score: projection.score,
|
||||
signalCount: projection.signalCount,
|
||||
dimensions: projection.dimensions,
|
||||
summary: projection.summary,
|
||||
updatedAt: projection.updatedAt.toISOString(),
|
||||
score: result.q_score,
|
||||
iq_score: result.iq_score,
|
||||
eq_score: result.eq_score,
|
||||
sq_score: result.sq_score,
|
||||
signalCount: signalTimeline.length,
|
||||
dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : result.quotients,
|
||||
summary: typeof breakdown.summary === "string" ? breakdown.summary : null,
|
||||
updatedAt: result.calculated_at || new Date().toISOString(),
|
||||
}
|
||||
: null,
|
||||
latestSignals,
|
||||
|
||||
@@ -117,6 +117,24 @@ async function applyArtifactPatches(input: {
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* DB status guard for the actor run loop. A queued event is processable unless
|
||||
* it is "failed" (terminal error) or "unresolved" (no userId). All other states
|
||||
* — "pending" (first attempt), "processing" (crash recovery), and "processed"
|
||||
* (synchronous projections already ran) — are allowed so the actor can run
|
||||
* mission reducers that only exist in the actor. Projections are idempotent,
|
||||
* and the in-memory processedEventIds guard prevents double-processing within
|
||||
* one actor lifetime. The routing gate (shouldRouteGrowEvent) already prevents
|
||||
* saturation from duplicate ingests; this guard is defense-in-depth.
|
||||
*/
|
||||
export function isProcessableStatus(
|
||||
status: string | null | undefined,
|
||||
): boolean {
|
||||
return status === "pending" || status === "processing" || status === "processed";
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const userEventActor = actor({
|
||||
options: { name: "User Event Parser", icon: "route", noSleep: true, actionTimeout: 300_000 },
|
||||
state: { userId: "", processedCount: 0, processedEventIds: [] } as UserEventActorState,
|
||||
@@ -143,17 +161,52 @@ export const userEventActor = actor({
|
||||
const message = await loopCtx.queue.next("wait-event", { names: ["events"] });
|
||||
const cmd = message.body as UserEventCommand;
|
||||
|
||||
await loopCtx.step(`process-event:${cmd.eventId}`, async () => {
|
||||
if (loopCtx.state.processedEventIds.includes(cmd.eventId)) return;
|
||||
await markGrowEventProcessing(cmd.eventId);
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.id, cmd.eventId), eq(growEvents.userId, cmd.userId)))
|
||||
.limit(1);
|
||||
if (!row) throw new Error(`grow event not found for user: ${cmd.eventId}`);
|
||||
// Pre-flight skip: check the DB status without claiming. Terminal
|
||||
// (processed/failed) or unresolved rows are advanced past immediately
|
||||
// so one settled event does not block the rest of the queue. The actual
|
||||
// claim (markGrowEventProcessing) happens INSIDE tryStep's run so the
|
||||
// retry unit is atomic: each attempt re-checks status before claiming.
|
||||
const [preflight] = await db
|
||||
.select({ processingStatus: growEvents.processingStatus })
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.id, cmd.eventId), eq(growEvents.userId, cmd.userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!preflight || !isProcessableStatus(preflight.processingStatus)) {
|
||||
await loopCtx.step(`skip-event:${cmd.eventId}`, async () => {
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process with tryStep so a failure is caught (not rethrown) and the
|
||||
// loop advances to the next message. Mirrors workflow-run-actor.ts.
|
||||
const result = await loopCtx.tryStep({
|
||||
name: `process-event:${cmd.eventId}`,
|
||||
maxRetries: 3,
|
||||
retryBackoffBase: 1_000,
|
||||
retryBackoffMax: 30_000,
|
||||
timeout: 300_000,
|
||||
run: async () => {
|
||||
if (loopCtx.state.processedEventIds.includes(cmd.eventId)) {
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
return;
|
||||
}
|
||||
// Atomic guard+claim: re-check status then mark processing inside the
|
||||
// retry unit. A prior attempt that crashed after claiming leaves the
|
||||
// row "processing" — isProcessableStatus allows retry (projections
|
||||
// are idempotent).
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.id, cmd.eventId), eq(growEvents.userId, cmd.userId)))
|
||||
.limit(1);
|
||||
if (!row) throw new Error(`grow event not found for user: ${cmd.eventId}`);
|
||||
if (!isProcessableStatus(row.processingStatus)) return;
|
||||
await markGrowEventProcessing(cmd.eventId);
|
||||
|
||||
try {
|
||||
await applyServiceSessionProjection(row);
|
||||
const qscoreResult = await applyQscoreProjection(row);
|
||||
const activeRows = await listActiveMissionsPg(cmd.userId);
|
||||
@@ -211,14 +264,26 @@ export const userEventActor = actor({
|
||||
loopCtx.state.processedEventIds = [cmd.eventId, ...loopCtx.state.processedEventIds.filter((id) => id !== cmd.eventId)].slice(0, 500);
|
||||
loopCtx.broadcast("eventProcessed", { eventId: cmd.eventId, userId: cmd.userId });
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
} catch (err) {
|
||||
await markGrowEventFailed(cmd.eventId, err);
|
||||
loopCtx.state.lastError = err instanceof Error ? err.message : String(err);
|
||||
},
|
||||
catch: ["timeout", "exhausted"],
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
// Per-event failure: mark terminal and advance. The loop continues to
|
||||
// the next queued message — one bad event does not block the rest.
|
||||
await loopCtx.step(`record-failure:${cmd.eventId}`, async () => {
|
||||
await markGrowEventFailed(cmd.eventId, result.failure.error);
|
||||
loopCtx.state.lastError = result.failure.error instanceof Error
|
||||
? result.failure.error.message
|
||||
: String(result.failure.error);
|
||||
loopCtx.state.updatedAt = new Date().toISOString();
|
||||
loopCtx.broadcast("updated", loopCtx.state);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}, {
|
||||
onError: async (_ctx, event) => {
|
||||
console.error("user-event-actor workflow error", event);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ function getInterviewActionRoute(task: DailyMissionTask) {
|
||||
params.set("difficulty", source.searchParams.get("difficulty") ?? "medium");
|
||||
params.set("media", "video");
|
||||
params.set("source", "daily-mission");
|
||||
return `/agents/interview/preview?${params.toString()}`;
|
||||
return `/agents/interview?${params.toString()}`;
|
||||
}
|
||||
|
||||
function compactAnswer(answer: string) {
|
||||
|
||||
@@ -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,16 +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 ?? "",
|
||||
// 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.
|
||||
@@ -155,3 +197,4 @@ export const config = {
|
||||
|
||||
required, // exported so other modules can fail fast on boot
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ async function ensureGrowConversationsMetadataColumn() {
|
||||
`);
|
||||
}
|
||||
|
||||
async function ensureUserPlanColumn() {
|
||||
await db.execute(`ALTER TABLE users ADD COLUMN IF NOT EXISTS plan text NOT NULL DEFAULT 'free'`);
|
||||
}
|
||||
|
||||
async function ensureSystemNotificationsTables() {
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS system_notifications (
|
||||
@@ -41,8 +45,24 @@ async function ensureSystemNotificationsTables() {
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS system_notification_preferences_user_idx ON system_notification_preferences (user_id)`);
|
||||
}
|
||||
|
||||
async function ensureOnboardingTable() {
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS onboarding (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE cascade,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS onboarding_user_idx ON onboarding (user_id)`);
|
||||
}
|
||||
|
||||
export async function ensureRuntimeSchema() {
|
||||
await ensureUserPlanColumn();
|
||||
await ensureGrowConversationsMetadataColumn();
|
||||
await ensureSystemNotificationsTables();
|
||||
await ensureOnboardingTable();
|
||||
log.info("runtime schema ensured");
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const users = pgTable(
|
||||
id: text("id").primaryKey(),
|
||||
email: text("email").notNull(),
|
||||
displayName: text("display_name"),
|
||||
plan: text("plan", { enum: ["free", "pro", "enterprise"] }).notNull().default("free"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
@@ -32,6 +33,25 @@ export const users = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
// Canonical onboarding state. `data` is the user-confirmed source of truth;
|
||||
// `payload` is the server-owned projection consumed by Curator and Home.
|
||||
export const onboarding = pgTable(
|
||||
"onboarding",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
data: jsonb("data").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: uniqueIndex("onboarding_user_idx").on(t.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
// One per user. Tracks the user's unified agent's container stack + Git repo.
|
||||
// Per changes.md §2A: per-user Gitea containers removed; central Gitea shared.
|
||||
// Per changes.md §5: ONE actor per user manages the full orchestration layer.
|
||||
|
||||
@@ -20,7 +20,7 @@ function normalizeSubject(value: unknown) {
|
||||
|
||||
function normalizeMission(value: unknown) {
|
||||
const record = asRecord(value);
|
||||
const instanceId = getString(record.instanceId ?? record.instance_id ?? record.mission_instance_id);
|
||||
const instanceId = getString(record.instanceId ?? record.instance_id ?? record.missionInstanceId ?? record.mission_instance_id);
|
||||
const missionId = getString(record.missionId ?? record.mission_id);
|
||||
const stageId = getString(record.stageId ?? record.stage_id);
|
||||
if (!instanceId && !missionId && !stageId) return undefined;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents, type GrowEventRow } from "../db/schema.js";
|
||||
import { asRecord } from "./envelope.js";
|
||||
@@ -38,11 +38,23 @@ export type OnboardingStatusEvent = {
|
||||
processingStatus: string;
|
||||
};
|
||||
|
||||
const COMPLETION_EVENT_TYPES = new Set<string>([
|
||||
"onboarding.completed",
|
||||
"user.onboarding.completed",
|
||||
"profile.onboarding.completed",
|
||||
]);
|
||||
export const COMPLETION_EVENT_TYPES: Record<string, true> = {
|
||||
"onboarding.completed": true,
|
||||
"user.onboarding.completed": true,
|
||||
"profile.onboarding.completed": true,
|
||||
};
|
||||
|
||||
// Every ledger row whose type (dotted OR underscore alias) marks a completion.
|
||||
// resetOnboardingLedger deletes all of these so the gate reopens regardless of
|
||||
// which alias form a legacy emitter wrote.
|
||||
export const COMPLETION_EVENT_TYPE_ALIASES: readonly string[] = [
|
||||
...Object.keys(COMPLETION_EVENT_TYPES),
|
||||
...Object.keys(COMPLETION_EVENT_TYPES).map((t) => t.replaceAll(".", "_")),
|
||||
];
|
||||
|
||||
// Snapshot ledger types in both alias forms. Only completion-bearing snapshots
|
||||
// are reset; intermediate step saves are preserved.
|
||||
export const SNAPSHOT_EVENT_TYPE_ALIASES = ["onboarding.snapshot.saved", "onboarding_snapshot_saved"] as const;
|
||||
|
||||
export function normalizeOnboardingEventType(type: string) {
|
||||
return type.toLowerCase().replaceAll("_", ".");
|
||||
@@ -72,7 +84,7 @@ export function completedAtFromOnboardingPayload(payload: Record<string, unknown
|
||||
|
||||
export function isValidOnboardingLedgerEvent(event: Pick<GrowEventRow, "type" | "payload">) {
|
||||
const normalizedType = normalizeOnboardingEventType(event.type);
|
||||
if (COMPLETION_EVENT_TYPES.has(normalizedType)) return true;
|
||||
if (COMPLETION_EVENT_TYPES[normalizedType]) return true;
|
||||
|
||||
// Snapshots are status-valid only when they are completion snapshots. Plain
|
||||
// intermediate step saves must not let a new seeker bypass onboarding.
|
||||
@@ -125,7 +137,7 @@ function onboardingContextFromInput(context?: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
|
||||
export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow, contextOverride?: Record<string, unknown>) {
|
||||
if (!event.userId || !isValidOnboardingLedgerEvent(event)) {
|
||||
return {
|
||||
qscoreBaselineSeeded: false,
|
||||
@@ -145,7 +157,7 @@ export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
|
||||
completedAt,
|
||||
sourceEventId: event.id,
|
||||
source: event.source,
|
||||
context: onboardingContextFromInput(event.payload),
|
||||
context: contextOverride ?? onboardingContextFromInput(event.payload),
|
||||
});
|
||||
const missions = {
|
||||
status: "skipped" as const,
|
||||
@@ -188,7 +200,7 @@ export async function recordAndProcessOnboardingCompletion(input: {
|
||||
await markGrowEventProcessing(event.id);
|
||||
}
|
||||
|
||||
const sideEffects = await ensureOnboardingSideEffectsForEvent(event);
|
||||
const sideEffects = await ensureOnboardingSideEffectsForEvent(event, context);
|
||||
if (sideEffects.curatorOnboarding.status === "skipped" && sideEffects.curatorOnboarding.reason === "loop_failed") {
|
||||
await markGrowEventFailed(event.id, new Error("curator_onboarding_loop_failed"));
|
||||
} else if (event.processingStatus !== "processed") {
|
||||
@@ -197,3 +209,329 @@ export async function recordAndProcessOnboardingCompletion(input: {
|
||||
|
||||
return { event, ...sideEffects };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Onboarding preferences helpers (pure — no DB, no network).
|
||||
//
|
||||
// The canonical onboarding store is `preferences.onboarding` on the user-service
|
||||
// profile, shaped as the v3 OnboardingData object (schema_version: 3). These
|
||||
// helpers read/validate/merge that blob and derive the faithful projection used
|
||||
// by the curator. Completion percentages and QX estimates are OWNED by the
|
||||
// dashboard (branch-aware); the backend persists the client-supplied values and
|
||||
// never recomputes them.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type OnboardingAccessChoice = "trial" | "full" | null;
|
||||
|
||||
export type OnboardingData = {
|
||||
schema_version: 3;
|
||||
revision: number;
|
||||
status: "in_progress" | "completed";
|
||||
progress: { stage: string; branch_index: number; updated_at: string | null };
|
||||
consent: { privacy_accepted: boolean; accepted_at: string | null; terms_version: string };
|
||||
profile: {
|
||||
intent: string | null;
|
||||
mode: string | null;
|
||||
icp: string | null;
|
||||
question_branch: string | null;
|
||||
};
|
||||
responses: {
|
||||
current_situation: string | null;
|
||||
career_barriers: string[];
|
||||
desired_outcomes: string[];
|
||||
target_milestone: string | null;
|
||||
weekly_time_commitment: string | null;
|
||||
target_role: string | null;
|
||||
target_field: string | null;
|
||||
venture_industry: string | null;
|
||||
experience_level: string | null;
|
||||
work_context: string | null;
|
||||
};
|
||||
import: {
|
||||
method: string;
|
||||
status: string;
|
||||
resume_id: string | null;
|
||||
resume_filename: string | null;
|
||||
resume_summary: string | null;
|
||||
linkedin_profile_id: string | null;
|
||||
linkedin_url: string | null;
|
||||
};
|
||||
access_choice: OnboardingAccessChoice;
|
||||
qx_estimate: number | null;
|
||||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export type OnboardingPayload = {
|
||||
schema_version: 1;
|
||||
source_revision: number;
|
||||
primary_intent: string | null;
|
||||
mode: string | null;
|
||||
question_branch: string | null;
|
||||
onboarding_icp: string | null;
|
||||
curator_registry_icp: string | null;
|
||||
target_role: string | null;
|
||||
target_field: string | null;
|
||||
experience_context: string | null;
|
||||
goals: string[];
|
||||
barriers: string[];
|
||||
priority: string | null;
|
||||
weekly_time_commitment: string | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_ONBOARDING_DATA: OnboardingData = {
|
||||
schema_version: 3,
|
||||
revision: 0,
|
||||
status: "in_progress",
|
||||
progress: { stage: "consent", branch_index: 0, updated_at: null },
|
||||
consent: { privacy_accepted: false, accepted_at: null, terms_version: "2026-07" },
|
||||
profile: { intent: null, mode: null, icp: null, question_branch: null },
|
||||
responses: {
|
||||
current_situation: null,
|
||||
career_barriers: [],
|
||||
desired_outcomes: [],
|
||||
target_milestone: null,
|
||||
weekly_time_commitment: null,
|
||||
target_role: null,
|
||||
target_field: null,
|
||||
venture_industry: null,
|
||||
experience_level: null,
|
||||
work_context: null,
|
||||
},
|
||||
import: {
|
||||
method: "manual",
|
||||
status: "not_started",
|
||||
resume_id: null,
|
||||
resume_filename: null,
|
||||
resume_summary: null,
|
||||
linkedin_profile_id: null,
|
||||
linkedin_url: null,
|
||||
},
|
||||
access_choice: null,
|
||||
qx_estimate: null,
|
||||
completed_at: null,
|
||||
};
|
||||
|
||||
export function defaultOnboardingData(): OnboardingData {
|
||||
return structuredClone(DEFAULT_ONBOARDING_DATA);
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an arbitrary stored value into a valid v3 OnboardingData, filling gaps
|
||||
* from the defaults. Rejects non-object shapes and falls back to defaults so the
|
||||
* route always returns a well-formed v3 response (never throws).
|
||||
*/
|
||||
export function extractOnboardingData(stored: unknown): OnboardingData {
|
||||
const base = defaultOnboardingData();
|
||||
if (!isObject(stored)) return base;
|
||||
|
||||
const progress = isObject(stored.progress) ? stored.progress : {};
|
||||
const consent = isObject(stored.consent) ? stored.consent : {};
|
||||
const profile = isObject(stored.profile) ? stored.profile : {};
|
||||
const responses = isObject(stored.responses) ? stored.responses : {};
|
||||
const imp = isObject(stored.import) ? stored.import : {};
|
||||
|
||||
const rev = typeof stored.revision === "number" && Number.isFinite(stored.revision) ? stored.revision : base.revision;
|
||||
const status = stored.status === "completed" ? "completed" : "in_progress";
|
||||
const access = stored.access_choice === "trial" || stored.access_choice === "full" ? stored.access_choice : null;
|
||||
const qx = typeof stored.qx_estimate === "number" && Number.isFinite(stored.qx_estimate) ? stored.qx_estimate : null;
|
||||
const completed = typeof stored.completed_at === "string" && stored.completed_at.trim() ? stored.completed_at : null;
|
||||
|
||||
return {
|
||||
schema_version: 3,
|
||||
revision: rev,
|
||||
status,
|
||||
progress: {
|
||||
stage: typeof progress.stage === "string" ? progress.stage : base.progress.stage,
|
||||
branch_index: typeof progress.branch_index === "number" && Number.isFinite(progress.branch_index) ? progress.branch_index : base.progress.branch_index,
|
||||
updated_at: typeof progress.updated_at === "string" && progress.updated_at.trim() ? progress.updated_at : null,
|
||||
},
|
||||
consent: {
|
||||
privacy_accepted: typeof consent.privacy_accepted === "boolean" ? consent.privacy_accepted : base.consent.privacy_accepted,
|
||||
accepted_at: typeof consent.accepted_at === "string" && consent.accepted_at.trim() ? consent.accepted_at : null,
|
||||
terms_version: typeof consent.terms_version === "string" && consent.terms_version.trim() ? consent.terms_version : base.consent.terms_version,
|
||||
},
|
||||
profile: {
|
||||
intent: typeof profile.intent === "string" ? profile.intent : null,
|
||||
mode: typeof profile.mode === "string" ? profile.mode : null,
|
||||
icp: typeof profile.icp === "string" ? profile.icp : null,
|
||||
question_branch: typeof profile.question_branch === "string" ? profile.question_branch : null,
|
||||
},
|
||||
responses: {
|
||||
current_situation: typeof responses.current_situation === "string" ? responses.current_situation : null,
|
||||
career_barriers: Array.isArray(responses.career_barriers) ? responses.career_barriers.filter((x) => typeof x === "string") : [],
|
||||
desired_outcomes: Array.isArray(responses.desired_outcomes) ? responses.desired_outcomes.filter((x) => typeof x === "string") : [],
|
||||
target_milestone: typeof responses.target_milestone === "string" ? responses.target_milestone : null,
|
||||
weekly_time_commitment: typeof responses.weekly_time_commitment === "string" ? responses.weekly_time_commitment : null,
|
||||
target_role: typeof responses.target_role === "string" ? responses.target_role : null,
|
||||
target_field: typeof responses.target_field === "string" ? responses.target_field : null,
|
||||
venture_industry: typeof responses.venture_industry === "string" ? responses.venture_industry : null,
|
||||
experience_level: typeof responses.experience_level === "string" ? responses.experience_level : null,
|
||||
work_context: typeof responses.work_context === "string" ? responses.work_context : null,
|
||||
},
|
||||
import: {
|
||||
method: typeof imp.method === "string" ? imp.method : base.import.method,
|
||||
status: typeof imp.status === "string" ? imp.status : base.import.status,
|
||||
resume_id: typeof imp.resume_id === "string" ? imp.resume_id : null,
|
||||
resume_filename: typeof imp.resume_filename === "string" ? imp.resume_filename : null,
|
||||
resume_summary: typeof imp.resume_summary === "string" ? imp.resume_summary : null,
|
||||
linkedin_profile_id: typeof imp.linkedin_profile_id === "string" ? imp.linkedin_profile_id : null,
|
||||
linkedin_url: typeof imp.linkedin_url === "string" ? imp.linkedin_url : null,
|
||||
},
|
||||
access_choice: access,
|
||||
qx_estimate: qx,
|
||||
completed_at: completed,
|
||||
};
|
||||
}
|
||||
|
||||
export class OnboardingRevisionConflict extends Error {
|
||||
readonly expected: number;
|
||||
readonly actual: number;
|
||||
constructor(expected: number, actual: number) {
|
||||
super(`onboarding revision conflict: expected ${expected}, actual ${actual}`);
|
||||
this.name = "OnboardingRevisionConflict";
|
||||
this.expected = expected;
|
||||
this.actual = actual;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic-concurrency guard. Throws OnboardingRevisionConflict when the
|
||||
* client's expectedRevision does not match the currently stored revision.
|
||||
* `expectedRevision` may be undefined only when there is no existing revision
|
||||
* (initial create); any mismatch with an existing doc is a conflict.
|
||||
*/
|
||||
export function assertOnboardingRevision(expected: number | undefined, current: OnboardingData): void {
|
||||
if (expected !== current.revision) {
|
||||
throw new OnboardingRevisionConflict(expected ?? -1, current.revision);
|
||||
}
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the outbound OnboardingPayload (schema v1) from the stored v3
|
||||
* OnboardingData. This is a faithful projection — no completion math, no
|
||||
* invented fields. `curator_registry_icp` is left null (the curator resolves
|
||||
* the canonical ICP from `onboarding_icp` at projection time).
|
||||
*/
|
||||
export function deriveOnboardingPayload(data: OnboardingData): OnboardingPayload {
|
||||
const experienceContext = [data.responses.experience_level, data.responses.work_context]
|
||||
.filter((x) => typeof x === "string" && x.trim())
|
||||
.join(" · ") || null;
|
||||
const goals = asStringArray(data.responses.desired_outcomes);
|
||||
const barriers = asStringArray(data.responses.career_barriers);
|
||||
const priority = goals[0] ?? barriers[0] ?? data.responses.target_milestone ?? null;
|
||||
|
||||
return {
|
||||
schema_version: 1,
|
||||
source_revision: data.revision,
|
||||
primary_intent: data.profile.intent,
|
||||
mode: data.profile.mode,
|
||||
question_branch: data.profile.question_branch,
|
||||
onboarding_icp: data.profile.icp,
|
||||
curator_registry_icp: null,
|
||||
target_role: data.responses.target_role,
|
||||
target_field: data.responses.target_field,
|
||||
experience_context: experienceContext,
|
||||
goals,
|
||||
barriers,
|
||||
priority,
|
||||
weekly_time_commitment: data.responses.weekly_time_commitment,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merge an incoming PATCH `data` (full OnboardingData) into the stored
|
||||
* preferences object, returning a NEW preferences object. Preserves every
|
||||
* unrelated preference key (interview_preferences, resume_preferences,
|
||||
* mission_preferences, target_roles, etc.) untouched. Bumps revision by one
|
||||
* and stamps updated_at.
|
||||
*/
|
||||
export function mergeOnboardingPatch(
|
||||
currentPreferences: Record<string, unknown>,
|
||||
incoming: OnboardingData,
|
||||
): Record<string, unknown> {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const stored = extractOnboardingData(currentPreferences.onboarding);
|
||||
const prevRevision = stored.revision;
|
||||
|
||||
const nextData: OnboardingData = {
|
||||
...incoming,
|
||||
// The client sends the revision it based its edits on; the stored revision
|
||||
// is authoritative. We bump from the previously stored revision.
|
||||
revision: prevRevision + 1,
|
||||
progress: { ...incoming.progress, updated_at: updatedAt },
|
||||
};
|
||||
|
||||
// Status/completed_at consistency: completed ⇒ completed_at must be set.
|
||||
if (nextData.status === "completed" && !nextData.completed_at) {
|
||||
nextData.completed_at = updatedAt;
|
||||
}
|
||||
// If regressed to in_progress, clear a stale completed_at.
|
||||
if (nextData.status === "in_progress" && nextData.completed_at && incoming.completed_at === null) {
|
||||
nextData.completed_at = null;
|
||||
}
|
||||
|
||||
return { ...currentPreferences, onboarding: nextData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the next preferences object for a reset: replace `preferences.onboarding`
|
||||
* with a fresh default v3 doc (revision 0) and preserve every unrelated
|
||||
* preference key. Pure — no DB, no network. The revision resets to 0 so the
|
||||
* next save starts a fresh optimistic-concurrency lineage.
|
||||
*/
|
||||
export function resetOnboardingPreferences(
|
||||
currentPreferences: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return { ...currentPreferences, onboarding: defaultOnboardingData() };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Onboarding reset
|
||||
//
|
||||
// The onboarding gate reopens when no valid completion ledger row exists for a
|
||||
// user (getLatestValidOnboardingLedgerEvent returns null). The reset deletes:
|
||||
// 1. Every completion-type row in EITHER alias form (dotted like
|
||||
// "onboarding.completed" or underscore like "onboarding_completed") — these
|
||||
// carry the `onboarding:completed:${userId}` dedupe key.
|
||||
// 2. Snapshot rows ("onboarding.snapshot.saved" / "onboarding_snapshot_saved")
|
||||
// ONLY when their payload carries a completion marker (completed_at /
|
||||
// completedAt at the top level or under onboarding). Intermediate step
|
||||
// saves — the vast majority of snapshots — are preserved.
|
||||
// Missions, curator context, and qscore baselines are never touched. Returns
|
||||
// the count of rows removed for audit output.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// jsonb predicate matching the same paths completedAtFromOnboardingPayload
|
||||
// inspects for snapshot payloads. Mirrors the JS validity rule in SQL so the
|
||||
// reset deletes exactly the snapshots that would otherwise keep the gate shut.
|
||||
const SNAPSHOT_HAS_COMPLETION = sql<boolean>`${growEvents.payload}->>'completed_at' IS NOT NULL
|
||||
OR ${growEvents.payload}->>'completedAt' IS NOT NULL
|
||||
OR ${growEvents.payload}->'onboarding'->>'completed_at' IS NOT NULL
|
||||
OR ${growEvents.payload}->'onboarding'->>'completedAt' IS NOT NULL`;
|
||||
|
||||
export async function resetOnboardingLedger(userId: string): Promise<number> {
|
||||
const deleted = await db
|
||||
.delete(growEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(growEvents.userId, userId),
|
||||
or(
|
||||
inArray(growEvents.type, [...COMPLETION_EVENT_TYPE_ALIASES]),
|
||||
and(
|
||||
inArray(growEvents.type, [...SNAPSHOT_EVENT_TYPE_ALIASES]),
|
||||
SNAPSHOT_HAS_COMPLETION,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning({ id: growEvents.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growQscoreLatest, growQscoreProjectionState, growQscoreSignals } from "../db/schema.js";
|
||||
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_QSCORE = 35;
|
||||
export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed";
|
||||
|
||||
/**
|
||||
* Workbook threshold for a completed onboarding baseline signal
|
||||
* (Complete=Yes → 0.5). qscore_service owns the aggregate score; this is the
|
||||
* per-signal evidence weight forwarded to it.
|
||||
*/
|
||||
export const ONBOARDING_BASELINE_SIGNAL_SCORE = 0.5;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
@@ -24,13 +29,13 @@ function onboardingCompletedAt(preferences: Record<string, unknown> | undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the first real Q Score projection when onboarding is completed.
|
||||
* Forward an onboarding.completed baseline signal to qscore_service when
|
||||
* onboarding is completed.
|
||||
*
|
||||
* The onboarding UI tells users their QX baseline starts at 35. Previously that
|
||||
* number was only cosmetic, while the header showed a separate home-feed
|
||||
* fallback and the Q Score page stayed empty. This makes the onboarding
|
||||
* baseline a persisted readiness signal, but only when the user has no Q Score
|
||||
* signals/projection yet so we do not overwrite mature accounts.
|
||||
* qscore_service OWNS all scoring. The backend no longer seeds local qscore
|
||||
* tables; instead it forwards a single baseline readiness signal and lets
|
||||
* qscore_service compute the score. Idempotency is delegated to qscore_service's
|
||||
* latest-signal upsert semantics.
|
||||
*/
|
||||
export async function ensureOnboardingBaselineQscore(
|
||||
userId: string,
|
||||
@@ -47,120 +52,24 @@ export async function ensureOnboardingBaselineQscoreForCompletedAt(
|
||||
): Promise<boolean> {
|
||||
const completedAt = parseCompletedAt(completedAtInput);
|
||||
if (!completedAt) return false;
|
||||
const latestSignals = await db
|
||||
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score })
|
||||
.from(growQscoreLatest)
|
||||
.where(and(eq(growQscoreLatest.userId, userId), eq(growQscoreLatest.present, true)));
|
||||
|
||||
const [existingProjection] = await db
|
||||
.select({ score: growQscoreProjectionState.score, signalCount: growQscoreProjectionState.signalCount })
|
||||
.from(growQscoreProjectionState)
|
||||
.where(eq(growQscoreProjectionState.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Repair users affected by the old resume-upload projector, which treated a
|
||||
// plain upload as a perfect 100 score. Uploading a resume during onboarding is
|
||||
// only baseline evidence; parsed resume/interview/roleplay results should be
|
||||
// what moves the score upward.
|
||||
if (
|
||||
latestSignals.length === 1 &&
|
||||
latestSignals[0]?.signalId === "resume.uploaded" &&
|
||||
latestSignals[0].score > ONBOARDING_BASELINE_QSCORE
|
||||
) {
|
||||
await db
|
||||
.update(growQscoreLatest)
|
||||
.set({
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
raw: {
|
||||
reason: "resume upload baseline correction",
|
||||
correctedFrom: latestSignals[0].score,
|
||||
correctedAt: now.toISOString(),
|
||||
},
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(growQscoreLatest.userId, userId), eq(growQscoreLatest.signalId, "resume.uploaded")));
|
||||
|
||||
await db
|
||||
.insert(growQscoreProjectionState)
|
||||
.values({
|
||||
userId,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: ["resume.uploaded"], corrected: true },
|
||||
summary: "Baseline Q Score from onboarding resume upload.",
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: growQscoreProjectionState.userId,
|
||||
set: {
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: ["resume.uploaded"], corrected: true },
|
||||
summary: "Baseline Q Score from onboarding resume upload.",
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (latestSignals.length > 0 || (existingProjection?.score ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
const raw = {
|
||||
reason: "completed onboarding baseline",
|
||||
completedAt: completedAt.toISOString(),
|
||||
};
|
||||
|
||||
const inserted = await db
|
||||
.insert(growQscoreLatest)
|
||||
.values({
|
||||
userId,
|
||||
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
present: true,
|
||||
source: "onboarding",
|
||||
raw,
|
||||
occurredAt: completedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ signalId: growQscoreLatest.signalId });
|
||||
|
||||
if (!inserted.length) return false;
|
||||
|
||||
await db.insert(growQscoreSignals).values({
|
||||
await forwardSignalsToQscoreService({
|
||||
orgId: DEFAULT_QSCORE_ORG_ID,
|
||||
userId,
|
||||
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
present: true,
|
||||
profession: "student",
|
||||
source: "onboarding",
|
||||
raw,
|
||||
occurredAt: completedAt,
|
||||
});
|
||||
|
||||
await db
|
||||
.insert(growQscoreProjectionState)
|
||||
.values({
|
||||
userId,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: [ONBOARDING_BASELINE_SIGNAL_ID] },
|
||||
summary: "Baseline Q Score from completed onboarding.",
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: growQscoreProjectionState.userId,
|
||||
set: {
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: [ONBOARDING_BASELINE_SIGNAL_ID] },
|
||||
summary: "Baseline Q Score from completed onboarding.",
|
||||
updatedAt: now,
|
||||
signals: [
|
||||
{
|
||||
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
score: ONBOARDING_BASELINE_SIGNAL_SCORE,
|
||||
present: true,
|
||||
raw: {
|
||||
reason: "completed onboarding baseline",
|
||||
completedAt: completedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
],
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growQscoreLatest, growQscoreProjectionState, growQscoreSignals, type GrowEventRow } from "../../db/schema.js";
|
||||
import { asRecord, clampScore, getNumber, type QscoreSignal } from "../envelope.js";
|
||||
import { type GrowEventRow } from "../../db/schema.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";
|
||||
|
||||
function signal(signalId: string, score: number, raw?: Record<string, unknown>, present = true): QscoreSignal {
|
||||
return { signalId, score: clampScore(score), present, raw };
|
||||
@@ -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,9 +131,75 @@ 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") &&
|
||||
!event.type.includes("started") &&
|
||||
!event.type.includes("watch")
|
||||
)
|
||||
return [];
|
||||
|
||||
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,
|
||||
courseId: payload.courseId ?? payload.course_id,
|
||||
lessonId: payload.lessonId ?? payload.lesson_id,
|
||||
watchPct: watchPctRaw,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -157,85 +247,139 @@ function extractScoredServiceSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
];
|
||||
}
|
||||
|
||||
function countFromPayload(payload: Record<string, unknown>): number | undefined {
|
||||
const result = asRecord(payload.result);
|
||||
const request = asRecord(payload.request);
|
||||
const params = asRecord(request.params);
|
||||
const direct = getNumber(payload.matchCount ?? payload.matches ?? payload.shortlisted ?? result.matchCount ?? result.matches ?? result.shortlisted);
|
||||
if (direct !== undefined) return direct;
|
||||
const opportunities = Array.isArray(result.opportunities) ? result.opportunities : undefined;
|
||||
if (opportunities) return opportunities.length;
|
||||
const requestOpportunities = Array.isArray(params.opportunities) ? params.opportunities : undefined;
|
||||
return requestOpportunities?.length;
|
||||
}
|
||||
|
||||
|
||||
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 raw = {
|
||||
eventId: event.id,
|
||||
type: event.type,
|
||||
action: payload.action,
|
||||
opportunityId: payload.opportunityId ?? payload.matchId ?? payload.jobId ?? event.subject?.externalId,
|
||||
taskId: payload.taskId ?? payload.curatorTaskId ?? event.correlation?.taskId,
|
||||
matchCount: count,
|
||||
status: payload.status,
|
||||
};
|
||||
|
||||
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[] {
|
||||
const source = event.source.toLowerCase();
|
||||
if (source.includes("resume") || event.type.startsWith("resume.")) return extractResumeSignals(event);
|
||||
if (source.includes("interview") || event.type.startsWith("interview.")) return extractInterviewSignals(event);
|
||||
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 [];
|
||||
}
|
||||
|
||||
export async function applyQscoreProjection(event: GrowEventRow) {
|
||||
if (!event.userId) return { signals: [], score: undefined };
|
||||
|
||||
const signals = extractQscoreSignals(event);
|
||||
if (!signals.length) return { signals, score: undefined };
|
||||
|
||||
for (const item of signals) {
|
||||
await db.insert(growQscoreSignals).values({
|
||||
userId: event.userId,
|
||||
sourceEventId: event.id,
|
||||
signalId: item.signalId,
|
||||
score: item.score,
|
||||
present: item.present,
|
||||
source: event.source,
|
||||
raw: item.raw,
|
||||
occurredAt: event.occurredAt,
|
||||
});
|
||||
|
||||
await db.insert(growQscoreLatest).values({
|
||||
userId: event.userId,
|
||||
signalId: item.signalId,
|
||||
score: item.score,
|
||||
present: item.present,
|
||||
source: event.source,
|
||||
sourceEventId: event.id,
|
||||
raw: item.raw,
|
||||
occurredAt: event.occurredAt,
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: [growQscoreLatest.userId, growQscoreLatest.signalId],
|
||||
set: {
|
||||
score: item.score,
|
||||
present: item.present,
|
||||
source: event.source,
|
||||
sourceEventId: event.id,
|
||||
raw: item.raw,
|
||||
occurredAt: event.occurredAt,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [aggregate] = await db
|
||||
.select({ score: sql<number>`round(avg(${growQscoreLatest.score}))::int`, count: sql<number>`count(*)::int` })
|
||||
.from(growQscoreLatest)
|
||||
.where(and(eq(growQscoreLatest.userId, event.userId), eq(growQscoreLatest.present, true)));
|
||||
|
||||
const score = aggregate?.score ?? 0;
|
||||
const signalCount = aggregate?.count ?? 0;
|
||||
await db.insert(growQscoreProjectionState).values({
|
||||
// qscore_service OWNS all scoring. The backend only extracts signals and
|
||||
// forwards them; ingest persists signals and marks the user dirty for async
|
||||
// score computation. It does NOT return a score, so score stays undefined
|
||||
// here — readers fetch the computed score via the proxy endpoints.
|
||||
await forwardSignalsToQscoreService({
|
||||
orgId: event.orgId ?? DEFAULT_QSCORE_ORG_ID,
|
||||
userId: event.userId,
|
||||
score,
|
||||
signalCount,
|
||||
dimensions: { latestSignalIds: signals.map((s) => s.signalId) },
|
||||
summary: `Estimated readiness score from ${signalCount} current signal${signalCount === 1 ? "" : "s"}.`,
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: growQscoreProjectionState.userId,
|
||||
set: {
|
||||
score,
|
||||
signalCount,
|
||||
dimensions: { latestSignalIds: signals.map((s) => s.signalId) },
|
||||
summary: `Estimated readiness score from ${signalCount} current signal${signalCount === 1 ? "" : "s"}.`,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
profession: "student",
|
||||
source: event.source,
|
||||
signals,
|
||||
});
|
||||
|
||||
return { signals, score };
|
||||
return { signals, score: undefined };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { missionServiceSessions, type GrowEventRow } from "../../db/schema.js";
|
||||
import { growActiveMissions, missionServiceSessions, type GrowEventRow } from "../../db/schema.js";
|
||||
import { asRecord, getString } from "../envelope.js";
|
||||
import { normalizeServiceId } from "../record-grow-event.js";
|
||||
|
||||
@@ -12,8 +12,16 @@ function extractExternalId(event: GrowEventRow): string | undefined {
|
||||
correlation.session_id ??
|
||||
correlation.externalId ??
|
||||
correlation.external_id ??
|
||||
correlation.lessonId ??
|
||||
correlation.courseId ??
|
||||
payload.session_id ??
|
||||
payload.sessionId ??
|
||||
payload.lessonId ??
|
||||
payload.lesson_id ??
|
||||
payload.courseId ??
|
||||
payload.course_id ??
|
||||
payload.videoId ??
|
||||
payload.video_id ??
|
||||
payload.id,
|
||||
);
|
||||
}
|
||||
@@ -21,6 +29,7 @@ function extractExternalId(event: GrowEventRow): string | undefined {
|
||||
function statusFor(event: GrowEventRow): string {
|
||||
const payload = event.payload ?? {};
|
||||
const explicit = getString(payload.status);
|
||||
if (explicit === "configured" || explicit === "draft" || explicit === "ready") return "active";
|
||||
if (explicit) return explicit;
|
||||
if (event.type.includes("review_completed") || event.type.includes("feedback.generated") || event.type.includes("completed")) return "completed";
|
||||
if (event.type.includes("failed")) return "failed";
|
||||
@@ -33,22 +42,58 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
|
||||
const externalId = extractExternalId(event);
|
||||
if (!externalId) return null;
|
||||
const serviceId = normalizeServiceId(event.source);
|
||||
if (!["interview", "roleplay", "resume"].includes(serviceId)) return null;
|
||||
if (!["interview", "roleplay", "resume", "course"].includes(serviceId)) return null;
|
||||
|
||||
const mission = asRecord(event.mission);
|
||||
const correlation = asRecord(event.correlation);
|
||||
const payload = event.payload ?? {};
|
||||
const missionInstanceId = getString(mission.instanceId ?? mission.instance_id ?? payload.missionInstanceId ?? payload.mission_instance_id);
|
||||
const missionId = getString(mission.missionId ?? mission.mission_id ?? payload.missionId ?? payload.mission_id);
|
||||
const stageId = getString(mission.stageId ?? mission.stage_id ?? payload.stageId ?? payload.stage_id);
|
||||
const [activeMission] = missionInstanceId
|
||||
? await db
|
||||
.select({
|
||||
instanceId: growActiveMissions.instanceId,
|
||||
missionId: growActiveMissions.missionId,
|
||||
currentStageId: growActiveMissions.currentStageId,
|
||||
})
|
||||
.from(growActiveMissions)
|
||||
.where(and(eq(growActiveMissions.userId, event.userId), eq(growActiveMissions.instanceId, missionInstanceId)))
|
||||
.limit(1)
|
||||
: [];
|
||||
const linkedMissionInstanceId = activeMission?.instanceId;
|
||||
const linkedMissionId = missionId ?? activeMission?.missionId;
|
||||
const linkedStageId = stageId ?? activeMission?.currentStageId ?? undefined;
|
||||
const metadata = {
|
||||
lastType: event.type,
|
||||
subject: event.subject,
|
||||
payloadStatus: event.payload?.status,
|
||||
missionInstanceId,
|
||||
missionId,
|
||||
stageId,
|
||||
taskId: getString(correlation.taskId ?? correlation.curatorTaskId ?? correlation.curator_task_id ?? payload.taskId ?? payload.curatorTaskId ?? payload.curator_task_id),
|
||||
curatorTaskId: getString(correlation.curatorTaskId ?? correlation.curator_task_id ?? correlation.taskId ?? payload.curatorTaskId ?? payload.curator_task_id ?? payload.taskId),
|
||||
courseId: getString(correlation.courseId ?? payload.courseId ?? payload.course_id),
|
||||
lessonId: getString(correlation.lessonId ?? payload.lessonId ?? payload.lesson_id),
|
||||
};
|
||||
const updateValues = {
|
||||
...(linkedMissionInstanceId ? { missionInstanceId: linkedMissionInstanceId } : {}),
|
||||
...(linkedMissionId ? { missionId: linkedMissionId } : {}),
|
||||
...(linkedStageId ? { stageId: linkedStageId } : {}),
|
||||
status: statusFor(event),
|
||||
metadata,
|
||||
lastEventId: event.id,
|
||||
lastCheckedAt: event.type.includes("review") ? new Date() : undefined,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const [row] = await db
|
||||
.insert(missionServiceSessions)
|
||||
.values({
|
||||
userId: event.userId,
|
||||
missionInstanceId: getString(mission.instanceId ?? mission.instance_id),
|
||||
missionId: getString(mission.missionId ?? mission.mission_id),
|
||||
stageId: getString(mission.stageId ?? mission.stage_id),
|
||||
missionInstanceId: linkedMissionInstanceId,
|
||||
missionId: linkedMissionId,
|
||||
stageId: linkedStageId,
|
||||
serviceId,
|
||||
externalId,
|
||||
status: statusFor(event),
|
||||
@@ -58,13 +103,7 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [missionServiceSessions.serviceId, missionServiceSessions.externalId],
|
||||
set: {
|
||||
status: statusFor(event),
|
||||
metadata,
|
||||
lastEventId: event.id,
|
||||
lastCheckedAt: event.type.includes("review") ? new Date() : undefined,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
set: updateValues,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -30,20 +30,23 @@ export function normalizeServiceId(source: string): string {
|
||||
if (source.includes("interview")) return "interview";
|
||||
if (source.includes("roleplay")) return "roleplay";
|
||||
if (source.includes("resume")) return "resume";
|
||||
if (source.includes("course")) return "course";
|
||||
if (source.includes("courses")) return "course";
|
||||
if (source.includes("qscore")) return "qscore";
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function recordGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<GrowEventRow> {
|
||||
const result = await recordGrowEventWithResult(input, overrides);
|
||||
return result.event;
|
||||
}
|
||||
|
||||
export async function recordGrowEventWithResult(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<{ event: GrowEventRow; inserted: boolean }> {
|
||||
const normalized = normalizeGrowEvent(input, overrides);
|
||||
const resolvedUserId = await resolveUserId(normalized);
|
||||
if (resolvedUserId) await ensureUser(resolvedUserId);
|
||||
|
||||
const processingStatus = resolvedUserId ? "pending" : "unresolved";
|
||||
if (normalized.dedupeKey) {
|
||||
const [existing] = await db.select().from(growEvents).where(eq(growEvents.dedupeKey, normalized.dedupeKey)).limit(1);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
const values = {
|
||||
id: normalized.id,
|
||||
@@ -62,30 +65,70 @@ export async function recordGrowEvent(input: unknown, overrides: { userId?: stri
|
||||
processingStatus,
|
||||
} satisfies typeof growEvents.$inferInsert;
|
||||
|
||||
const [inserted] = await db
|
||||
// Atomic insert-or-dedupe.
|
||||
//
|
||||
// onConflictDoNothing() with no target catches BOTH the PK (grow_events.id)
|
||||
// and the dedupe_key unique index violations atomically. .returning() yields
|
||||
// a row only on a fresh INSERT — on a conflict the row is suppressed. This is
|
||||
// the deterministic insert-vs-existing discriminator with no clock heuristics,
|
||||
// no xmax probe, and no raw SQL.
|
||||
//
|
||||
// Critically, DO NOTHING (not DO UPDATE) means an existing row is left
|
||||
// *untouched*: a terminal (processed/failed) row is never resurrected back to
|
||||
// *pending by a duplicate ingest. We then SELECT the pre-existing row AS-IS.
|
||||
//
|
||||
// NULL dedupeKey never collides (Postgres treats NULLs as distinct under a
|
||||
// unique index), so events with no dedupe key always insert fresh.
|
||||
const insertedRows = await db
|
||||
.insert(growEvents)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: growEvents.id,
|
||||
set: {
|
||||
userId: values.userId,
|
||||
orgId: values.orgId,
|
||||
source: values.source,
|
||||
type: values.type,
|
||||
category: values.category,
|
||||
occurredAt: values.occurredAt,
|
||||
mission: values.mission,
|
||||
subject: values.subject,
|
||||
correlation: values.correlation,
|
||||
payload: values.payload,
|
||||
raw: values.raw,
|
||||
processingStatus: values.processingStatus,
|
||||
},
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (!inserted) throw new Error("failed to record grow event");
|
||||
return inserted;
|
||||
const inserted = insertedRows[0];
|
||||
if (inserted) {
|
||||
return { event: inserted, inserted: true };
|
||||
}
|
||||
// Conflict: the row already existed. onConflictDoNothing() (no target)
|
||||
// suppresses on EITHER the PK (id) or the dedupe_key unique index. We must
|
||||
// find the conflicting row by whichever constraint fired — try dedupeKey
|
||||
// first (the common case), then fall back to id (PK collision). Both can
|
||||
// point to the same row when normalize.ts sets dedupeKey = id.
|
||||
let existing: GrowEventRow | undefined;
|
||||
if (normalized.dedupeKey) {
|
||||
[existing] = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(eq(growEvents.dedupeKey, normalized.dedupeKey))
|
||||
.limit(1);
|
||||
}
|
||||
if (!existing && normalized.id) {
|
||||
[existing] = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(eq(growEvents.id, normalized.id))
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
// Should not happen: onConflictDoNothing suppressed the insert yet we
|
||||
// cannot find the conflicting row on either constraint. Treat as a hard
|
||||
// failure rather than silently dropping the event.
|
||||
throw new Error("failed to record grow event: conflict with unresolvable row");
|
||||
}
|
||||
|
||||
return { event: existing, inserted: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Routing gate: only newly inserted, user-resolved, pending events should be
|
||||
* enqueued to the user-event actor. Replays / dedupe hits / unresolved events
|
||||
* must not enqueue (they would saturate the actor queue on duplicate ingest).
|
||||
*/
|
||||
export function shouldRouteGrowEvent(event: Pick<GrowEventRow, "userId" | "processingStatus">, inserted: boolean): boolean {
|
||||
if (!inserted) return false;
|
||||
if (!event.userId) return false;
|
||||
return event.processingStatus === "pending";
|
||||
}
|
||||
|
||||
export async function markGrowEventProcessing(eventId: string) {
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
markGrowEventFailed,
|
||||
markGrowEventProcessed,
|
||||
markGrowEventProcessing,
|
||||
recordGrowEvent,
|
||||
recordGrowEventWithResult,
|
||||
shouldRouteGrowEvent,
|
||||
} from "./record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "./route-to-user-actor.js";
|
||||
import { applyQscoreProjection } from "./projectors/qscore-projector.js";
|
||||
@@ -30,8 +31,8 @@ type RedisClientLike = {
|
||||
};
|
||||
|
||||
type ServiceRedisSpec = {
|
||||
serviceId: "interview" | "roleplay" | "resume";
|
||||
agentName: "interview-service" | "roleplay-service" | "resume-builder";
|
||||
serviceId: "interview" | "roleplay" | "resume" | "course";
|
||||
agentName: "interview-service" | "roleplay-service" | "resume-builder" | "course-service";
|
||||
redisUrl: string;
|
||||
};
|
||||
|
||||
@@ -71,6 +72,15 @@ function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function getNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fieldsToEvent(fields: Record<string, string>, stream: string) {
|
||||
const payload = fields.payload ? parseFieldValue(fields.payload) : parseFieldValue(fields.data ?? "{}");
|
||||
const correlation = fields.correlation ? parseFieldValue(fields.correlation) : undefined;
|
||||
@@ -98,6 +108,7 @@ function serviceSpecs(): ServiceRedisSpec[] {
|
||||
{ serviceId: "interview", agentName: "interview-service", redisUrl: config.interviewRedisUrl },
|
||||
{ serviceId: "roleplay", agentName: "roleplay-service", redisUrl: config.roleplayRedisUrl },
|
||||
{ serviceId: "resume", agentName: "resume-builder", redisUrl: config.resumeRedisUrl },
|
||||
{ serviceId: "course", agentName: "course-service", redisUrl: config.coursesRedisUrl },
|
||||
];
|
||||
return specs.filter((spec) => Boolean(spec.redisUrl));
|
||||
}
|
||||
@@ -126,6 +137,14 @@ function actionToEventType(serviceId: ServiceRedisSpec["serviceId"], action: str
|
||||
return `roleplay.${effective.replaceAll("_", ".")}`;
|
||||
}
|
||||
|
||||
if (serviceId === "course") {
|
||||
if (effective === "swipe_recorded" || action === "record_swipe") return "course.progress_recorded";
|
||||
if (effective === "profile_created" || effective === "profile_recalibrated") return "course.started";
|
||||
if (effective === "course_state_loaded") return "course.page_state_loaded";
|
||||
if (effective === "feed_loaded") return "course.feed_loaded";
|
||||
return `course.${effective.replaceAll("_", ".")}`;
|
||||
}
|
||||
|
||||
if (effective === "ai_analysis_complete" || action === "ai_analyze") return "resume.analysis.completed";
|
||||
if (effective === "resume_loaded") return "resume.loaded";
|
||||
if (effective === "resume_parsed") return "resume.parsed";
|
||||
@@ -152,12 +171,39 @@ function extractResumeId(message: Record<string, unknown>, ctx?: LegacyTaskConte
|
||||
);
|
||||
}
|
||||
|
||||
function extractCourseIds(ctx?: LegacyTaskContext) {
|
||||
const params = ctx?.params ?? {};
|
||||
const courseId = getString(params.courseId ?? params.course_id ?? params.anchor_id ?? params.anchorId);
|
||||
const lessonId = getString(params.lessonId ?? params.lesson_id ?? params.video_id ?? params.videoId);
|
||||
return { courseId, lessonId };
|
||||
}
|
||||
|
||||
function meaningfulCourseProgress(data: Record<string, unknown>, ctx?: LegacyTaskContext) {
|
||||
if (data.error) return false;
|
||||
const params = ctx?.params ?? {};
|
||||
const watchS = getNumber(params.watch_s ?? params.watchS) ?? 0;
|
||||
const watchPct = getNumber(data.watch_pct ?? data.watchPct) ?? 0;
|
||||
const label = getString(data.label);
|
||||
return watchS >= 3 || watchPct >= 0.5 || label === "pos" || label === "strong_pos";
|
||||
}
|
||||
|
||||
async function recordAndRoute(input: unknown) {
|
||||
const event = await recordGrowEvent(input);
|
||||
await routeGrowEventToUserActor(event).catch((err) => {
|
||||
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
|
||||
});
|
||||
if (!event.userId || event.processingStatus === "processed") return event;
|
||||
const { event, inserted } = await recordGrowEventWithResult(input);
|
||||
|
||||
// Route only newly inserted, user-resolved, pending events to the actor.
|
||||
// Dedupe hits (inserted=false) are skipped so duplicate ingests cannot
|
||||
// saturate the actor queue.
|
||||
const routed = shouldRouteGrowEvent(event, inserted);
|
||||
if (routed) {
|
||||
await routeGrowEventToUserActor(event).catch((err) => {
|
||||
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
|
||||
});
|
||||
}
|
||||
|
||||
// In-process projection/onboarding only for freshly inserted events;
|
||||
// dedupe hits have already been processed (or are being processed elsewhere).
|
||||
if (!inserted) return event;
|
||||
if (!event.userId) return event;
|
||||
|
||||
await markGrowEventProcessing(event.id);
|
||||
try {
|
||||
@@ -169,6 +215,10 @@ async function recordAndRoute(input: unknown) {
|
||||
) {
|
||||
throw new Error("curator_onboarding_loop_failed");
|
||||
}
|
||||
// Always mark "processed" after successful synchronous projections, regardless
|
||||
// of route outcome. The actor will still run mission reducers on "processed"
|
||||
// events (isProcessableStatus allows "processed") — projections are idempotent
|
||||
// and the in-memory processedEventIds guard prevents double-processing.
|
||||
await markGrowEventProcessed(event.id);
|
||||
} catch (err) {
|
||||
await markGrowEventFailed(event.id, err);
|
||||
@@ -189,6 +239,12 @@ async function handleLegacyResponseMessage(spec: ServiceRedisSpec, channel: stri
|
||||
const eventType = actionToEventType(spec.serviceId, ctx?.action, message);
|
||||
const sessionId = extractSessionId(message, ctx);
|
||||
const resumeId = extractResumeId(message, ctx);
|
||||
const { courseId, lessonId } = extractCourseIds(ctx);
|
||||
if (eventType === "course.progress_recorded" && !meaningfulCourseProgress(data, ctx)) return;
|
||||
const curatorTaskId = getString(ctx?.params?.curatorTaskId ?? ctx?.params?.curator_task_id ?? ctx?.params?.taskId);
|
||||
const missionInstanceId = getString(ctx?.params?.missionInstanceId ?? ctx?.params?.mission_instance_id);
|
||||
const missionId = getString(ctx?.params?.missionId ?? ctx?.params?.mission_id);
|
||||
const stageId = getString(ctx?.params?.stageId ?? ctx?.params?.stage_id);
|
||||
|
||||
await recordAndRoute({
|
||||
id: randomUUID(),
|
||||
@@ -197,18 +253,36 @@ async function handleLegacyResponseMessage(spec: ServiceRedisSpec, channel: stri
|
||||
category: type === "agent_error" ? "system" : "service",
|
||||
userId: ctx?.userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
mission: {
|
||||
instanceId: missionInstanceId,
|
||||
missionId,
|
||||
stageId,
|
||||
},
|
||||
correlation: {
|
||||
taskId,
|
||||
taskId: curatorTaskId ?? taskId,
|
||||
curatorTaskId,
|
||||
serviceTaskId: taskId,
|
||||
action: ctx?.action,
|
||||
sessionId,
|
||||
resumeId,
|
||||
externalId: sessionId ?? resumeId,
|
||||
courseId,
|
||||
lessonId,
|
||||
externalId: sessionId ?? resumeId ?? lessonId ?? courseId,
|
||||
},
|
||||
payload: {
|
||||
action: ctx?.action,
|
||||
params: ctx?.params,
|
||||
message,
|
||||
data,
|
||||
taskId: curatorTaskId,
|
||||
curatorTaskId,
|
||||
serviceId: spec.agentName,
|
||||
sessionId,
|
||||
courseId,
|
||||
lessonId,
|
||||
missionInstanceId,
|
||||
missionId,
|
||||
stageId,
|
||||
},
|
||||
raw: { channel, message },
|
||||
dedupeKey: `legacy-a2a:${spec.agentName}:${taskId}:${eventType}:${JSON.stringify(message).slice(0, 512)}`,
|
||||
@@ -335,14 +409,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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,46 @@ import { config } from "../config.js";
|
||||
import type { Registry } from "../actors/registry.js";
|
||||
import type { GrowEventRow } from "../db/schema.js";
|
||||
|
||||
export type GrowEventActorRouteResult =
|
||||
| { routed: true }
|
||||
| { routed: false; reason: "unresolved_user" | "actor_route_timeout" };
|
||||
|
||||
export interface GrowEventActorRouteOptions {
|
||||
timeoutMs?: number;
|
||||
enqueue?: (event: { userId: string; eventId: string }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const DEFAULT_ACTOR_ROUTE_TIMEOUT_MS = 2_000;
|
||||
const ACTOR_ROUTE_TIMEOUT = Symbol("actor_route_timeout");
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
|
||||
}
|
||||
|
||||
export async function routeGrowEventToUserActor(event: Pick<GrowEventRow, "id" | "userId">) {
|
||||
if (!event.userId) return { routed: false, reason: "unresolved_user" } as const;
|
||||
await getClient().userEventActor.getOrCreate([event.userId]).enqueueEvent({ userId: event.userId, eventId: event.id });
|
||||
return { routed: true } as const;
|
||||
export async function routeGrowEventToUserActor(
|
||||
event: Pick<GrowEventRow, "id" | "userId">,
|
||||
options: GrowEventActorRouteOptions = {},
|
||||
): Promise<GrowEventActorRouteResult> {
|
||||
if (!event.userId) return { routed: false, reason: "unresolved_user" };
|
||||
|
||||
const actorEvent = { userId: event.userId, eventId: event.id };
|
||||
const enqueue = options.enqueue ?? ((input) =>
|
||||
getClient().userEventActor.getOrCreate([input.userId]).enqueueEvent(input));
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_ACTOR_ROUTE_TIMEOUT_MS;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
enqueue(actorEvent),
|
||||
new Promise<typeof ACTOR_ROUTE_TIMEOUT>((resolve) => {
|
||||
timer = setTimeout(() => resolve(ACTOR_ROUTE_TIMEOUT), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
return result === ACTOR_ROUTE_TIMEOUT
|
||||
? { routed: false, reason: "actor_route_timeout" }
|
||||
: { routed: true };
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { generateText } from "ai";
|
||||
import { z } from "zod";
|
||||
import { getConversationModel } from "../actors/conversation/agent.js";
|
||||
import { config } from "../config.js";
|
||||
import { log } from "../log.js";
|
||||
import { isAllowedNotificationHref, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.js";
|
||||
|
||||
const notificationSchema = z.object({
|
||||
moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]),
|
||||
title: z.string().min(4).max(72),
|
||||
subtitle: z.string().min(4).max(110),
|
||||
tag: z.string().min(2).max(14),
|
||||
urgency: z.enum(["now", "today", "soon", "calm"]),
|
||||
href: z.string().min(1),
|
||||
source: z.enum(["resume", "interview", "roleplay", "qscore", "mission", "social", "pathways", "rewards", "system"]),
|
||||
reason: z.string().max(160).optional(),
|
||||
});
|
||||
|
||||
const rawNotificationSchema = notificationSchema.extend({
|
||||
moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]).optional(),
|
||||
tag: z.string().min(2).max(14).optional(),
|
||||
href: z.string().min(1).optional(),
|
||||
source: z.enum(["resume", "interview", "roleplay", "qscore", "mission", "social", "pathways", "rewards", "system"]).optional(),
|
||||
});
|
||||
|
||||
const feedSchema = z.object({
|
||||
notifications: z.array(notificationSchema).min(6).max(24),
|
||||
});
|
||||
|
||||
const rawFeedSchema = z.object({
|
||||
notifications: z.array(rawNotificationSchema).min(1).max(24),
|
||||
});
|
||||
|
||||
const HOME_FEED_AGENT_ENABLED = process.env.HOME_FEED_AGENT_ENABLED === "true";
|
||||
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 5000);
|
||||
const HOME_FEED_AGENT_ATTEMPTS = Math.max(1, Number(process.env.HOME_FEED_AGENT_ATTEMPTS ?? 1));
|
||||
|
||||
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
|
||||
|
||||
export class HomeFeedAgentError extends Error {
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(message);
|
||||
this.name = "HomeFeedAgentError";
|
||||
}
|
||||
}
|
||||
|
||||
const SYSTEM = `You are GrowQR's Home Feed Agent.
|
||||
Your job is to rank and rewrite dashboard notifications from real platform context.
|
||||
Keep them coherent, specific, and action-oriented. Do not invent unavailable products, scores, sessions, deadlines, companies, artifacts, or rewards.
|
||||
Every notification must point to one of these real dashboard routes:
|
||||
- /agents/resume for resume building, resume analysis, ATS, resume suggestions
|
||||
- /agents/interview for mock interview setup, interview session, interview review
|
||||
- /agents/roleplay for recruiter/manager/salary/stakeholder roleplay
|
||||
- /missions for mission progress, approvals, artifacts, next stages
|
||||
- /agents/social-branding for LinkedIn/social branding
|
||||
- /agents/matchmaking for Scout/opportunity matching
|
||||
- /rewards for locked/coming-soon rewards
|
||||
- /suggestions for broad onboarding/profile suggestions
|
||||
Every notification object must include:
|
||||
- moduleId: one of ${MODULE_IDS.join(", ")}
|
||||
- source: one of resume, interview, roleplay, mission, social, pathways, rewards, system
|
||||
Use minimal iPhone-notification copy: title <= 72 chars, subtitle <= 110 chars, short tag <= 14 chars.
|
||||
Use urgency truthfully: now = needs immediate user action, today = useful today, soon = next few days, calm = informational.`;
|
||||
|
||||
function sanitizeHref(href: string, moduleId: HomeModuleId) {
|
||||
if (isAllowedNotificationHref(href)) return href;
|
||||
if (href.startsWith("/missions")) return "/missions/active";
|
||||
if (href.startsWith("/agents/social-branding")) return "/agents/social-branding";
|
||||
if (href.startsWith("/agents/matchmaking")) return "/agents/matchmaking";
|
||||
if (href.startsWith("/rewards")) return "/rewards";
|
||||
return moduleId === "productivity" ? "/agents" : `/${moduleId}`;
|
||||
}
|
||||
|
||||
function stableId(prefix: string, index: number) {
|
||||
return `${prefix}-${index + 1}`;
|
||||
}
|
||||
|
||||
function sourceFromHref(href: string) {
|
||||
if (href.startsWith("/agents/resume")) return "resume";
|
||||
if (href.startsWith("/agents/interview")) return "interview";
|
||||
if (href.startsWith("/agents/roleplay")) return "roleplay";
|
||||
if (href.startsWith("/missions")) return "mission";
|
||||
if (href.startsWith("/agents/social-branding")) return "social";
|
||||
if (href.startsWith("/agents/matchmaking")) return "pathways";
|
||||
if (href.startsWith("/rewards")) return "rewards";
|
||||
return "system";
|
||||
}
|
||||
|
||||
function moduleFromSource(source: NonNullable<AgentHomeNotification["source"]>): HomeModuleId {
|
||||
if (source === "mission") return "missions";
|
||||
if (source === "social") return "social";
|
||||
if (source === "pathways") return "pathways";
|
||||
if (source === "rewards") return "rewards";
|
||||
if (source === "resume" || source === "interview" || source === "roleplay") return "productivity";
|
||||
return "suggestions";
|
||||
}
|
||||
|
||||
function tagFromSource(source: NonNullable<AgentHomeNotification["source"]>) {
|
||||
if (source === "mission") return "Mission";
|
||||
if (source === "roleplay") return "Roleplay";
|
||||
if (source === "interview") return "Interview";
|
||||
if (source === "resume") return "Resume";
|
||||
if (source === "social") return "Social";
|
||||
if (source === "pathways") return "Pathways";
|
||||
if (source === "rewards") return "Rewards";
|
||||
return "Update";
|
||||
}
|
||||
|
||||
function defaultHrefForSource(source: NonNullable<AgentHomeNotification["source"]>, moduleId: HomeModuleId) {
|
||||
if (source === "resume") return "/agents/resume";
|
||||
if (source === "interview") return "/agents/interview";
|
||||
if (source === "roleplay") return "/agents/roleplay";
|
||||
if (source === "mission") return "/missions";
|
||||
if (source === "social") return "/agents/social-branding";
|
||||
if (source === "pathways") return "/agents/matchmaking";
|
||||
if (source === "rewards") return "/rewards";
|
||||
return moduleId === "productivity" ? "/agents" : `/${moduleId}`;
|
||||
}
|
||||
|
||||
function normalizeAgentNotification(
|
||||
raw: z.infer<typeof rawNotificationSchema>,
|
||||
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>,
|
||||
): AgentHomeNotification {
|
||||
const seed = (raw.href ? seeds.find((item) => item.href === raw.href) : undefined)
|
||||
?? seeds.find((item) => item.title.toLowerCase() === raw.title.toLowerCase());
|
||||
const inferredSource = raw.source ?? seed?.source;
|
||||
const normalizedSource = inferredSource === "qscore" ? "pathways" : inferredSource;
|
||||
const moduleId = raw.moduleId ?? seed?.moduleId ?? (normalizedSource ? moduleFromSource(normalizedSource) : "suggestions");
|
||||
const rawHref = raw.href ?? seed?.href ?? (normalizedSource ? defaultHrefForSource(normalizedSource, moduleId) : `/${moduleId}`);
|
||||
const href = sanitizeHref(rawHref, moduleId);
|
||||
const seedSource = seed?.source === "qscore" ? "pathways" : seed?.source;
|
||||
const source = raw.source ?? seedSource ?? sourceFromHref(href);
|
||||
return notificationSchema.parse({
|
||||
...raw,
|
||||
tag: raw.tag ?? seed?.tag ?? tagFromSource(source),
|
||||
href,
|
||||
source,
|
||||
moduleId,
|
||||
});
|
||||
}
|
||||
|
||||
function notificationKey(notification: AgentHomeNotification) {
|
||||
return [
|
||||
notification.moduleId,
|
||||
notification.href,
|
||||
notification.title.trim().toLowerCase(),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function completeNotificationsWithSeeds(
|
||||
notifications: AgentHomeNotification[],
|
||||
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>,
|
||||
) {
|
||||
const completed = [...notifications];
|
||||
const seen = new Set(completed.map(notificationKey));
|
||||
|
||||
for (const seed of seeds) {
|
||||
if (completed.length >= 6) break;
|
||||
const candidateSeed = seed.source === "qscore" ? { ...seed, source: "pathways" as const, href: seed.href?.startsWith("/agents/qscore") ? "/agents/matchmaking" : seed.href } : seed;
|
||||
const candidate = normalizeAgentNotification(candidateSeed, seeds);
|
||||
const key = notificationKey(candidate);
|
||||
if (seen.has(key)) continue;
|
||||
completed.push(candidate);
|
||||
seen.add(key);
|
||||
}
|
||||
|
||||
return feedSchema.parse({ notifications: completed }).notifications;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string) {
|
||||
const cleaned = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
|
||||
try {
|
||||
return JSON.parse(cleaned);
|
||||
} catch {
|
||||
const start = cleaned.indexOf("{");
|
||||
const end = cleaned.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end <= start) throw new Error("home_feed_agent_invalid_json");
|
||||
return JSON.parse(cleaned.slice(start, end + 1));
|
||||
}
|
||||
}
|
||||
|
||||
export async function refineHomeNotificationsWithAgent(input: {
|
||||
userId: string;
|
||||
context: Record<string, unknown>;
|
||||
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>;
|
||||
}): Promise<Array<AgentHomeNotification & { id: string; createdAt: string }>> {
|
||||
if (!HOME_FEED_AGENT_ENABLED) return [];
|
||||
if (!config.llmApiKey && config.nodeEnv === "production") {
|
||||
throw new HomeFeedAgentError("home_feed_agent_missing_llm_api_key");
|
||||
}
|
||||
if (!config.llmApiKey) return [];
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= HOME_FEED_AGENT_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: getConversationModel(),
|
||||
system: [
|
||||
SYSTEM,
|
||||
"Return JSON only. Shape: {\"notifications\": [...]}. Do not use markdown.",
|
||||
"Use ASCII punctuation only.",
|
||||
].join("\n"),
|
||||
timeout: HOME_FEED_AGENT_TIMEOUT_MS,
|
||||
prompt: JSON.stringify({
|
||||
task: "Create coherent GrowQR home dashboard notifications from the provided service context and deterministic candidates.",
|
||||
userId: input.userId,
|
||||
serviceContext: input.context,
|
||||
deterministicCandidates: input.seeds,
|
||||
}),
|
||||
});
|
||||
|
||||
const parsed = rawFeedSchema.parse(parseJsonObject(result.text));
|
||||
const notifications = completeNotificationsWithSeeds(
|
||||
parsed.notifications.map((item) => normalizeAgentNotification(item, input.seeds)),
|
||||
input.seeds,
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
return notifications.map((n, index) => ({
|
||||
...n,
|
||||
urgency: n.urgency as HomeUrgency,
|
||||
id: stableId("agent-home", index),
|
||||
createdAt: now,
|
||||
}));
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
log.debug({ err, userId: input.userId, attempt, attempts: HOME_FEED_AGENT_ATTEMPTS }, "home feed agent attempt failed");
|
||||
}
|
||||
}
|
||||
|
||||
throw new HomeFeedAgentError("home_feed_agent_generation_failed", lastError);
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
growActiveMissions,
|
||||
growEvents,
|
||||
growHomeNotifications,
|
||||
growQscoreLatest,
|
||||
growQscoreProjectionState,
|
||||
missionArtifacts,
|
||||
missionServiceSessions,
|
||||
missionSuggestions,
|
||||
@@ -17,8 +15,8 @@ import {
|
||||
} from "../db/schema.js";
|
||||
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
|
||||
import { buildServiceLink } from "../services/service-registry.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../services/qscore-proxy.js";
|
||||
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
|
||||
import { HomeFeedAgentError, refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
|
||||
import { listAvailableMissionDefinitions } from "../missions/registry.js";
|
||||
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
|
||||
import { buildCuratorSprint } from "../v1/curator/curator-store.js";
|
||||
@@ -384,13 +382,18 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
|
||||
async function collectContext(userId: string, input: { userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeContext> {
|
||||
const [user] = await db.select({ id: users.id, email: users.email, displayName: users.displayName }).from(users).where(eq(users.id, userId)).limit(1);
|
||||
const [qscore] = await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, userId)).limit(1);
|
||||
const qscoreSignals = await db
|
||||
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score, source: growQscoreLatest.source, updatedAt: growQscoreLatest.updatedAt })
|
||||
.from(growQscoreLatest)
|
||||
.where(eq(growQscoreLatest.userId, userId))
|
||||
.orderBy(desc(growQscoreLatest.updatedAt))
|
||||
.limit(30);
|
||||
const qscoreResult = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
|
||||
const breakdown = qscoreResult?.breakdown ?? {};
|
||||
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
|
||||
const qscoreSignals = breakdownSignals.map((item) => {
|
||||
const s = isRecord(item) ? item : {};
|
||||
return {
|
||||
signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "",
|
||||
score: typeof s.score === "number" ? s.score : 0,
|
||||
source: typeof s.source === "string" ? s.source : null,
|
||||
updatedAt: new Date(typeof s.updatedAt === "string" ? s.updatedAt : (qscoreResult?.calculated_at ?? new Date().toISOString())),
|
||||
};
|
||||
});
|
||||
const activeMissions = await db
|
||||
.select({ instanceId: growActiveMissions.instanceId, missionId: growActiveMissions.missionId, title: growActiveMissions.title, status: growActiveMissions.status, progressPercent: growActiveMissions.progressPercent, currentStageId: growActiveMissions.currentStageId, updatedAt: growActiveMissions.updatedAt })
|
||||
.from(growActiveMissions)
|
||||
@@ -447,12 +450,12 @@ async function collectContext(userId: string, input: { userProfile?: Record<stri
|
||||
|
||||
return {
|
||||
user,
|
||||
qscore: qscore
|
||||
qscore: qscoreResult
|
||||
? {
|
||||
score: qscore.score,
|
||||
signalCount: qscore.signalCount,
|
||||
summary: qscore.summary,
|
||||
dimensions: isRecord(qscore.dimensions) ? qscore.dimensions : null,
|
||||
score: qscoreResult.q_score,
|
||||
signalCount: typeof breakdown.signalCount === "number" ? breakdown.signalCount : qscoreSignals.length,
|
||||
summary: typeof breakdown.summary === "string" ? breakdown.summary : null,
|
||||
dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : isRecord(qscoreResult.quotients) ? qscoreResult.quotients : null,
|
||||
}
|
||||
: undefined,
|
||||
qscoreSignals,
|
||||
@@ -542,14 +545,6 @@ function ensureCoverage(seeds: SeedNotification[], fallback: SeedNotification[])
|
||||
}
|
||||
|
||||
function moduleCount(moduleId: HomeModuleId, notifications: HomeNotification[], ctx: HomeContext, mode: HomeFeedResponse["mode"]) {
|
||||
if (mode === "demo") {
|
||||
if (moduleId === "missions") return "1 active";
|
||||
if (moduleId === "productivity") return "4 updates";
|
||||
if (moduleId === "social") return "3 ready";
|
||||
if (moduleId === "pathways") return "Soon";
|
||||
if (moduleId === "rewards") return "Demo";
|
||||
return String(notifications.length);
|
||||
}
|
||||
if (moduleId === "missions") {
|
||||
return mode === "day1" && notifications.length === 0 ? "0" : String(Math.min(notifications.length, MISSION_MODULE_LIMIT));
|
||||
}
|
||||
@@ -614,12 +609,11 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean; use
|
||||
const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences });
|
||||
const persisted = await readPersistedNotifications(userId);
|
||||
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
|
||||
const hasDemo = persisted.some((row) => row.generatedBy === "demo");
|
||||
const hasLegacyMock = hasLegacyMockSeed(persisted);
|
||||
const fresh = newest > Date.now() - FRESH_MS;
|
||||
|
||||
if (persisted.length && !hasLegacyMock && (hasDemo || (!opts.refresh && fresh))) {
|
||||
const mode = hasDemo ? "demo" : hasAnyRealActivity(ctx) ? "dynamic" : "day1";
|
||||
if (persisted.length && !hasLegacyMock && !opts.refresh && fresh) {
|
||||
const mode = hasAnyRealActivity(ctx) ? "dynamic" : "day1";
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
mode,
|
||||
@@ -630,46 +624,7 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean; use
|
||||
|
||||
const dayOneSeeds = buildDayOneSeeds();
|
||||
const deterministic = hasAnyRealActivity(ctx) ? buildDynamicSeeds(ctx) : dayOneSeeds;
|
||||
let agentNotifications: Awaited<ReturnType<typeof refineHomeNotificationsWithAgent>> = [];
|
||||
try {
|
||||
agentNotifications = await refineHomeNotificationsWithAgent({
|
||||
userId,
|
||||
context: {
|
||||
qscore: ctx.qscore,
|
||||
qscoreSignals: ctx.qscoreSignals,
|
||||
activeMissions: ctx.activeMissions,
|
||||
sessions: ctx.sessions,
|
||||
artifacts: ctx.artifacts,
|
||||
recentEvents: ctx.events,
|
||||
serviceStates: ctx.serviceStates,
|
||||
missionSuggestions: ctx.missionSuggestions,
|
||||
userProfile: ctx.userProfile,
|
||||
preferences: ctx.preferences,
|
||||
routeRules: SERVICE_HREFS,
|
||||
},
|
||||
seeds: deterministic,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof HomeFeedAgentError)) throw err;
|
||||
log.info({ userId }, "home feed agent unavailable, using deterministic notifications");
|
||||
}
|
||||
|
||||
const generatedBy = agentNotifications.length ? "agent" : "deterministic";
|
||||
const generatedSeeds: SeedNotification[] = agentNotifications.length
|
||||
? agentNotifications.map((n, index) => ({
|
||||
moduleId: n.moduleId,
|
||||
title: n.title,
|
||||
subtitle: n.subtitle,
|
||||
tag: n.tag,
|
||||
urgency: n.urgency,
|
||||
href: n.href,
|
||||
source: n.source,
|
||||
reason: n.reason,
|
||||
priority: 100 - index,
|
||||
}))
|
||||
: deterministic;
|
||||
|
||||
await replaceGeneratedNotifications(userId, ensureCoverage(generatedSeeds, dayOneSeeds), generatedBy);
|
||||
await replaceGeneratedNotifications(userId, ensureCoverage(deterministic, dayOneSeeds), "deterministic");
|
||||
const rows = await readPersistedNotifications(userId);
|
||||
const mode = hasAnyRealActivity(ctx) ? "dynamic" : "day1";
|
||||
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import "dotenv/config";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growActiveMissions, growHomeNotifications, growQscoreLatest, growQscoreProjectionState, missionArtifacts, missionServiceSessions, users, type NewGrowHomeNotification } from "../db/schema.js";
|
||||
import type { HomeModuleId, HomeSource, HomeUrgency } from "./types.js";
|
||||
|
||||
type DemoNotification = {
|
||||
moduleId: HomeModuleId;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
tag: string;
|
||||
urgency: HomeUrgency;
|
||||
href: string;
|
||||
source: HomeSource;
|
||||
priority: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
const demoNotifications: DemoNotification[] = [
|
||||
{ moduleId: "suggestions", title: "Approve resume v6", subtitle: "ATS 78 → 86 · two PM bullets are ready to ship.", tag: "Urgent", urgency: "now", href: "/agents/resume", source: "resume", priority: 120 },
|
||||
{ moduleId: "suggestions", title: "Mock follow-up at 6:30 PM", subtitle: "Behavioral drill based on yesterday's review gaps.", tag: "Today", urgency: "today", href: "/agents/interview", source: "interview", priority: 116 },
|
||||
{ moduleId: "suggestions", title: "Practice recruiter pushback", subtitle: "Roleplay can tighten your compensation and notice-period answer.", tag: "Soon", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 110 },
|
||||
|
||||
{ moduleId: "missions", title: "Interview-to-Offer — 72%", subtitle: "Resume fit done · mock review ready · final report locked.", tag: "Active", urgency: "today", href: "/missions", source: "mission", priority: 115 },
|
||||
{ moduleId: "missions", title: "2 approvals pending", subtitle: "Resume v6 and Mock #4 feedback need your confirmation.", tag: "Action", urgency: "now", href: "/missions", source: "mission", priority: 113 },
|
||||
{ moduleId: "missions", title: "Final readiness unlock", subtitle: "Complete one roleplay recovery drill to generate the final checklist.", tag: "Next", urgency: "soon", href: "/missions", source: "mission", priority: 106 },
|
||||
|
||||
{ moduleId: "social", title: "LinkedIn headline v3 ready", subtitle: "Clearer target: Product Intern · FinTech · Growth Systems.", tag: "Ready", urgency: "today", href: "/agents/social-branding", source: "social", priority: 104 },
|
||||
{ moduleId: "social", title: "Featured section has 3 proof pins", subtitle: "Use resume scan, mock review, and Q Score delta as credibility blocks.", tag: "Proof", urgency: "soon", href: "/agents/social-branding", source: "social", priority: 100 },
|
||||
{ moduleId: "social", title: "Banner options queued", subtitle: "Three calm orange/blue layouts are waiting for review.", tag: "Brand", urgency: "soon", href: "/agents/social-branding", source: "social", priority: 96 },
|
||||
|
||||
{ moduleId: "pathways", title: "Pathways stay locked for demo", subtitle: "Resume + interview data is enough; pathway service is not enabled yet.", tag: "Soon", urgency: "calm", href: "/agents/matchmaking", source: "pathways", priority: 70 },
|
||||
{ moduleId: "pathways", title: "PM SaaS route predicted", subtitle: "Directional only until the pathways service is connected.", tag: "Preview", urgency: "calm", href: "/agents/matchmaking", source: "pathways", priority: 68 },
|
||||
|
||||
{ moduleId: "productivity", title: "Resume ATS 86", subtitle: "Keyword relevance +9 after JD tailoring. Open Resume Builder.", tag: "Resume", urgency: "today", href: "/agents/resume", source: "resume", priority: 118 },
|
||||
{ moduleId: "productivity", title: "Interview review is ready", subtitle: "Overall 82 · storytelling and concise examples need one more drill.", tag: "Mock", urgency: "now", href: "/agents/interview", source: "interview", priority: 117 },
|
||||
{ moduleId: "productivity", title: "Roleplay recruiter screen", subtitle: "Scenario ready · handle salary range and start-date objections.", tag: "Roleplay", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 105 },
|
||||
{ moduleId: "productivity", title: "Q Score now 86", subtitle: "Signals: resume ATS, mock review, and roleplay completion.", tag: "Q Score", urgency: "today", href: "/agents/qscore", source: "qscore", priority: 102 },
|
||||
|
||||
{ moduleId: "rewards", title: "Mentor pass preview", subtitle: "Demo reward only · would unlock after 3 completed readiness actions.", tag: "Demo", urgency: "calm", href: "/rewards", source: "rewards", priority: 60 },
|
||||
{ moduleId: "rewards", title: "Resume Pro credit queued", subtitle: "Tabled for v1; shown as a consistent notification tile.", tag: "Soon", urgency: "calm", href: "/rewards", source: "rewards", priority: 58 },
|
||||
];
|
||||
|
||||
export async function seedDemoHome(userId: string) {
|
||||
await db.insert(users).values({ id: userId, email: `${userId}@demo.local`, displayName: "Demo User" }).onConflictDoNothing();
|
||||
|
||||
await db
|
||||
.delete(growHomeNotifications)
|
||||
.where(and(eq(growHomeNotifications.userId, userId), inArray(growHomeNotifications.generatedBy, ["demo", "agent", "deterministic"])));
|
||||
|
||||
const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
|
||||
const rows: NewGrowHomeNotification[] = demoNotifications.map((n) => ({
|
||||
userId,
|
||||
moduleId: n.moduleId,
|
||||
title: n.title,
|
||||
subtitle: n.subtitle,
|
||||
tag: n.tag,
|
||||
urgency: n.urgency,
|
||||
href: n.href,
|
||||
source: n.source,
|
||||
priority: n.priority,
|
||||
generatedBy: "demo",
|
||||
reason: n.reason ?? "Seeded for multi-day dashboard demo.",
|
||||
status: "active",
|
||||
expiresAt,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
await db.insert(growHomeNotifications).values(rows);
|
||||
|
||||
await db.insert(growQscoreProjectionState).values({
|
||||
userId,
|
||||
score: 86,
|
||||
signalCount: 14,
|
||||
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
|
||||
summary: "Demo readiness score from resume, interview, and roleplay signals.",
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: growQscoreProjectionState.userId,
|
||||
set: {
|
||||
score: 86,
|
||||
signalCount: 14,
|
||||
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
|
||||
summary: "Demo readiness score from resume, interview, and roleplay signals.",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const signalRows = [
|
||||
["resume.uploaded", 100, "resume-service"],
|
||||
["resume.ats_compatibility", 86, "resume-service"],
|
||||
["resume.keyword_relevance", 84, "resume-service"],
|
||||
["interview.completed", 100, "interview-service"],
|
||||
["interview.overall_score", 82, "interview-service"],
|
||||
["interview.response_clarity", 74, "interview-service"],
|
||||
["roleplay.completed", 100, "roleplay-service"],
|
||||
["roleplay.communication_effectiveness", 79, "roleplay-service"],
|
||||
] as const;
|
||||
|
||||
for (const [signalId, score, source] of signalRows) {
|
||||
await db.insert(growQscoreLatest).values({
|
||||
userId,
|
||||
signalId,
|
||||
score,
|
||||
present: true,
|
||||
source,
|
||||
raw: { demo: true },
|
||||
occurredAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: [growQscoreLatest.userId, growQscoreLatest.signalId],
|
||||
set: { score, present: true, source, raw: { demo: true }, occurredAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await db.insert(growActiveMissions).values({
|
||||
instanceId: `demo-interview-to-offer-${userId}`,
|
||||
userId,
|
||||
missionId: "interview-to-offer",
|
||||
workflowId: "interview-to-offer",
|
||||
actorType: "interviewToOfferMissionActor",
|
||||
title: "Interview-to-Offer Accelerator",
|
||||
shortTitle: "Interview-to-Offer",
|
||||
status: "active",
|
||||
progressPercent: 72,
|
||||
currentStageId: "mock-interview-review",
|
||||
goal: "Land a PM internship offer",
|
||||
mission: { demo: true, missionId: "interview-to-offer" },
|
||||
snapshot: { demo: true, progressPercent: 72 },
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: growActiveMissions.instanceId,
|
||||
set: { status: "active", progressPercent: 72, currentStageId: "mock-interview-review", snapshot: { demo: true, progressPercent: 72 }, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
await db.insert(missionServiceSessions).values([
|
||||
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, status: "completed", metadata: { demo: true, ats: 86 }, updatedAt: new Date() },
|
||||
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, status: "review_ready", metadata: { demo: true, overall: 82 }, updatedAt: new Date() },
|
||||
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "roleplay-recovery", serviceId: "roleplay-service", externalId: `demo-roleplay-${userId}`, status: "configured", metadata: { demo: true }, updatedAt: new Date() },
|
||||
]).onConflictDoNothing();
|
||||
|
||||
await db.insert(missionArtifacts).values([
|
||||
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, type: "resume_fit_scan", title: "Resume Fit Scan", status: "ready", summary: "ATS 86 with stronger PM keywords.", metadata: { demo: true }, updatedAt: new Date() },
|
||||
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, type: "mock_interview_review", title: "Mock Interview Review", status: "ready", summary: "Overall 82; improve story brevity and tradeoff framing.", metadata: { demo: true }, updatedAt: new Date() },
|
||||
]).onConflictDoNothing();
|
||||
|
||||
return { inserted: rows.length, userId };
|
||||
}
|
||||
|
||||
const entry = process.argv[1] ?? "";
|
||||
if (entry.endsWith("seed-demo-home.ts") || entry.endsWith("seed-demo-home.js")) {
|
||||
const userId = process.env.DEMO_USER_ID;
|
||||
if (!userId) {
|
||||
console.error("Set DEMO_USER_ID to seed demo home notifications.");
|
||||
process.exit(1);
|
||||
}
|
||||
seedDemoHome(userId)
|
||||
.then((result) => {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export type HomeIdentity = {
|
||||
|
||||
export type HomeFeedResponse = {
|
||||
generatedAt: string;
|
||||
mode: "day1" | "dynamic" | "demo";
|
||||
mode: "day1" | "dynamic";
|
||||
identity: HomeIdentity;
|
||||
modules: HomeModule[];
|
||||
};
|
||||
@@ -68,9 +68,9 @@ export const ALLOWED_NOTIFICATION_HREFS = new Set([
|
||||
"/rewards",
|
||||
"/agents/resume",
|
||||
"/agents/interview",
|
||||
"/agents/interview/setup",
|
||||
"/agents/interview",
|
||||
"/agents/roleplay",
|
||||
"/agents/roleplay",
|
||||
"/agents/roleplay/setup",
|
||||
"/agents/qscore",
|
||||
]);
|
||||
|
||||
@@ -80,9 +80,9 @@ export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
|
||||
"/missions/available",
|
||||
"/agents/resume",
|
||||
"/agents/interview",
|
||||
"/agents/interview/setup",
|
||||
"/agents/interview",
|
||||
"/agents/roleplay",
|
||||
"/agents/roleplay",
|
||||
"/agents/roleplay/setup",
|
||||
"/agents/qscore",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -93,11 +93,11 @@ function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: Missio
|
||||
if (role === "Interview") {
|
||||
params.set("mode", "mission");
|
||||
params.set("prompt", `${stage.title} for ${profile.targetRole}`);
|
||||
return { label: stage.status === "in_progress" ? "Continue mock" : "Start mock", href: `/agents/interview/setup?${params.toString()}` };
|
||||
return { label: stage.status === "in_progress" ? "Continue mock" : "Start mock", href: `/agents/interview?${params.toString()}` };
|
||||
}
|
||||
if (role === "Roleplay") {
|
||||
params.set("scenario", `${stage.title} for ${profile.targetRole}`);
|
||||
return { label: stage.status === "in_progress" ? "Continue roleplay" : "Start roleplay", href: `/agents/roleplay/setup?${params.toString()}` };
|
||||
return { label: stage.status === "in_progress" ? "Continue roleplay" : "Start roleplay", href: `/agents/roleplay?${params.toString()}` };
|
||||
}
|
||||
if (role === "Resume") {
|
||||
params.set("focus", `${stage.title}: ${profile.targetRole}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { convertToModelMessages, generateText, stepCountIs, streamText, tool, type UIMessage } from "ai";
|
||||
import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, generateText, stepCountIs, streamText, tool, type UIMessage } from "ai";
|
||||
import { config } from "../config.js";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import type { Registry } from "../actors/registry.js";
|
||||
@@ -111,6 +111,7 @@ const createMissionConversationSchema = z.object({
|
||||
const streamSchema = z.object({
|
||||
messages: z.array(z.custom<UIMessage>()),
|
||||
conversationId: z.string().optional(),
|
||||
growContext: z.any().optional(),
|
||||
});
|
||||
|
||||
const createMissionInstanceId = (missionId: string) =>
|
||||
@@ -154,15 +155,25 @@ function textFromMessage(message: UIMessage | undefined) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function forcedToolForPrompt(text: string) {
|
||||
function forcedToolForPrompt(text: string, hasGrowContext = false) {
|
||||
const lower = text.toLowerCase();
|
||||
const asksForTodayTasks =
|
||||
/\b(show|list|give|get|what|which|today|daily|streak|task|tasks|mission|missions|report|progress)\b/.test(lower) &&
|
||||
/\b(today|daily|streak|task|tasks|mission|missions|day|report|progress)\b/.test(lower);
|
||||
|
||||
// Keep high-value dashboard UI predictable: when the user explicitly asks to
|
||||
// see/discover/find workflows, always produce workflow cards instead of a
|
||||
// text-only answer.
|
||||
if (hasGrowContext && /\b(report|result|outcome|kpi|summary)\b/.test(lower)) {
|
||||
return "showCareerSprintReport" as const;
|
||||
}
|
||||
|
||||
if (hasGrowContext && asksForTodayTasks) {
|
||||
return "showCareerSprintTasks" as const;
|
||||
}
|
||||
|
||||
// The legacy workflow catalog should only show when the user explicitly asks
|
||||
// for broader programs/workflows, not when they ask for streak missions.
|
||||
if (
|
||||
/\b(discover|find|show|list|recommend|suggest|compare)\b/.test(lower) &&
|
||||
/\b(workflow|workflows|mission|missions|plan|plans|program|programs)\b/.test(lower)
|
||||
/\b(workflow|workflows|program|programs)\b/.test(lower)
|
||||
) {
|
||||
return "discoverWorkflows" as const;
|
||||
}
|
||||
@@ -170,6 +181,27 @@ function forcedToolForPrompt(text: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildGrowContextPrompt(growContext: any) {
|
||||
if (!growContext || typeof growContext !== "object") return "";
|
||||
const activeDay = growContext.activeDay;
|
||||
const tasks = Array.isArray(activeDay?.tasks) ? activeDay.tasks : [];
|
||||
const taskLines = tasks
|
||||
.map((task: any, index: number) => ` ${index + 1}. ${task.title ?? "Task"} — ${task.serviceName ?? task.serviceId ?? "service"} — status: ${task.status ?? "pending"} — route: ${task.route ?? "none"}`)
|
||||
.join("\n");
|
||||
return `\n\nCurrent CareerSprint context from the dashboard/streak UI:
|
||||
- localDate: ${growContext.localDate ?? "unknown"}
|
||||
- activeDayIndex: ${growContext.activeDayIndex ?? "unknown"}
|
||||
- streak: ${growContext.streak ?? 0} day(s)
|
||||
- progress: ${growContext.completedCount ?? 0}/${growContext.totalCount ?? 0} tasks (${growContext.overallProgressPercent ?? 0}%)
|
||||
- reportEligible: ${growContext.reportEligible ? "yes" : "no"}
|
||||
- activeDayTitle: ${activeDay?.title ?? "today"}
|
||||
- activeDayCompleted: ${activeDay?.completedCount ?? 0}/${activeDay?.totalCount ?? tasks.length}
|
||||
- activeDayTasks:
|
||||
${taskLines || " none"}
|
||||
|
||||
When the user asks for missions, tasks, today, streak status, missed work, or report readiness, use this CareerSprint context instead of the old workflow catalog. If reportEligible is true and the user asks about a report, ask whether they want to open the report. If activeDayIndex is 8 or higher, explain recovery/missed tasks only when there are incomplete tasks.`;
|
||||
}
|
||||
|
||||
async function buildMissionContextPrompt(userId: string, conversationId: string) {
|
||||
const metadata = await getConversationMetadataPg(userId, conversationId);
|
||||
const missionInstanceId = typeof metadata.missionInstanceId === "string" ? metadata.missionInstanceId : undefined;
|
||||
@@ -207,7 +239,8 @@ How to help:
|
||||
- Don't call tools for general chitchat, emotional support, simple explanations, or when a normal text answer would do.
|
||||
- If you don't know something, say so. If a tool fails, just say it failed and move on.
|
||||
- When the user asks about interview prep, roleplay, resume, or Q Score — just answer normally. Only route to a specialist tool if they explicitly say something like "connect me to the interview specialist" or "let me talk to the roleplay agent".
|
||||
- When the user asks to see missions or plans, call discoverWorkflows or showMissions. When they ask about memory, use the memory tools. Otherwise, just chat.
|
||||
- When the user asks to see today’s missions, streak tasks, daily tasks, missed tasks, task progress, or report readiness, call showCareerSprintTasks if CareerSprint context is available.
|
||||
- Use discoverWorkflows only for broad workflow/program discovery. When they ask about memory, use the memory tools. Otherwise, just chat.
|
||||
- Only start a mission if the user clearly says yes to starting one. Don't push.
|
||||
- When you write memory, a quick "Saved." is enough. No need to over-confirm.${missionContext}`
|
||||
}
|
||||
@@ -264,8 +297,107 @@ When helping:
|
||||
Return compact, bullet-heavy markdown.`,
|
||||
};
|
||||
|
||||
function buildConversationTools(userId: string) {
|
||||
function careerSprintTasksPayload(growContext: any, dayIndex?: number) {
|
||||
const days = Array.isArray(growContext?.days) ? growContext.days : [];
|
||||
const requestedDay = typeof dayIndex === "number" ? dayIndex : Number(growContext?.activeDayIndex ?? 1);
|
||||
const clampedDay = Math.max(1, Math.min(7, requestedDay || 1));
|
||||
const activeDay = days.find((day: any) => Number(day?.dayIndex) === clampedDay) ?? growContext?.activeDay ?? days[0] ?? null;
|
||||
const tasks = Array.isArray(activeDay?.tasks) ? activeDay.tasks : [];
|
||||
const missedTasks = days.flatMap((day: any) => (Array.isArray(day?.tasks) ? day.tasks : []).filter((task: any) => task?.status !== "completed"));
|
||||
return {
|
||||
kind: "career-sprint-tasks",
|
||||
localDate: growContext?.localDate,
|
||||
activeDayIndex: growContext?.activeDayIndex,
|
||||
requestedDayIndex: clampedDay,
|
||||
streak: growContext?.streak ?? 0,
|
||||
completedCount: growContext?.completedCount ?? 0,
|
||||
totalCount: growContext?.totalCount ?? 0,
|
||||
overallProgressPercent: growContext?.overallProgressPercent ?? 0,
|
||||
reportEligible: Boolean(growContext?.reportEligible),
|
||||
isRecoveryDay: Number(growContext?.activeDayIndex ?? 1) > 7,
|
||||
missedCount: missedTasks.length,
|
||||
missedTasks,
|
||||
day: activeDay,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
function careerSprintReportPayload(growContext: any) {
|
||||
const days = Array.isArray(growContext?.days) ? growContext.days : [];
|
||||
const completedCount = Number(growContext?.completedCount ?? 0);
|
||||
const totalCount = Number(growContext?.totalCount ?? 0);
|
||||
const reportEligible = Boolean(growContext?.reportEligible);
|
||||
const missedTasks = days.flatMap((day: any) => (Array.isArray(day?.tasks) ? day.tasks : []).filter((task: any) => task?.status !== "completed"));
|
||||
const params = new URLSearchParams();
|
||||
if (typeof growContext?.demoIcpId === "string" && growContext.demoIcpId) params.set("icpId", growContext.demoIcpId);
|
||||
if (Number.isFinite(Number(growContext?.activeDayIndex))) params.set("dayIndex", String(growContext.activeDayIndex));
|
||||
const href = `/agents/careersprint-report${params.size ? `?${params.toString()}` : ""}`;
|
||||
return {
|
||||
kind: "career-sprint-report-status",
|
||||
localDate: growContext?.localDate,
|
||||
activeDayIndex: growContext?.activeDayIndex,
|
||||
streak: growContext?.streak ?? 0,
|
||||
completedCount,
|
||||
totalCount,
|
||||
overallProgressPercent: growContext?.overallProgressPercent ?? 0,
|
||||
reportEligible,
|
||||
href,
|
||||
missedCount: missedTasks.length,
|
||||
missedTasks: missedTasks.slice(0, 8),
|
||||
};
|
||||
}
|
||||
|
||||
function deterministicCareerSprintResponse(options: {
|
||||
originalMessages: UIMessage[];
|
||||
toolName: "showCareerSprintTasks" | "showCareerSprintReport";
|
||||
input: Record<string, unknown>;
|
||||
output: unknown;
|
||||
onFinish: (responseMessage: UIMessage) => Promise<void>;
|
||||
}) {
|
||||
const messageId = `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const toolCallId = `call-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const stream = createUIMessageStream<UIMessage>({
|
||||
originalMessages: options.originalMessages,
|
||||
execute: ({ writer }) => {
|
||||
writer.write({ type: "start", messageId } as any);
|
||||
writer.write({ type: "start-step" } as any);
|
||||
writer.write({
|
||||
type: "tool-input-available",
|
||||
toolCallId,
|
||||
toolName: options.toolName,
|
||||
input: options.input,
|
||||
} as any);
|
||||
writer.write({
|
||||
type: "tool-output-available",
|
||||
toolCallId,
|
||||
output: options.output,
|
||||
} as any);
|
||||
writer.write({ type: "finish-step" } as any);
|
||||
writer.write({ type: "finish", finishReason: "stop" } as any);
|
||||
},
|
||||
onFinish: async ({ responseMessage }) => {
|
||||
await options.onFinish(responseMessage as UIMessage);
|
||||
},
|
||||
});
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
}
|
||||
|
||||
function buildConversationTools(userId: string, growContext?: any) {
|
||||
return {
|
||||
showCareerSprintTasks: tool({
|
||||
description: "Return the current CareerSprint streak tasks from the dashboard context. Use for today missions, daily tasks, streak status, missed tasks, completed tasks, Day 8/recovery, and report readiness.",
|
||||
inputSchema: z.object({
|
||||
dayIndex: z.number().optional().describe("Optional requested CareerSprint day number. Defaults to active day."),
|
||||
}),
|
||||
execute: async ({ dayIndex }) => careerSprintTasksPayload(growContext, dayIndex),
|
||||
}),
|
||||
|
||||
showCareerSprintReport: tool({
|
||||
description: "Return CareerSprint report readiness, KPI completion status, and the report link. Use when the user asks for report, result, outcome, KPI, or 7-day summary.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => careerSprintReportPayload(growContext),
|
||||
}),
|
||||
|
||||
discoverWorkflows: tool({
|
||||
description: "Return sellable GrowQR missions as rich UI-ready discovery cards. Use whenever the user asks to see, find, discover, compare, choose, recommend, or understand missions/workflows.",
|
||||
inputSchema: z.object({
|
||||
@@ -364,33 +496,36 @@ function buildConversationTools(userId: string) {
|
||||
inputSchema: z.object({
|
||||
focus: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ focus }) => ({
|
||||
kind: "missions",
|
||||
focus,
|
||||
missions: [
|
||||
{
|
||||
id: "mission-share-goal",
|
||||
title: "Tell Grow what you are aiming for",
|
||||
description: "Share target role, company, deadline, and biggest blocker so the agent can set up your first mission.",
|
||||
status: "suggested",
|
||||
rewardLabel: "+10 readiness",
|
||||
},
|
||||
{
|
||||
id: "mission-pick-workflow",
|
||||
title: "Pick a mission to activate",
|
||||
description: "Start Interview-to-Offer if you have an interview coming up, or Career Transition if you are exploring a pivot.",
|
||||
status: "suggested",
|
||||
rewardLabel: "Unlock plan",
|
||||
},
|
||||
{
|
||||
id: "mission-save-memory",
|
||||
title: "Save one durable career fact",
|
||||
description: "Let Grow remember a role target, resume link, deadline, or interview date.",
|
||||
status: "suggested",
|
||||
rewardLabel: "Better recommendations",
|
||||
},
|
||||
],
|
||||
}),
|
||||
execute: async ({ focus }) => {
|
||||
if (growContext && typeof growContext === "object") return careerSprintTasksPayload(growContext);
|
||||
return {
|
||||
kind: "missions",
|
||||
focus,
|
||||
missions: [
|
||||
{
|
||||
id: "mission-share-goal",
|
||||
title: "Tell Grow what you are aiming for",
|
||||
description: "Share target role, company, deadline, and biggest blocker so the agent can set up your first mission.",
|
||||
status: "suggested",
|
||||
rewardLabel: "+10 readiness",
|
||||
},
|
||||
{
|
||||
id: "mission-pick-workflow",
|
||||
title: "Pick a mission to activate",
|
||||
description: "Start Interview-to-Offer if you have an interview coming up, or Career Transition if you are exploring a pivot.",
|
||||
status: "suggested",
|
||||
rewardLabel: "Unlock plan",
|
||||
},
|
||||
{
|
||||
id: "mission-save-memory",
|
||||
title: "Save one durable career fact",
|
||||
description: "Let Grow remember a role target, resume link, deadline, or interview date.",
|
||||
status: "suggested",
|
||||
rewardLabel: "Better recommendations",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
startWorkflow: tool({
|
||||
@@ -657,13 +792,37 @@ export function conversationRoutes() {
|
||||
withActorRecovery("conversationActor", () => conversation.addMessage({ role: "user", content: latestUserText, sender: "User" })).catch((err) => console.warn("conversationActor user-message mirror failed", err));
|
||||
}
|
||||
|
||||
const visualTool = forcedToolForPrompt(latestUserText);
|
||||
const missionContext = await buildMissionContextPrompt(userId, conversationId);
|
||||
const visualTool = forcedToolForPrompt(latestUserText, Boolean(body.growContext));
|
||||
if (visualTool === "showCareerSprintTasks" || visualTool === "showCareerSprintReport") {
|
||||
const output = visualTool === "showCareerSprintTasks"
|
||||
? careerSprintTasksPayload(body.growContext)
|
||||
: careerSprintReportPayload(body.growContext);
|
||||
return deterministicCareerSprintResponse({
|
||||
originalMessages: body.messages,
|
||||
toolName: visualTool,
|
||||
input: {},
|
||||
output,
|
||||
onFinish: async (responseMessage) => {
|
||||
const assistantText = textFromMessage(responseMessage);
|
||||
await addMessagePg(userId, {
|
||||
id: responseMessage.id,
|
||||
conversationId,
|
||||
role: "assistant",
|
||||
content: assistantText || JSON.stringify(responseMessage.parts ?? []),
|
||||
sender: "GrowQR",
|
||||
});
|
||||
await touchConversationPg(userId, conversationId, latestUserText.slice(0, 64) || undefined);
|
||||
await growPromise;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const missionContext = `${await buildMissionContextPrompt(userId, conversationId)}${buildGrowContextPrompt(body.growContext)}`;
|
||||
const result = streamText({
|
||||
model: getConversationModel(),
|
||||
system: buildSystemPrompt(missionContext),
|
||||
messages: await convertToModelMessages(body.messages),
|
||||
tools: buildConversationTools(userId),
|
||||
tools: buildConversationTools(userId, body.growContext),
|
||||
toolChoice: visualTool ? { type: "tool", toolName: visualTool } : "auto",
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
@@ -686,8 +845,7 @@ export function conversationRoutes() {
|
||||
sender: "GrowQR",
|
||||
})).catch((err) => console.warn("conversationActor assistant-message mirror failed", err));
|
||||
await touchConversationPg(userId, conversationId, latestUserText.slice(0, 64) || undefined);
|
||||
const grow = await growPromise;
|
||||
if (grow) await grow.touchConversation({ conversationId, title: latestUserText.slice(0, 64) || undefined }).catch((err) => console.warn("growActor touch mirror failed", err));
|
||||
await growPromise;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
markGrowEventFailed,
|
||||
markGrowEventProcessed,
|
||||
markGrowEventProcessing,
|
||||
recordGrowEvent,
|
||||
recordGrowEventWithResult,
|
||||
shouldRouteGrowEvent,
|
||||
} from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { applyQscoreProjection } from "../events/projectors/qscore-projector.js";
|
||||
import { applyServiceSessionProjection } from "../events/projectors/service-session-projector.js";
|
||||
import { ensureOnboardingSideEffectsForEvent } from "../events/onboarding-ledger.js";
|
||||
|
||||
function serviceAuthorized(auth: string | undefined) {
|
||||
@@ -22,15 +24,24 @@ function serviceAuthorized(auth: string | undefined) {
|
||||
}
|
||||
|
||||
async function ingest(body: unknown, userId?: string, source?: string) {
|
||||
const event = await recordGrowEvent(body, { userId, source });
|
||||
const route = await routeGrowEventToUserActor(event).catch((err) => ({
|
||||
routed: false as const,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
if (event.processingStatus === "processed") {
|
||||
const { event, inserted } = await recordGrowEventWithResult(body, { userId, source });
|
||||
|
||||
// Route only newly inserted, user-resolved, pending events to the actor.
|
||||
// Dedupe hits (inserted=false) never re-enqueue, preventing actor saturation.
|
||||
const routed = shouldRouteGrowEvent(event, inserted);
|
||||
const route = routed
|
||||
? await routeGrowEventToUserActor(event).catch((err) => ({
|
||||
routed: false as const,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
}))
|
||||
: { routed: false as const, reason: "dedupe_or_unresolved" as const };
|
||||
|
||||
// Dedupe hit: event was already processed (or is unresolved). Return early
|
||||
// with idempotent semantics — no in-process re-projection.
|
||||
if (!inserted) {
|
||||
return {
|
||||
event,
|
||||
processingStatus: "processed" as const,
|
||||
processingStatus: event.processingStatus,
|
||||
route,
|
||||
qscore: { signals: [], score: undefined, idempotent: true },
|
||||
onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "already_processed" } },
|
||||
@@ -48,6 +59,7 @@ async function ingest(body: unknown, userId?: string, source?: string) {
|
||||
|
||||
await markGrowEventProcessing(event.id);
|
||||
try {
|
||||
await applyServiceSessionProjection(event);
|
||||
const qscore = await applyQscoreProjection(event);
|
||||
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
|
||||
if (
|
||||
@@ -56,6 +68,11 @@ async function ingest(body: unknown, userId?: string, source?: string) {
|
||||
) {
|
||||
throw new Error("curator_onboarding_loop_failed");
|
||||
}
|
||||
// Always mark "processed" after successful synchronous projections, regardless
|
||||
// of route outcome. The actor will still run mission reducers on "processed"
|
||||
// events (isProcessableStatus allows "processed") — projections are idempotent
|
||||
// and the in-memory processedEventIds guard prevents double-processing.
|
||||
// This avoids orphaning the event if the route call failed.
|
||||
await markGrowEventProcessed(event.id);
|
||||
return { event, processingStatus: "processed" as const, route, qscore, onboarding };
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { config } from "../config.js";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { dismissHomeNotification, getHomeFeed, getHomeFeedDebugCounts } from "../home/home-feed.js";
|
||||
import { HomeFeedAgentError } from "../home/home-feed-agent.js";
|
||||
import { seedDemoHome } from "../home/seed-demo-home.js";
|
||||
import { getRequestUserProfile } from "../services/user-context.js";
|
||||
import { log } from "../log.js";
|
||||
import {
|
||||
@@ -16,10 +13,6 @@ import {
|
||||
type SystemNotificationPreference,
|
||||
} from "../notifications/system-notifications.js";
|
||||
|
||||
function canSeedDemo(userId: string) {
|
||||
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
|
||||
}
|
||||
|
||||
export function homeRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
@@ -31,21 +24,7 @@ export function homeRoutes() {
|
||||
log.warn({ err, userId }, "home feed continuing without user-service profile");
|
||||
return {};
|
||||
});
|
||||
try {
|
||||
return c.json(await getHomeFeed(userId, { refresh, ...profile }));
|
||||
} catch (err) {
|
||||
if (err instanceof HomeFeedAgentError) {
|
||||
log.warn({ err, userId }, "home feed generation unavailable");
|
||||
return c.json(
|
||||
{
|
||||
error: "home_feed_generation_unavailable",
|
||||
message: "Home feed generation is temporarily unavailable. Please retry.",
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return c.json(await getHomeFeed(userId, { refresh, ...profile }));
|
||||
});
|
||||
|
||||
app.post("/notifications/:id/dismiss", async (c) => {
|
||||
@@ -81,11 +60,5 @@ export function homeRoutes() {
|
||||
|
||||
app.get("/debug/counts", async (c) => c.json(await getHomeFeedDebugCounts(c.get("userId"))));
|
||||
|
||||
app.post("/demo/seed", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
if (!canSeedDemo(userId)) return c.json({ error: "forbidden" }, 403);
|
||||
return c.json(await seedDemoHome(userId));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { Hono } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { createHash } from "node:crypto";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { config } from "../config.js";
|
||||
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
|
||||
import { interviewService, ProductServiceError, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { events, growQscoreLatest, growQscoreProjectionState } from "../db/schema.js";
|
||||
import { events, growEvents, missionServiceSessions } from "../db/schema.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
|
||||
import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js";
|
||||
import { buildMatchmakingGatewayEvent } from "../services/matchmaking-events.js";
|
||||
import { log } from "../log.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../services/qscore-proxy.js";
|
||||
|
||||
const LANDING_AGENTS = [
|
||||
{ id: "resume", title: "Resume", agent: "Resume Strategist", description: "Resume proof, versions, parsing, analysis, and mission artifacts.", route: "/agents/resume" },
|
||||
@@ -75,6 +77,80 @@ function curatorTaskIdFromRequest(req: Request, body?: JsonObject) {
|
||||
return getString(url.searchParams.get("curatorTaskId"));
|
||||
}
|
||||
|
||||
function orchestrationFromMission(mission?: Record<string, unknown>, taskId?: string) {
|
||||
return {
|
||||
...(taskId ? { taskId, curatorTaskId: taskId } : {}),
|
||||
...(getString(mission?.missionId ?? mission?.mission_id) ? { missionId: getString(mission?.missionId ?? mission?.mission_id) } : {}),
|
||||
...(getString(mission?.instanceId ?? mission?.instance_id ?? mission?.missionInstanceId ?? mission?.mission_instance_id)
|
||||
? { missionInstanceId: getString(mission?.instanceId ?? mission?.instance_id ?? mission?.missionInstanceId ?? mission?.mission_instance_id) }
|
||||
: {}),
|
||||
...(getString(mission?.stageId ?? mission?.stage_id) ? { stageId: getString(mission?.stageId ?? mission?.stage_id) } : {}),
|
||||
...(getString(mission?.source) ? { source: getString(mission?.source) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type SessionMissionContext = {
|
||||
mission?: Record<string, unknown>;
|
||||
taskId?: string;
|
||||
};
|
||||
|
||||
async function sessionMissionContext(serviceId: "interview" | "roleplay", sessionId: string): Promise<SessionMissionContext> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
missionInstanceId: missionServiceSessions.missionInstanceId,
|
||||
missionId: missionServiceSessions.missionId,
|
||||
stageId: missionServiceSessions.stageId,
|
||||
metadata: missionServiceSessions.metadata,
|
||||
})
|
||||
.from(missionServiceSessions)
|
||||
.where(and(eq(missionServiceSessions.serviceId, serviceId), eq(missionServiceSessions.externalId, sessionId)))
|
||||
.limit(1);
|
||||
const metadata = row && isRecord(row.metadata) ? row.metadata : {};
|
||||
const fromSession: SessionMissionContext = {
|
||||
mission: row && (row.missionInstanceId || row.missionId || row.stageId)
|
||||
? {
|
||||
instanceId: row.missionInstanceId ?? undefined,
|
||||
missionId: row.missionId ?? undefined,
|
||||
stageId: row.stageId ?? undefined,
|
||||
source: "curator-v1",
|
||||
}
|
||||
: undefined,
|
||||
taskId: getString(metadata.taskId ?? metadata.curatorTaskId ?? metadata.curator_task_id),
|
||||
};
|
||||
if (fromSession.taskId || fromSession.mission) return fromSession;
|
||||
|
||||
const eventRows = await db
|
||||
.select({
|
||||
mission: growEvents.mission,
|
||||
correlation: growEvents.correlation,
|
||||
payload: growEvents.payload,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.source, `${serviceId}-service`), eq(growEvents.type, serviceId === "roleplay" ? "roleplay.scenario.configured" : "interview.session.configured")))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(50);
|
||||
const eventRow = eventRows.find((item) => {
|
||||
const correlation = isRecord(item.correlation) ? item.correlation : {};
|
||||
return getString(correlation.sessionId ?? correlation.session_id ?? correlation.externalId) === sessionId;
|
||||
});
|
||||
|
||||
if (!eventRow) return fromSession;
|
||||
const correlation = isRecord(eventRow.correlation) ? eventRow.correlation : {};
|
||||
const payload = isRecord(eventRow.payload) ? eventRow.payload : {};
|
||||
const request = isRecord(payload.request) ? payload.request : {};
|
||||
return {
|
||||
mission: isRecord(eventRow.mission) ? eventRow.mission : fromSession.mission,
|
||||
taskId: getString(
|
||||
correlation.taskId ??
|
||||
correlation.curatorTaskId ??
|
||||
correlation.curator_task_id ??
|
||||
request.taskId ??
|
||||
request.curatorTaskId ??
|
||||
request.curator_task_id,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function stripMissionFromBody(body: JsonObject): JsonObject {
|
||||
if (!("mission" in body)) return body;
|
||||
const { mission: _mission, ...rest } = body;
|
||||
@@ -85,6 +161,7 @@ function canonicalSubjectServiceId(source: string) {
|
||||
if (source.includes("interview")) return "interview";
|
||||
if (source.includes("roleplay")) return "roleplay";
|
||||
if (source.includes("resume")) return "resume";
|
||||
if (source.includes("social")) return "social";
|
||||
if (source.includes("qscore")) return "qscore";
|
||||
if (source.includes("matchmaking")) return "matchmaking";
|
||||
if (source.includes("analytics")) return "analytics";
|
||||
@@ -218,6 +295,23 @@ function resumeEventTypeForRest(method: string, rest: string, ok: boolean) {
|
||||
return "resume.loaded";
|
||||
}
|
||||
|
||||
function socialEventTypeForRest(method: string, rest: string, ok: boolean) {
|
||||
if (!ok) return "social.request_failed";
|
||||
const m = method.toUpperCase();
|
||||
const path = rest.replace(/^\/+/, "").split("?")[0] ?? rest;
|
||||
if (m === "POST" && /^accounts\/connect/.test(path)) return "social.account.connected";
|
||||
if (m === "POST" && /^accounts\/[^/]+\/profile\/polish/.test(path)) return "social.profile.polished";
|
||||
if (m === "POST" && /^accounts\/[^/]+\/profile\/refresh/.test(path)) return "social.profile.refreshed";
|
||||
if (m === "POST" && /^accounts\/[^/]+\/profile\/apply/.test(path)) return "social.profile.updated";
|
||||
if (m === "POST" && /^accounts\/[^/]+\/profile\/refine-experience/.test(path)) return "social.experience.refined";
|
||||
if (m === "POST" && /^profiles\/fetch-by-url/.test(path)) return "social.profile.fetched";
|
||||
if (m === "POST" && /^accounts\/[^/]+\/(content|posts)/.test(path)) return "social.content.generated";
|
||||
if (m !== "GET" && m !== "HEAD" && /^accounts\/[^/]+\/schedule/.test(path)) return "social.post.scheduled";
|
||||
if (m !== "GET" && m !== "HEAD" && /^accounts\/[^/]+\/publish/.test(path)) return "social.post.published";
|
||||
if (m === "POST" || m === "PUT" || m === "PATCH") return "social.updated";
|
||||
return "social.updated";
|
||||
}
|
||||
|
||||
function agentDataAction(result: Record<string, unknown>) {
|
||||
const messages = Array.isArray(result.messages) ? result.messages : [];
|
||||
for (const item of messages) {
|
||||
@@ -270,18 +364,6 @@ function serviceErrorResponse(err: unknown): never {
|
||||
throw err;
|
||||
}
|
||||
|
||||
function matchmakingEventType(action: string, response: Record<string, unknown>) {
|
||||
if (response.status === "error") return "matchmaking.request.failed";
|
||||
if (action === "run_search" || action === "generate_matches") return "matchmaking.matches.generated";
|
||||
if (action === "get_scout_feed" || action === "get_feed" || action === "session_start") return "matchmaking.feed.viewed";
|
||||
if (action === "get_opportunity_detail" || action === "mark_viewed") return "matchmaking.match.viewed";
|
||||
if (action === "mark_saved" || action === "record_feedback") return "matchmaking.match.saved";
|
||||
if (action === "dismiss_opportunity") return "matchmaking.match.dismissed";
|
||||
if (action === "tailor_resume") return "matchmaking.application.started";
|
||||
if (action === "submit_application") return "matchmaking.application.completed";
|
||||
return "matchmaking.workflow.completed";
|
||||
}
|
||||
|
||||
async function callMatchmakingA2a(body: Record<string, unknown>, userId: string) {
|
||||
const target = new URL("/a2a/tasks", config.matchmakingServiceUrl.replace(/\/$/, ""));
|
||||
const action = getString(body.action) ?? (body.session_start ? "session_start" : "");
|
||||
@@ -314,6 +396,17 @@ function parseJsonBody(body: ArrayBuffer | undefined, headers: Headers): JsonObj
|
||||
}
|
||||
}
|
||||
|
||||
function decodedProxyHeaders(headers: Headers) {
|
||||
const forwarded = new Headers(headers);
|
||||
// Node fetch transparently decodes gzip/br bodies but retains the upstream
|
||||
// encoding and byte-length headers. Forwarding those stale headers makes the
|
||||
// next proxy/browser decode an already-decoded body and fail.
|
||||
forwarded.delete("content-encoding");
|
||||
forwarded.delete("content-length");
|
||||
forwarded.delete("transfer-encoding");
|
||||
return forwarded;
|
||||
}
|
||||
|
||||
async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
const incoming = new URL(req.url);
|
||||
const normalizedRest = rest
|
||||
@@ -337,6 +430,7 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
headers.delete("cookie");
|
||||
headers.set("authorization", `Bearer ${config.a2aAllowedKey}`);
|
||||
headers.set("x-growqr-user", userId);
|
||||
headers.set("accept-encoding", "identity");
|
||||
|
||||
const method = req.method.toUpperCase();
|
||||
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
|
||||
@@ -354,7 +448,11 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
});
|
||||
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: decodedProxyHeaders(res.headers),
|
||||
});
|
||||
}
|
||||
|
||||
const responseBuffer = await res.arrayBuffer();
|
||||
@@ -379,7 +477,7 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
return new Response(responseBuffer, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
headers: decodedProxyHeaders(res.headers),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -437,6 +535,9 @@ async function resolveGrowUserContext(req: Request, userId: string): Promise<Rec
|
||||
if (resumeState.current_company && !userContext.current_company) userContext.current_company = resumeState.current_company;
|
||||
if (Array.isArray(resumeState.experience_history)) userContext.experience_history = resumeState.experience_history;
|
||||
if (Array.isArray(resumeState.education)) userContext.education = resumeState.education;
|
||||
userContext.resume_available = Number(resumeState.resume_count ?? 0) > 0;
|
||||
const resumeSummary = getString(resumeState.resume_summary ?? resumeState.resumeSummary);
|
||||
if (resumeSummary) userContext.resume_summary = resumeSummary;
|
||||
}
|
||||
|
||||
if (socialState) {
|
||||
@@ -452,7 +553,9 @@ async function resolveGrowUserContext(req: Request, userId: string): Promise<Rec
|
||||
|
||||
function hasResumeContext(userContext: Record<string, unknown>): boolean {
|
||||
return Boolean(
|
||||
(Array.isArray(userContext.experience_history) && userContext.experience_history.length > 0) ||
|
||||
userContext.resume_available ||
|
||||
getString(userContext.resume_summary) ||
|
||||
(Array.isArray(userContext.experience_history) && userContext.experience_history.length > 0) ||
|
||||
(Array.isArray(userContext.skills) && userContext.skills.length > 0) ||
|
||||
getString(userContext.current_role)
|
||||
);
|
||||
@@ -474,6 +577,9 @@ function composeCandidateProfile(userContext: Record<string, unknown>): string {
|
||||
if (currentRole) lines.push(`Current role: ${currentRole}`);
|
||||
if (headline && headline.toLowerCase() !== currentRole?.toLowerCase()) lines.push(`LinkedIn headline: ${headline}`);
|
||||
|
||||
const resumeSummary = getString(userContext.resume_summary);
|
||||
if (resumeSummary) lines.push(`Primary resume summary: ${resumeSummary.length > 600 ? `${resumeSummary.slice(0, 600).trimEnd()}…` : resumeSummary}`);
|
||||
|
||||
const skills = stringArray(userContext.skills);
|
||||
if (skills.length) lines.push(`Key skills: ${skills.slice(0, 12).join(", ")}`);
|
||||
|
||||
@@ -509,7 +615,7 @@ function composeCandidateProfile(userContext: Record<string, unknown>): string {
|
||||
if (education.length) lines.push(`Education: ${education.slice(0, 3).join("; ")}`);
|
||||
|
||||
const summary = getString(userContext.linkedin_summary);
|
||||
if (summary) lines.push(`Profile summary: ${summary.length > 300 ? `${summary.slice(0, 300).trimEnd()}…` : summary}`);
|
||||
if (summary && summary !== resumeSummary) lines.push(`LinkedIn summary: ${summary.length > 300 ? `${summary.slice(0, 300).trimEnd()}…` : summary}`);
|
||||
|
||||
const text = lines.join("\n");
|
||||
return text.length > 1200 ? `${text.slice(0, 1200).trimEnd()}…` : text;
|
||||
@@ -531,7 +637,8 @@ async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject,
|
||||
difficulty: getString(incomingContext.difficulty) ?? "medium",
|
||||
};
|
||||
|
||||
if (incomingContext.personalize) {
|
||||
const curatorHandoff = getString(incomingContext.source) === "curator-v1" || Boolean(getString(incomingContext.curatorTaskId));
|
||||
if (incomingContext.personalize || curatorHandoff) {
|
||||
const candidateProfile = composeCandidateProfile(userContext);
|
||||
if (candidateProfile) context.candidate_profile = candidateProfile;
|
||||
}
|
||||
@@ -581,8 +688,13 @@ async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: Jso
|
||||
async function proxySocialRequest(req: Request, rest: string, userId: string) {
|
||||
const incoming = new URL(req.url);
|
||||
const normalizedRest = rest.replace(/^\/+/, "");
|
||||
const forwardedQuery = new URLSearchParams(incoming.searchParams);
|
||||
forwardedQuery.delete("missionInstanceId");
|
||||
forwardedQuery.delete("missionId");
|
||||
forwardedQuery.delete("stageId");
|
||||
forwardedQuery.delete("source");
|
||||
const target = new URL(
|
||||
`/api/v1/${normalizedRest}${incoming.search}`,
|
||||
`/${normalizedRest}${forwardedQuery.toString() ? `?${forwardedQuery.toString()}` : ""}`,
|
||||
config.socialBrandingServiceUrl.replace(/\/$/, ""),
|
||||
);
|
||||
|
||||
@@ -593,9 +705,50 @@ async function proxySocialRequest(req: Request, rest: string, userId: string) {
|
||||
|
||||
const method = req.method.toUpperCase();
|
||||
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
|
||||
const res = await fetch(target, { method, headers, body });
|
||||
const requestJson = parseJsonBody(body, headers);
|
||||
const mission = missionFromRequest(req, requestJson);
|
||||
// Strip mission fields from the forwarded body, mirroring the resume proxy,
|
||||
// so orchestration metadata is not passed downstream.
|
||||
const forwardBody =
|
||||
body && headers.get("content-type")?.includes("application/json")
|
||||
? Buffer.from(JSON.stringify(stripMissionFromBody(requestJson)))
|
||||
: body;
|
||||
if (forwardBody !== body) headers.delete("content-length");
|
||||
const res = await fetch(target, { method, headers, body: forwardBody });
|
||||
|
||||
return new Response(res.body, {
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
||||
}
|
||||
|
||||
// Security note: the social-branding service handles LinkedIn sessions, tokens,
|
||||
// cookies, and credentials. The gateway event payload intentionally carries ONLY
|
||||
// canonical metadata (path/method/status/action) — never raw request/response bodies.
|
||||
const responseBuffer = await res.arrayBuffer();
|
||||
const responseText = Buffer.from(responseBuffer).toString("utf8");
|
||||
let responseJson: unknown;
|
||||
try { responseJson = responseText ? JSON.parse(responseText) : undefined; } catch { responseJson = undefined; }
|
||||
const responseObj = isRecord(responseJson) ? responseJson : {};
|
||||
|
||||
const eventType = socialEventTypeForRest(method, normalizedRest, res.ok);
|
||||
const derivedAction = eventType.split(".").slice(1).join(".");
|
||||
const accountMatch = normalizedRest.match(/^accounts\/(?!connect\/?$)[^/]+\/.+/);
|
||||
const externalId =
|
||||
(accountMatch ? accountMatch[0].split("/")[1] : undefined) ??
|
||||
getString(responseObj.account_id ?? responseObj.accountId ?? responseObj.id);
|
||||
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "agent-social-branding",
|
||||
type: eventType,
|
||||
payload: { path: normalizedRest, method, status: res.status, action: derivedAction },
|
||||
correlation: {
|
||||
externalId: externalId ?? `${canonicalSubjectServiceId("agent-social-branding")}:${eventType}`,
|
||||
taskId: curatorTaskIdFromRequest(req, requestJson),
|
||||
},
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err, path: normalizedRest }, "failed to record social gateway event"));
|
||||
|
||||
return new Response(responseBuffer, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
@@ -660,68 +813,61 @@ export function serviceRoutes() {
|
||||
log.warn({ err, userId }, "failed to seed onboarding Q Score baseline before current Q Score read");
|
||||
}
|
||||
|
||||
const [projection] = await db
|
||||
.select()
|
||||
.from(growQscoreProjectionState)
|
||||
.where(eq(growQscoreProjectionState.userId, userId))
|
||||
.limit(1);
|
||||
const signals = await db
|
||||
.select()
|
||||
.from(growQscoreLatest)
|
||||
.where(eq(growQscoreLatest.userId, userId))
|
||||
.orderBy(desc(growQscoreLatest.updatedAt));
|
||||
const qscoreResult = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
|
||||
const score = qscoreResult?.q_score ?? null;
|
||||
const breakdown = qscoreResult?.breakdown ?? {};
|
||||
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
|
||||
const quotients = qscoreResult?.quotients ?? {};
|
||||
|
||||
const grouped = new Map<string, { label: string; score: number; count: number; sources: Set<string> }>();
|
||||
for (const signal of signals) {
|
||||
const groupId = signal.signalId.split(".")[0] || "readiness";
|
||||
const current = grouped.get(groupId) ?? {
|
||||
label: groupId.replace(/-/g, " ").replace(/^./, (char) => char.toUpperCase()),
|
||||
score: 0,
|
||||
count: 0,
|
||||
sources: new Set<string>(),
|
||||
};
|
||||
current.score += signal.score;
|
||||
current.count += 1;
|
||||
if (signal.source) current.sources.add(signal.source);
|
||||
grouped.set(groupId, current);
|
||||
}
|
||||
|
||||
const dimensions = Array.from(grouped.entries()).map(([id, group]) => ({
|
||||
id,
|
||||
label: group.label,
|
||||
score: Math.round(group.score / Math.max(group.count, 1)),
|
||||
signalCount: group.count,
|
||||
sources: Array.from(group.sources),
|
||||
}));
|
||||
const score = projection?.score && projection.score > 0
|
||||
? projection.score
|
||||
: signals.length
|
||||
? Math.round(signals.reduce((sum, signal) => sum + signal.score, 0) / signals.length)
|
||||
: null;
|
||||
const dimensionFromKey = (id: string, value: unknown): {
|
||||
id: string;
|
||||
label: string;
|
||||
score: number;
|
||||
signalCount: number;
|
||||
sources: string[];
|
||||
} | null => {
|
||||
const num = typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: (isRecord(value) && typeof value.score === "number" ? value.score : null);
|
||||
if (num === null) return null;
|
||||
const label = id.replace(/-/g, " ").replace(/^./, (char) => char.toUpperCase());
|
||||
const signalCount = isRecord(value) && typeof value.signalCount === "number" ? value.signalCount : 0;
|
||||
const sources = isRecord(value) && Array.isArray(value.sources) ? value.sources.filter((s): s is string => typeof s === "string") : [];
|
||||
return { id, label, score: Math.round(num), signalCount, sources };
|
||||
};
|
||||
const dimensions = Object.entries(quotients)
|
||||
.map(([id, value]) => dimensionFromKey(id, value))
|
||||
.filter((d): d is { id: string; label: string; score: number; signalCount: number; sources: string[] } => d !== null);
|
||||
|
||||
const response = {
|
||||
qscore: score === null ? null : {
|
||||
score,
|
||||
signalCount: projection?.signalCount ?? signals.length,
|
||||
summary: projection?.summary ?? `Readiness score computed from ${signals.length} current signal${signals.length === 1 ? "" : "s"}.`,
|
||||
iq_score: qscoreResult?.iq_score ?? null,
|
||||
eq_score: qscoreResult?.eq_score ?? null,
|
||||
sq_score: qscoreResult?.sq_score ?? null,
|
||||
signalCount: breakdownSignals.length,
|
||||
summary: typeof breakdown.summary === "string" ? breakdown.summary : `Readiness score computed from ${breakdownSignals.length} current signal${breakdownSignals.length === 1 ? "" : "s"}.`,
|
||||
dimensions,
|
||||
updatedAt: projection?.updatedAt?.toISOString() ?? signals[0]?.updatedAt?.toISOString() ?? null,
|
||||
updatedAt: qscoreResult?.calculated_at ?? null,
|
||||
},
|
||||
signals: signals.map((signal) => ({
|
||||
signalId: signal.signalId,
|
||||
score: Math.round(signal.score),
|
||||
present: signal.present,
|
||||
source: signal.source,
|
||||
occurredAt: signal.occurredAt.toISOString(),
|
||||
updatedAt: signal.updatedAt.toISOString(),
|
||||
})),
|
||||
signals: breakdownSignals.map((signal) => {
|
||||
const s = isRecord(signal) ? signal : {};
|
||||
return {
|
||||
signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "",
|
||||
score: typeof s.score === "number" ? Math.round(s.score) : 0,
|
||||
present: typeof s.present === "boolean" ? s.present : true,
|
||||
source: typeof s.source === "string" ? s.source : "",
|
||||
occurredAt: typeof s.occurredAt === "string" ? s.occurredAt : qscoreResult?.calculated_at ?? new Date().toISOString(),
|
||||
updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : qscoreResult?.calculated_at ?? new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "qscore-service",
|
||||
type: "qscore.review.opened",
|
||||
payload: { score, signalCount: signals.length, source: "services.qscore.current" },
|
||||
payload: { score, signalCount: breakdownSignals.length, source: "services.qscore.current" },
|
||||
correlation: { taskId: curatorTaskIdFromRequest(c.req.raw) },
|
||||
}).catch((err) => log.warn({ err, userId }, "failed to record qscore review event"));
|
||||
|
||||
@@ -750,12 +896,14 @@ export function serviceRoutes() {
|
||||
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await interviewService.configure(payload).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const taskId = curatorTaskIdFromRequest(c.req.raw, body);
|
||||
const orchestration = orchestrationFromMission(mission, taskId);
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "interview-service",
|
||||
type: "interview.session.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
|
||||
payload: { ...orchestration, request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), ...orchestration },
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record interview configured event"));
|
||||
return c.json(result);
|
||||
@@ -776,15 +924,19 @@ export function serviceRoutes() {
|
||||
app.post("/interview/results:bulk", async (c) => c.json(await interviewService.resultsBulk(await c.req.json<JsonObject>())));
|
||||
app.get("/interview/review/:sessionId", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const result = await interviewService.review(sessionId, userId).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const sessionContext = await sessionMissionContext("interview", sessionId).catch((): SessionMissionContext => ({}));
|
||||
const taskId = curatorTaskIdFromRequest(c.req.raw) ?? sessionContext.taskId;
|
||||
const orchestration = orchestrationFromMission(sessionContext.mission, taskId);
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "interview-service",
|
||||
type: eventTypeForReview("interview", resultObj),
|
||||
payload: resultObj,
|
||||
correlation: { sessionId, taskId: curatorTaskIdFromRequest(c.req.raw) },
|
||||
payload: { ...orchestration, ...resultObj },
|
||||
correlation: { sessionId, ...orchestration },
|
||||
mission: sessionContext.mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record interview review event"));
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -815,12 +967,14 @@ export function serviceRoutes() {
|
||||
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await roleplayService.configure(payload).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const taskId = curatorTaskIdFromRequest(c.req.raw, body);
|
||||
const orchestration = orchestrationFromMission(mission, taskId);
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.scenario.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
|
||||
payload: { ...orchestration, request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), ...orchestration },
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record roleplay configured event"));
|
||||
return c.json(result);
|
||||
@@ -844,12 +998,16 @@ export function serviceRoutes() {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const result = await roleplayService.review(sessionId, userId).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const sessionContext = await sessionMissionContext("roleplay", sessionId).catch((): SessionMissionContext => ({}));
|
||||
const taskId = curatorTaskIdFromRequest(c.req.raw) ?? sessionContext.taskId;
|
||||
const orchestration = orchestrationFromMission(sessionContext.mission, taskId);
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "roleplay-service",
|
||||
type: eventTypeForReview("roleplay", resultObj),
|
||||
payload: resultObj,
|
||||
correlation: { sessionId, taskId: curatorTaskIdFromRequest(c.req.raw) },
|
||||
payload: { ...orchestration, ...resultObj },
|
||||
correlation: { sessionId, ...orchestration },
|
||||
mission: sessionContext.mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record roleplay review event"));
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -900,16 +1058,15 @@ export function serviceRoutes() {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>().catch(() => ({}));
|
||||
const { action, result } = await callMatchmakingA2a(body, userId);
|
||||
const event = buildMatchmakingGatewayEvent({ userId, action, body, result });
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "matchmaking-v2",
|
||||
type: matchmakingEventType(action, result),
|
||||
payload: { request: body, result },
|
||||
correlation: {
|
||||
taskId: curatorTaskIdFromRequest(c.req.raw, body),
|
||||
externalId: getString(result.task_id ?? result.taskId),
|
||||
},
|
||||
mission: missionFromRequest(c.req.raw, body),
|
||||
source: event.source,
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
correlation: event.correlation,
|
||||
mission: event.mission ?? missionFromRequest(c.req.raw, body),
|
||||
subject: event.subject,
|
||||
}).catch((err) => log.warn({ err, userId, action }, "failed to record matchmaking workflow event"));
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
@@ -1,17 +1,77 @@
|
||||
import { Hono } from "hono";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, userStacks, type UserStack } from "../db/schema.js";
|
||||
import { onboarding, users, userStacks, type UserStack } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { log } from "../log.js";
|
||||
import { config } from "../config.js";
|
||||
import { fetchUserProfile, patchPreferencesRequest, userServiceTarget } from "../services/user-profile.js";
|
||||
import {
|
||||
onboardingCompletedAtFromPreferences,
|
||||
} from "../v1/curator/curator-onboarding-loop.js";
|
||||
import {
|
||||
getLatestValidOnboardingLedgerEvent,
|
||||
recordAndProcessOnboardingCompletion,
|
||||
extractOnboardingData,
|
||||
deriveOnboardingPayload,
|
||||
assertOnboardingRevision,
|
||||
mergeOnboardingPatch,
|
||||
resetOnboardingLedger,
|
||||
resetOnboardingPreferences,
|
||||
defaultOnboardingData,
|
||||
OnboardingRevisionConflict,
|
||||
type OnboardingData,
|
||||
} from "../events/onboarding-ledger.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const onboardingPatchSchema = z.object({
|
||||
expectedRevision: z.number().int().nonnegative().optional(),
|
||||
data: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
const curatorIcpByOnboardingIcp: Record<string, string> = {
|
||||
student: "student_recent_grad",
|
||||
intern: "intern",
|
||||
fresher: "fresher_early_professional",
|
||||
experienced: "experienced_professional",
|
||||
freelancer: "freelancer",
|
||||
founder: "founder",
|
||||
};
|
||||
|
||||
function buildOnboardingPayload(data: Record<string, unknown>) {
|
||||
const profile = asRecord(data.profile);
|
||||
const responses = asRecord(data.responses);
|
||||
const onboardingIcp = typeof profile.icp === "string" ? profile.icp : "fresher";
|
||||
const targetField = typeof responses.target_field === "string"
|
||||
? responses.target_field
|
||||
: typeof responses.venture_industry === "string"
|
||||
? responses.venture_industry
|
||||
: null;
|
||||
|
||||
return {
|
||||
schema_version: 1,
|
||||
source_revision: typeof data.revision === "number" ? data.revision : 0,
|
||||
primary_intent: typeof profile.intent === "string" ? profile.intent : null,
|
||||
mode: typeof profile.mode === "string" ? profile.mode : null,
|
||||
question_branch: typeof profile.question_branch === "string" ? profile.question_branch : null,
|
||||
onboarding_icp: onboardingIcp,
|
||||
curator_registry_icp: curatorIcpByOnboardingIcp[onboardingIcp] ?? "fresher_early_professional",
|
||||
target_role: typeof responses.target_role === "string" ? responses.target_role : null,
|
||||
target_field: targetField,
|
||||
experience_context: typeof responses.experience_level === "string" ? responses.experience_level : null,
|
||||
goals: strings(responses.desired_outcomes),
|
||||
barriers: strings(responses.career_barriers),
|
||||
priority: typeof responses.target_milestone === "string" ? responses.target_milestone : null,
|
||||
weekly_time_commitment: typeof responses.weekly_time_commitment === "string" ? responses.weekly_time_commitment : null,
|
||||
};
|
||||
}
|
||||
|
||||
function publicStack(stack: UserStack | null | undefined) {
|
||||
if (!stack) return stack;
|
||||
@@ -19,9 +79,6 @@ function publicStack(stack: UserStack | null | undefined) {
|
||||
return safe;
|
||||
}
|
||||
|
||||
function userServiceTarget(path: string, search = "") {
|
||||
return new URL(`/api/v1/users${path}${search}`, config.userServiceUrl.replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
async function fetchUserService(req: Request, path: string) {
|
||||
const incoming = new URL(req.url);
|
||||
@@ -57,6 +114,75 @@ async function ensureUserServiceUser(req: Request) {
|
||||
return res.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
|
||||
function recordPreferences(profile: Record<string, unknown>): Record<string, unknown> {
|
||||
return isPlainObject(profile.preferences) ? profile.preferences : {};
|
||||
}
|
||||
|
||||
/** Build the { id, data, payload, revision, updatedAt } response shape. */
|
||||
function onboardingStateResponse(userId: string, data: OnboardingData) {
|
||||
const updatedAt = data.progress.updated_at ?? data.completed_at;
|
||||
return {
|
||||
id: userId,
|
||||
data,
|
||||
payload: deriveOnboardingPayload(data),
|
||||
revision: data.revision,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
type OnboardingResetResult =
|
||||
| { error: null; body: Record<string, unknown>; status: 200 }
|
||||
| { error: string; message: string; status: 502 };
|
||||
|
||||
/**
|
||||
* Reusable per-user onboarding reset. Resets preferences.onboarding to the
|
||||
* default v3 doc (preserving all unrelated preference keys, history, and
|
||||
* curator data) and deletes ONLY the user's completion-type ledger rows so the
|
||||
* onboarding gate reopens. Idempotent — a user with no onboarding state is a
|
||||
* no-op success.
|
||||
*
|
||||
* Exported so the staging bulk-reset CLI can reuse the exact same logic
|
||||
* (preferences reset + backend ledger deletion) end-to-end via the service
|
||||
* token path, instead of re-implementing it.
|
||||
*/
|
||||
export async function resetUserOnboarding(req: Request, userId: string): Promise<OnboardingResetResult> {
|
||||
const profile = await fetchUserProfile(req);
|
||||
const preferences = recordPreferences(profile);
|
||||
const nextPreferences = resetOnboardingPreferences(preferences);
|
||||
|
||||
const updateRes = await fetchUserService(patchPreferencesRequest(req, nextPreferences), "/me");
|
||||
if (!updateRes.ok) {
|
||||
const text = await updateRes.text().catch(() => "");
|
||||
log.error({ err: text, userId, status: updateRes.status }, "failed to persist reset onboarding preferences");
|
||||
return {
|
||||
error: "user_service_persist_failed",
|
||||
message: "Failed to reset onboarding preferences",
|
||||
status: 502,
|
||||
};
|
||||
}
|
||||
|
||||
const ledgerDeleted = await resetOnboardingLedger(userId);
|
||||
log.info({ userId, ledgerDeleted }, "onboarding reset complete");
|
||||
|
||||
const data = extractOnboardingData(nextPreferences.onboarding);
|
||||
return {
|
||||
error: null,
|
||||
body: {
|
||||
id: userId,
|
||||
reset: true,
|
||||
ledgerRowsDeleted: ledgerDeleted,
|
||||
data,
|
||||
},
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
|
||||
export function userRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
@@ -77,6 +203,10 @@ export function userRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
const rawPlan = typeof userServiceUser.plan === "string" ? userServiceUser.plan.toLowerCase() : "free";
|
||||
const plan = rawPlan === "pro" || rawPlan === "enterprise" ? rawPlan : "free";
|
||||
await db.update(users).set({ plan, updatedAt: new Date() }).where(eq(users.id, userId));
|
||||
|
||||
const userRow = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
@@ -94,23 +224,188 @@ export function userRoutes() {
|
||||
|
||||
app.get("/onboarding-status", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const state = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
|
||||
const event = await getLatestValidOnboardingLedgerEvent(userId);
|
||||
if (event) {
|
||||
return c.json({
|
||||
userId,
|
||||
hasOnboardingEvent: true,
|
||||
onboardingEvent: {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
processingStatus: event.processingStatus,
|
||||
},
|
||||
needsOnboarding: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Ledger missing but canonical preferences may still show completed (the
|
||||
// completion row was lost/never written). Self-heal: re-run the existing
|
||||
// idempotent completion recording, which re-creates the ledger row via the
|
||||
// `onboarding:completed:<userId>` dedupe key. If the canonical state is NOT
|
||||
// completed, the gate stays open honestly. A heal failure must surface as a
|
||||
// service error — never as a false "needs onboarding" for an already-done
|
||||
// user, which would force them through onboarding again.
|
||||
let healed: { ok: true } | { ok: false; status: 502; error: string; message: string };
|
||||
try {
|
||||
const profile = await fetchUserProfile(c.req.raw);
|
||||
const preferences = recordPreferences(profile);
|
||||
const data = extractOnboardingData(preferences.onboarding);
|
||||
if (data.status === "completed" && data.completed_at) {
|
||||
await recordAndProcessOnboardingCompletion({
|
||||
userId,
|
||||
completedAt: data.completed_at,
|
||||
source: "onboarding-status-heal",
|
||||
context: { preferences, onboarding: data },
|
||||
});
|
||||
healed = { ok: true };
|
||||
} else {
|
||||
return c.json({
|
||||
userId,
|
||||
hasOnboardingEvent: false,
|
||||
onboardingEvent: null,
|
||||
needsOnboarding: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err, userId }, "onboarding status heal failed");
|
||||
healed = {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: "onboarding_status_heal_failed",
|
||||
message: "Onboarding status check failed; please retry",
|
||||
};
|
||||
}
|
||||
|
||||
if (!healed.ok) {
|
||||
return c.json({ error: healed.error, message: healed.message }, healed.status);
|
||||
}
|
||||
|
||||
// The heal re-ran the idempotent completion recording, so a fresh read
|
||||
// should now find the ledger row. Re-fetch to return a consistent shape
|
||||
// (onboardingEvent populated alongside hasOnboardingEvent: true) rather than
|
||||
// a contradictory true+null that downstream consumers would misread.
|
||||
const fresh = await getLatestValidOnboardingLedgerEvent(userId);
|
||||
return c.json({
|
||||
userId,
|
||||
hasOnboardingEvent: Boolean(event),
|
||||
onboardingEvent: event
|
||||
hasOnboardingEvent: true,
|
||||
onboardingEvent: fresh
|
||||
? {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
processingStatus: event.processingStatus,
|
||||
}
|
||||
id: fresh.id,
|
||||
type: fresh.type,
|
||||
occurredAt: fresh.occurredAt.toISOString(),
|
||||
processingStatus: fresh.processingStatus,
|
||||
}
|
||||
: null,
|
||||
needsOnboarding: !event,
|
||||
needsOnboarding: false,
|
||||
healed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Onboarding preferences (canonical store: user-service preferences.onboarding) ─
|
||||
// GET returns the v3 OnboardingData (or a default v3 doc when none exists yet)
|
||||
// plus its faithful projection payload and the current revision.
|
||||
// PATCH performs optimistic-concurrency merge (expectedRevision must match),
|
||||
// preserves all unrelated preference keys, persists access_choice, validates
|
||||
// status/completed_at consistency, and fires the existing completion ledger
|
||||
// side effects when status is "completed".
|
||||
app.get("/onboarding", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const profile = await fetchUserProfile(c.req.raw);
|
||||
const preferences = recordPreferences(profile);
|
||||
const data = extractOnboardingData(preferences.onboarding);
|
||||
return c.json(onboardingStateResponse(userId, data));
|
||||
});
|
||||
|
||||
app.patch("/onboarding", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json().catch(() => null) as
|
||||
| { data?: unknown; expectedRevision?: unknown }
|
||||
| null;
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return c.json({ error: "invalid_body", message: "Expected { data, expectedRevision }" }, 400);
|
||||
}
|
||||
|
||||
const incoming = extractOnboardingData(body.data);
|
||||
const expectedRevision = typeof body.expectedRevision === "number" ? body.expectedRevision : undefined;
|
||||
|
||||
// Read current profile to resolve the stored revision for the OCC check.
|
||||
const profile = await fetchUserProfile(c.req.raw);
|
||||
const preferences = recordPreferences(profile);
|
||||
const current = extractOnboardingData(preferences.onboarding);
|
||||
|
||||
try {
|
||||
assertOnboardingRevision(expectedRevision, current);
|
||||
} catch (err) {
|
||||
if (err instanceof OnboardingRevisionConflict) {
|
||||
return c.json(
|
||||
{
|
||||
error: "revision_conflict",
|
||||
message: "Onboarding was modified by another session. Reload and retry.",
|
||||
expected: err.expected,
|
||||
actual: err.actual,
|
||||
revision: err.actual,
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Stamp the incoming revision with the authoritative stored revision so the
|
||||
// merge helper bumps from it (the client sends the revision it read).
|
||||
const incomingWithClientRev: OnboardingData = { ...incoming, revision: current.revision };
|
||||
const nextPreferences = mergeOnboardingPatch(preferences, incomingWithClientRev);
|
||||
const nextData = extractOnboardingData(nextPreferences.onboarding);
|
||||
|
||||
// Persist back to user-service via PATCH /me (only preferences is written).
|
||||
const updateRes = await fetchUserService(patchPreferencesRequest(c.req.raw, nextPreferences), "/me");
|
||||
if (!updateRes.ok) {
|
||||
const text = await updateRes.text().catch(() => "");
|
||||
log.error({ err: text, userId, status: updateRes.status }, "failed to persist onboarding preferences");
|
||||
return c.json(
|
||||
{ error: "user_service_persist_failed", message: "Failed to save onboarding preferences" },
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Completion ledger side effects (idempotent via dedupeKey onboarding:completed:<userId>).
|
||||
if (nextData.status === "completed" && nextData.completed_at) {
|
||||
try {
|
||||
await recordAndProcessOnboardingCompletion({
|
||||
userId,
|
||||
completedAt: nextData.completed_at,
|
||||
source: "onboarding-api",
|
||||
context: {
|
||||
preferences: nextPreferences,
|
||||
onboarding: nextData,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ err, userId }, "failed to run onboarding completion side effects");
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(onboardingStateResponse(userId, nextData));
|
||||
});
|
||||
|
||||
// DELETE /onboarding — authenticated per-user reset. Reopens the onboarding
|
||||
// gate by deleting ONLY the user's completion-type ledger rows (those carrying
|
||||
// the onboarding:completed dedupe key) and resetting preferences.onboarding to
|
||||
// the default v3 doc. Preserves unrelated preferences, snapshot history,
|
||||
// missions, and curator data. Idempotent: deleting nothing is a success.
|
||||
app.delete("/onboarding", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const result = await resetUserOnboarding(c.req.raw, userId);
|
||||
if (result.error !== null) {
|
||||
return c.json({ error: result.error, message: result.message }, result.status);
|
||||
}
|
||||
return c.json(result.body, result.status);
|
||||
});
|
||||
|
||||
app.get("/me", async (c) => proxyUserService(c.req.raw, "/me"));
|
||||
app.patch("/me", async (c) => {
|
||||
const res = await fetchUserService(c.req.raw, "/me");
|
||||
|
||||
139
src/services/matchmaking-events.ts
Normal file
139
src/services/matchmaking-events.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function getNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function paramsFromBody(body: JsonRecord): JsonRecord {
|
||||
return isRecord(body.params) ? body.params : {};
|
||||
}
|
||||
|
||||
function agentData(result: JsonRecord, action: string): JsonRecord | undefined {
|
||||
const messages = Array.isArray(result.messages) ? result.messages : [];
|
||||
for (const item of messages) {
|
||||
const message = isRecord(item) ? item : {};
|
||||
if (message.type !== "agent_data") continue;
|
||||
if (message.action !== action) continue;
|
||||
const data = isRecord(message.data) ? message.data : {};
|
||||
return data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstAgentData(result: JsonRecord, actions: string[]): JsonRecord {
|
||||
for (const action of actions) {
|
||||
const data = agentData(result, action);
|
||||
if (data) return data;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function matchmakingEventType(action: string, response: JsonRecord) {
|
||||
if (response.status === "error") return "matchmaking.request.failed";
|
||||
if (action === "run_search" || action === "generate_matches") return "matchmaking.matches.generated";
|
||||
if (action === "get_scout_feed" || action === "get_feed" || action === "session_start") return "matchmaking.feed.viewed";
|
||||
if (action === "get_opportunity_detail" || action === "mark_viewed") return "matchmaking.match.viewed";
|
||||
if (action === "mark_saved") return "matchmaking.match.saved";
|
||||
if (action === "record_feedback") return "matchmaking.matches.reviewed";
|
||||
if (action === "dismiss_opportunity") return "matchmaking.match.dismissed";
|
||||
if (action === "tailor_resume") return "matchmaking.application.started";
|
||||
if (action === "mark_applied") return "matchmaking.match.applied";
|
||||
if (action === "submit_application") return "matchmaking.application.completed";
|
||||
return "matchmaking.workflow.completed";
|
||||
}
|
||||
|
||||
function opportunityIdFrom(input: { params: JsonRecord; body: JsonRecord; data: JsonRecord; result: JsonRecord }) {
|
||||
return getString(
|
||||
input.params.opportunity_id ??
|
||||
input.params.opportunityId ??
|
||||
input.params.matchId ??
|
||||
input.params.jobId ??
|
||||
input.body.opportunity_id ??
|
||||
input.body.opportunityId ??
|
||||
input.data.opportunity_id ??
|
||||
input.data.opportunityId ??
|
||||
input.data.id ??
|
||||
input.result.opportunity_id ??
|
||||
input.result.opportunityId,
|
||||
);
|
||||
}
|
||||
|
||||
function missionFromParams(params: JsonRecord) {
|
||||
const instanceId = getString(params.missionInstanceId ?? params.instanceId);
|
||||
const missionId = getString(params.missionId);
|
||||
const stageId = getString(params.stageId);
|
||||
const source = getString(params.source);
|
||||
if (!instanceId && !missionId && !stageId) return undefined;
|
||||
return {
|
||||
missionId,
|
||||
instanceId,
|
||||
stageId,
|
||||
source: source ?? "curator-v1",
|
||||
};
|
||||
}
|
||||
|
||||
function countFromData(data: JsonRecord) {
|
||||
const opportunities = Array.isArray(data.opportunities) ? data.opportunities : undefined;
|
||||
return getNumber(data.matchCount ?? data.matches ?? data.shortlisted) ?? opportunities?.length;
|
||||
}
|
||||
|
||||
export function buildMatchmakingGatewayEvent(input: {
|
||||
userId: string;
|
||||
action: string;
|
||||
body: JsonRecord;
|
||||
result: JsonRecord;
|
||||
}) {
|
||||
const params = paramsFromBody(input.body);
|
||||
const type = matchmakingEventType(input.action, input.result);
|
||||
const data = firstAgentData(input.result, ["search_complete", "feed_loaded", "opportunity_state_saved", "opportunity_detail"]);
|
||||
const opportunityId = opportunityIdFrom({ params, body: input.body, data, result: input.result });
|
||||
const taskId = getString(params.curatorTaskId ?? params.taskId ?? input.body.curatorTaskId ?? input.body.taskId);
|
||||
const resultTaskId = getString(input.result.task_id ?? input.result.taskId);
|
||||
const matchCount = countFromData(data);
|
||||
const scanned = getNumber(data.scanned);
|
||||
|
||||
const payload: JsonRecord = {
|
||||
request: input.body,
|
||||
result: input.result,
|
||||
action: input.action,
|
||||
status: getString(input.result.status),
|
||||
...(taskId ? { taskId, curatorTaskId: taskId } : {}),
|
||||
...(opportunityId ? { opportunityId, matchId: opportunityId, jobId: opportunityId } : {}),
|
||||
...(matchCount !== undefined ? { matchCount } : {}),
|
||||
...(scanned !== undefined ? { scanned } : {}),
|
||||
...(getString(params.source) ? { source: getString(params.source) } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
userId: input.userId,
|
||||
source: "matchmaking-v2",
|
||||
type,
|
||||
payload,
|
||||
correlation: {
|
||||
...(taskId ? { taskId, curatorTaskId: taskId } : {}),
|
||||
...(opportunityId ? { opportunityId, matchId: opportunityId, jobId: opportunityId } : {}),
|
||||
externalId: opportunityId ?? resultTaskId,
|
||||
...(resultTaskId ? { a2aTaskId: resultTaskId } : {}),
|
||||
},
|
||||
mission: missionFromParams(params),
|
||||
subject: {
|
||||
serviceId: "matchmaking",
|
||||
kind: opportunityId ? "opportunity" : "opportunity_feed",
|
||||
id: opportunityId,
|
||||
externalId: opportunityId ?? resultTaskId ?? `matchmaking:${type}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -34,7 +34,7 @@ function resolveUserPayload(userIdOrPayload?: string | JsonObject, payload?: Jso
|
||||
: { userId: undefined, payload: userIdOrPayload };
|
||||
}
|
||||
|
||||
async function serviceJson<T = JsonObject>(
|
||||
export async function serviceJson<T = JsonObject>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
opts: ServiceCallOptions = {},
|
||||
|
||||
140
src/services/qscore-proxy.ts
Normal file
140
src/services/qscore-proxy.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
// Current backend auth and actor contexts are single-org; keep the constraint
|
||||
// explicit and shared until org membership is threaded through every caller.
|
||||
export const DEFAULT_QSCORE_ORG_ID = "growqr";
|
||||
|
||||
/**
|
||||
* Deterministic identity mapping from a raw growqr userId to the qscore_service
|
||||
* stable UUID key. Both the signal feeder (write path) and the score readers
|
||||
* (read path) MUST use this so producer and consumer agree on identity.
|
||||
*
|
||||
* This is the single source of truth for the mapping; the feeder imports it
|
||||
* from here rather than maintaining a parallel copy.
|
||||
*/
|
||||
export function toQscoreUserId(userId: string): string {
|
||||
const hex = createHash("sha256").update(userId).digest("hex").slice(0, 32);
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
`4${hex.slice(13, 16)}`,
|
||||
`8${hex.slice(17, 20)}`,
|
||||
hex.slice(20, 32),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw response shape returned by qscore_service GET /v1/qscore/{user_id}.
|
||||
* See qscore_service/app/api/schemas.py QScoreReadResponse.
|
||||
*/
|
||||
export type QscoreServiceResult = {
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
q_score: number;
|
||||
iq_score: number | null;
|
||||
eq_score: number | null;
|
||||
sq_score: number | null;
|
||||
overall_rqscore: number | null;
|
||||
profession: string;
|
||||
formula_version: string;
|
||||
formula_id: string;
|
||||
calculated_at: string;
|
||||
quotients: Record<string, unknown>;
|
||||
breakdown: Record<string, unknown>;
|
||||
ledger_seq_from: number | null;
|
||||
ledger_seq_to: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Public result type for the proxy helper. The assignment's contract names this
|
||||
* `QscoreProxyResult`; it is the non-null service response (the function itself
|
||||
* returns `QscoreProxyResult | null` for graceful degradation).
|
||||
*/
|
||||
export type QscoreProxyResult = QscoreServiceResult;
|
||||
|
||||
const QSCORE_PROXY_TIMEOUT_MS = Number(
|
||||
process.env.QSCORE_PROXY_TIMEOUT_MS ?? 3500,
|
||||
);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseResult(body: unknown): QscoreServiceResult {
|
||||
if (!isRecord(body)) {
|
||||
throw new Error("qscore_service returned non-object response");
|
||||
}
|
||||
const q = body.q_score;
|
||||
if (typeof q !== "number" || !Number.isFinite(q)) {
|
||||
throw new Error("qscore_service response missing numeric q_score");
|
||||
}
|
||||
const numOrNull = (v: unknown): number | null =>
|
||||
typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
return {
|
||||
org_id: str(body.org_id),
|
||||
user_id: str(body.user_id),
|
||||
q_score: q,
|
||||
iq_score: numOrNull(body.iq_score),
|
||||
eq_score: numOrNull(body.eq_score),
|
||||
sq_score: numOrNull(body.sq_score),
|
||||
overall_rqscore: numOrNull(body.overall_rqscore),
|
||||
profession: str(body.profession),
|
||||
formula_version: str(body.formula_version),
|
||||
formula_id: str(body.formula_id),
|
||||
calculated_at: str(body.calculated_at),
|
||||
quotients: isRecord(body.quotients) ? body.quotients : {},
|
||||
breakdown: isRecord(body.breakdown) ? body.breakdown : {},
|
||||
ledger_seq_from: numOrNull(body.ledger_seq_from),
|
||||
ledger_seq_to: numOrNull(body.ledger_seq_to),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest computed Q-Score for a user from qscore_service.
|
||||
*
|
||||
* Takes a RAW growqr userId (matching the local-DB readers) and applies
|
||||
* {@link toQscoreUserId} internally before calling the service, so all callers
|
||||
* share one identity contract.
|
||||
*
|
||||
* Returns null when the service is unreachable, returns a non-success status,
|
||||
* or reports no score has been computed yet (404). Never throws.
|
||||
*/
|
||||
export async function getQscoreFromService(
|
||||
userId: string,
|
||||
orgId: string = DEFAULT_QSCORE_ORG_ID,
|
||||
timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS,
|
||||
): Promise<QscoreProxyResult | null> {
|
||||
const qscoreUserId = toQscoreUserId(userId);
|
||||
const path = `/v1/qscore/${encodeURIComponent(qscoreUserId)}?org_id=${encodeURIComponent(orgId)}`;
|
||||
const url = `${config.qscoreServiceUrl.replace(/\/$/, "")}${path}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(config.a2aAllowedKey
|
||||
? { authorization: `Bearer ${config.a2aAllowedKey}` }
|
||||
: {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
// 404 = no run computed yet for this user → treat as null (graceful).
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
log.warn(
|
||||
{ status: res.status, body: text, userId, orgId },
|
||||
"qscore_service returned non-success status",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const body = await res.json();
|
||||
return parseResult(body);
|
||||
} catch (err) {
|
||||
log.warn({ err, userId, orgId }, "qscore_service proxy call failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { config } from "../config.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { buildServiceSessionPath } from "./service-registry.js";
|
||||
import type { QscoreSignal } from "../events/envelope.js";
|
||||
import { toQscoreUserId } from "./qscore-proxy.js";
|
||||
import { serviceJson as productServiceJson } from "./product-service-clients.js";
|
||||
|
||||
// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule).
|
||||
export type ServiceAgentRef = {
|
||||
@@ -254,6 +257,45 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward extracted Grow signals to qscore_service for scoring.
|
||||
*
|
||||
* qscore_service OWNS all scoring; the backend only extracts signals and feeds
|
||||
* them here. The userId is hashed to the qscore UUID via toQscoreUserId (from
|
||||
* qscore-proxy.ts, the single source of truth shared with readers). The ingest
|
||||
* endpoint persists signals and marks the user dirty for async score
|
||||
* computation — it does NOT return a score, so the caller treats the result as
|
||||
* fire-and-forget.
|
||||
*/
|
||||
export async function forwardSignalsToQscoreService(params: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
profession: string;
|
||||
source: string;
|
||||
signals: QscoreSignal[];
|
||||
}): Promise<void> {
|
||||
const qscoreUserId = toQscoreUserId(params.userId);
|
||||
await productServiceJson(
|
||||
config.qscoreServiceUrl,
|
||||
"/v1/signals/ingest",
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
org_id: params.orgId,
|
||||
user_id: qscoreUserId,
|
||||
profession: params.profession,
|
||||
source: params.source,
|
||||
signals: params.signals.map((s) => ({
|
||||
signal_id: s.signalId,
|
||||
present: s.present,
|
||||
score: s.score,
|
||||
raw: s.raw ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Resume Building (resume-builder service from growqr-app) ──
|
||||
|
||||
async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
||||
|
||||
@@ -96,6 +96,9 @@ type CuratorRouteInput = {
|
||||
missionId?: string;
|
||||
stageId?: string;
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
taskSubtitle?: string;
|
||||
taskType?: string;
|
||||
targetRole?: string;
|
||||
durationMinutes?: number;
|
||||
difficulty?: string;
|
||||
@@ -140,6 +143,7 @@ const allowedServiceIds = new Set<ServiceId>([
|
||||
"courses-service",
|
||||
"resume-service",
|
||||
"qscore-service",
|
||||
"social-branding-service",
|
||||
]);
|
||||
|
||||
const serviceRegistry: ServiceRecord[] = [
|
||||
@@ -177,37 +181,18 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
pages: {
|
||||
discovery: {
|
||||
service: {
|
||||
path: "/agents/interview",
|
||||
queryParams: ["fresh"],
|
||||
usage: "Entry screen for role selection and resume of in-progress interview work.",
|
||||
},
|
||||
setup: {
|
||||
path: "/agents/interview/setup",
|
||||
queryParams: ["role", "type", "from_assignment", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Collects interview role, type, duration, persona, media mode, and personalization consent.",
|
||||
},
|
||||
preview: {
|
||||
path: "/agents/interview/preview",
|
||||
queryParams: ["role", "type", "persona", "duration", "difficulty", "media", "vip", "from_assignment", "personalize", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Curator default handoff. The page configures the session and opens the launch overlay.",
|
||||
},
|
||||
feedback: {
|
||||
path: "/agents/interview/feedback",
|
||||
queryParams: ["sessionId", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Opens feedback/review for a completed or processing session.",
|
||||
},
|
||||
session: {
|
||||
path: "/agents/interview/preview",
|
||||
queryParams: ["session_id", "goal", "role", "type"],
|
||||
usage: "Legacy service-session launcher used by service agent results.",
|
||||
aliases: ["discovery", "setup", "preview", "feedback", "session"],
|
||||
queryParams: ["view", "phase", "sessionId", "role", "type", "persona", "duration", "difficulty", "media", "vip", "personalize", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "taskTitle", "taskSubtitle", "taskType"],
|
||||
usage: "Single nuqs-driven interview surface for configuration, live practice, plan review, and feedback.",
|
||||
},
|
||||
},
|
||||
usage: "Prefer preview for curator links and setup for mission CTAs that still need user choices.",
|
||||
usage: "All interview UI states use /agents/interview with typed query state; no child UI routes exist.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "preview",
|
||||
defaultActionLabel: "Open interview preview",
|
||||
defaultPage: "service",
|
||||
defaultActionLabel: "Open interview",
|
||||
actionLabels: {
|
||||
start: "Start mock",
|
||||
review: "Review interview",
|
||||
@@ -220,12 +205,12 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
media: "video",
|
||||
},
|
||||
toolName: "prepare_interview_preview",
|
||||
completionEvents: ["interview.session.configured", "interview.session.completed", "interview.feedback.generated"],
|
||||
completionEvents: ["interview.session.completed", "interview.feedback.generated"],
|
||||
qscoreSignals: ["communication.interview", "proof.story_bank", "readiness.practice"],
|
||||
usage: "Include missionInstanceId, missionId, stageId, curatorTaskId, role, and media when building stateful handoffs.",
|
||||
},
|
||||
usageDocs: [
|
||||
"Call buildServiceLink('interview-service', 'preview', state) for curator handoffs.",
|
||||
"Call buildServiceLink('interview-service', 'service', state) for curator handoffs.",
|
||||
"Call getServiceEndpoint('interview-service', 'configure') for backend contract metadata.",
|
||||
],
|
||||
eventContract: {
|
||||
@@ -276,37 +261,18 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
pages: {
|
||||
discovery: {
|
||||
service: {
|
||||
path: "/agents/roleplay",
|
||||
queryParams: ["fresh"],
|
||||
usage: "Entry screen for scenario discovery and resume of in-progress roleplay work.",
|
||||
},
|
||||
setup: {
|
||||
path: "/agents/roleplay/setup",
|
||||
queryParams: ["scenario", "scenario_text", "scenario_name", "from_assignment", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Collects roleplay scenario details and stores configure parameters for the builder.",
|
||||
},
|
||||
builder: {
|
||||
path: "/agents/roleplay/builder",
|
||||
queryParams: ["sessionId", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "role", "persona", "duration", "mode", "brief", "vip", "from_assignment_id"],
|
||||
usage: "Curator default handoff for generating or resuming a roleplay plan.",
|
||||
},
|
||||
feedback: {
|
||||
path: "/agents/roleplay/feedback",
|
||||
queryParams: ["sessionId", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Opens feedback/review for a completed or processing session.",
|
||||
},
|
||||
session: {
|
||||
path: "/agents/roleplay/builder",
|
||||
queryParams: ["session_id", "goal", "role", "type"],
|
||||
usage: "Legacy service-session launcher used by service agent results.",
|
||||
aliases: ["discovery", "setup", "builder", "feedback", "session"],
|
||||
queryParams: ["view", "phase", "sessionId", "scenario", "scenarioName", "brief", "role", "persona", "duration", "difficulty", "mode", "vip", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "taskTitle", "taskSubtitle", "taskType"],
|
||||
usage: "Single nuqs-driven roleplay surface for configuration, scenario review, live practice, and feedback.",
|
||||
},
|
||||
},
|
||||
usage: "Prefer builder for curator links and setup for mission CTAs that still need user choices.",
|
||||
usage: "All roleplay UI states use /agents/roleplay with typed query state; no child UI routes exist.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "builder",
|
||||
defaultActionLabel: "Open roleplay preview",
|
||||
defaultPage: "service",
|
||||
defaultActionLabel: "Open roleplay",
|
||||
actionLabels: {
|
||||
start: "Run drill",
|
||||
review: "Review roleplay",
|
||||
@@ -323,7 +289,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
usage: "Include role, brief, mission state, and curatorTaskId when building stateful handoffs.",
|
||||
},
|
||||
usageDocs: [
|
||||
"Call buildServiceLink('roleplay-service', 'builder', state) for curator handoffs.",
|
||||
"Call buildServiceLink('roleplay-service', 'service', state) for curator handoffs.",
|
||||
"Call getServiceEndpoint('roleplay-service', 'configure') for backend contract metadata.",
|
||||
],
|
||||
eventContract: {
|
||||
@@ -374,7 +340,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
pages: {
|
||||
workspace: {
|
||||
path: "/agents/resume",
|
||||
queryParams: ["tab", "section", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
queryParams: ["view", "phase", "tab", "section", "role", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "taskTitle", "taskSubtitle", "taskType"],
|
||||
usage: "Resume hub. Use tab=resumes by default and section to deep-link editor panels.",
|
||||
},
|
||||
editor: {
|
||||
@@ -535,7 +501,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
pages: {
|
||||
catalog: {
|
||||
path: "/agents/courses",
|
||||
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
queryParams: ["view", "phase", "role", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "taskTitle", "taskSubtitle", "taskType"],
|
||||
usage: "Course catalog and learning entry point.",
|
||||
},
|
||||
assigned: {
|
||||
@@ -558,8 +524,8 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
start: "Start course",
|
||||
},
|
||||
toolName: "prepare_course_handoff",
|
||||
completionEvents: ["course.generated", "course.started", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
qscoreSignals: ["skills.learning", "readiness.course_progress"],
|
||||
completionEvents: ["course.generated", "course.started", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
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."],
|
||||
@@ -568,9 +534,9 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
openEvents: ["task.opened"],
|
||||
startedEvents: ["service.started", "course.started"],
|
||||
configuredEvents: ["course.generated"],
|
||||
completionEvents: ["service.completed", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
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"],
|
||||
@@ -685,10 +651,12 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.match.dismissed",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"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: [
|
||||
@@ -706,10 +674,12 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.match.dismissed",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"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"],
|
||||
@@ -786,9 +756,9 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
backend: {
|
||||
baseUrl: config.socialBrandingServiceUrl,
|
||||
publicUrl: config.socialBrandingPublicUrl,
|
||||
healthPath: "/health",
|
||||
healthPath: "/healthz",
|
||||
endpoints: {
|
||||
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability."),
|
||||
health: endpoint("GET", "/healthz", "Readiness probe.", "Check service availability."),
|
||||
state: endpoint("GET", "/api/state/:clerkId", "Reads social/profile state.", "Hydrate personalization context."),
|
||||
profile: endpoint("GET", "/api/v1/profile", "Reads profile data when available.", "Social profile page."),
|
||||
linkedin: endpoint("POST", "/api/v1/linkedin", "Connects or imports LinkedIn data when available.", "LinkedIn onboarding."),
|
||||
@@ -801,7 +771,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
pages: {
|
||||
profile: {
|
||||
path: "/agents/social-branding",
|
||||
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
queryParams: ["view", "phase", "role", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "taskTitle", "taskSubtitle", "taskType"],
|
||||
usage: "Social/profile improvement page.",
|
||||
},
|
||||
},
|
||||
@@ -851,6 +821,11 @@ export function listServices() {
|
||||
return serviceRegistry.filter((service) => allowedServiceIds.has(service.id));
|
||||
}
|
||||
|
||||
function getServiceRecord(serviceId?: string | null) {
|
||||
const normalized = normalizeServiceId(serviceId);
|
||||
return normalized ? serviceRegistry.find((service) => service.id === normalized) : undefined;
|
||||
}
|
||||
|
||||
export function getService(serviceId?: string | null) {
|
||||
const normalized = normalizeServiceId(serviceId);
|
||||
return normalized ? listServices().find((service) => service.id === normalized) : undefined;
|
||||
@@ -972,6 +947,9 @@ function unavailableServiceRoute(input: CuratorRouteInput) {
|
||||
return appendQuery("/agents/service-unavailable", {
|
||||
...curatorBaseParams(input),
|
||||
serviceId: input.serviceId,
|
||||
taskTitle: input.taskTitle,
|
||||
taskSubtitle: input.taskSubtitle,
|
||||
taskType: input.taskType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -982,13 +960,20 @@ export function buildCuratorServiceRoute(input: CuratorRouteInput) {
|
||||
const state: QueryState = {
|
||||
...curatorBaseParams(input),
|
||||
};
|
||||
if (input.taskTitle) state.taskTitle = input.taskTitle;
|
||||
if (input.taskSubtitle) state.taskSubtitle = input.taskSubtitle;
|
||||
if (input.taskType) state.taskType = input.taskType;
|
||||
|
||||
if (service.id === "interview-service") {
|
||||
state.role = input.targetRole?.trim() || "Product Manager";
|
||||
state.type = "behavioral";
|
||||
const taskText = `${input.taskTitle ?? ""} ${input.taskSubtitle ?? ""}`.toLowerCase();
|
||||
const isIntroductionTask = /(?:60|90)[ -]?second|\bintroduction\b|\bpitch\b|\bpresentation\b/.test(taskText);
|
||||
// Curator already resolves this from onboarding/user context. Never invent a
|
||||
// role here: the task title/subtitle carry the exercise intent separately.
|
||||
state.role = isIntroductionTask ? undefined : input.targetRole?.trim();
|
||||
state.type = isIntroductionTask ? "warm_up" : "behavioral";
|
||||
state.persona = input.personaId ?? "payal";
|
||||
state.duration = input.durationMinutes ?? 5;
|
||||
state.difficulty = input.difficulty ?? "medium";
|
||||
state.difficulty = input.difficulty ?? (isIntroductionTask ? "easy" : "medium");
|
||||
state.media = input.requestedMode ?? "video";
|
||||
}
|
||||
|
||||
@@ -1000,6 +985,18 @@ export function buildCuratorServiceRoute(input: CuratorRouteInput) {
|
||||
state.brief = input.roleplayBrief;
|
||||
}
|
||||
|
||||
if (service.id === "social-branding-service") {
|
||||
state.agent = "nova";
|
||||
}
|
||||
|
||||
if (
|
||||
service.id === "resume-service" ||
|
||||
service.id === "courses-service" ||
|
||||
service.id === "social-branding-service"
|
||||
) {
|
||||
state.role = input.targetRole?.trim();
|
||||
}
|
||||
|
||||
return buildServiceLink(service.id, service.curator.defaultPage, state)
|
||||
?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId });
|
||||
}
|
||||
@@ -1021,7 +1018,7 @@ export function getServiceToolName(serviceId?: string) {
|
||||
}
|
||||
|
||||
export function getCompletionEvents(serviceId?: string) {
|
||||
return getService(serviceId)?.curator.completionEvents ?? ["curator.task.completed"];
|
||||
return getServiceRecord(serviceId)?.curator.completionEvents ?? ["curator.task.completed"];
|
||||
}
|
||||
|
||||
export const getServiceCompletionEvents = getCompletionEvents;
|
||||
|
||||
65
src/services/user-profile.ts
Normal file
65
src/services/user-profile.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { config } from "../config.js";
|
||||
|
||||
/**
|
||||
* Build the absolute user-service URL for a given sub-path.
|
||||
* Pure — no IO. Exported so callers (and tests) can resolve targets without
|
||||
* duplicating the base-URL/trim logic.
|
||||
*/
|
||||
export function userServiceTarget(path: string, search = ""): URL {
|
||||
return new URL(`/api/v1/users${path}${search}`, config.userServiceUrl.replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the user-service profile via an explicit GET /me.
|
||||
*
|
||||
* This is a READ and must never replay the inbound request method/body. The
|
||||
* PATCH /onboarding handler parses its own JSON payload with `c.req.json()`,
|
||||
* which consumes `c.req.raw` (marks bodyUsed). Reusing that Request here would
|
||||
* (a) wrongly issue a PATCH /me write for a read, and (b) call
|
||||
* `arrayBuffer()` on an already-consumed body —
|
||||
* `TypeError: Body is unusable: Body has already been read` — which bubbles
|
||||
* out of the handler as an uncaught 500 `{"error":"internal"}`.
|
||||
*
|
||||
* Building a fresh GET sidesteps both: no body replay, and the inbound
|
||||
* Request's body is never touched.
|
||||
*/
|
||||
export async function fetchUserProfile(req: Request): Promise<Record<string, unknown>> {
|
||||
const target = userServiceTarget("/me", new URL(req.url).search);
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
headers.delete("content-type");
|
||||
headers.delete("content-length");
|
||||
headers.delete("transfer-encoding");
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`user-service /me fetch failed: ${res.status} ${text}`);
|
||||
}
|
||||
return res.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a synthetic PATCH /me request carrying only `{ preferences }` so the
|
||||
* backend can persist the merged onboarding blob without disturbing other
|
||||
* profile fields. Strips host/cookie like the other proxy helpers.
|
||||
*
|
||||
* The body differs from the inbound request, so hop-by-hop and length headers
|
||||
* copied from the inbound request MUST be dropped: a stale `content-length`
|
||||
* (or `transfer-encoding`) describes the ORIGINAL body, not the synthetic one,
|
||||
* and undici closes the socket mid-write ("other side closed") when the
|
||||
* declared length disagrees with the bytes sent. The Request constructor does
|
||||
* NOT recompute content-length when an explicit body is provided alongside
|
||||
* forwarded headers, so these deletes are load-bearing.
|
||||
*/
|
||||
export function patchPreferencesRequest(req: Request, preferences: Record<string, unknown>): Request {
|
||||
const target = userServiceTarget("/me");
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
headers.delete("content-length");
|
||||
headers.delete("transfer-encoding");
|
||||
headers.set("content-type", "application/json");
|
||||
const body = JSON.stringify({ preferences });
|
||||
return new Request(target, { method: "PATCH", headers, body });
|
||||
}
|
||||
@@ -3,10 +3,15 @@ import { z } from "zod";
|
||||
import { and, desc, eq, gte, sql } from "drizzle-orm";
|
||||
import { requireUser, type AuthContext } from "../../auth/clerk.js";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
|
||||
import { growEvents } from "../../db/schema.js";
|
||||
import { recordGrowEvent } from "../../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../../events/route-to-user-actor.js";
|
||||
import { v1AnalyticsActor } from "./analytics-actor.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function daysAgo(days: number) {
|
||||
return new Date(Date.now() - days * 86400000);
|
||||
@@ -40,17 +45,17 @@ export function v1AnalyticsRoutes() {
|
||||
|
||||
app.get("/insight-snapshot", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const [projection] = await db
|
||||
.select()
|
||||
.from(growQscoreProjectionState)
|
||||
.where(eq(growQscoreProjectionState.userId, userId))
|
||||
.limit(1);
|
||||
const latestSignals = await db
|
||||
.select()
|
||||
.from(growQscoreLatest)
|
||||
.where(eq(growQscoreLatest.userId, userId))
|
||||
.orderBy(desc(growQscoreLatest.updatedAt))
|
||||
.limit(20);
|
||||
const qscoreResult = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
|
||||
const breakdown = qscoreResult?.breakdown ?? {};
|
||||
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
|
||||
const latestSignals = breakdownSignals.map((item) => {
|
||||
const s = isRecord(item) ? item : {};
|
||||
return {
|
||||
signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "",
|
||||
score: typeof s.score === "number" ? s.score : 0,
|
||||
source: typeof s.source === "string" ? s.source : null,
|
||||
};
|
||||
});
|
||||
const recentEvents = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
@@ -71,9 +76,12 @@ export function v1AnalyticsRoutes() {
|
||||
const bucket = sourceBucket(event.source);
|
||||
serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + 1);
|
||||
}
|
||||
const score = projection?.score ?? null;
|
||||
const score = qscoreResult?.q_score ?? null;
|
||||
const strongestSignal = [...latestSignals].sort((a, b) => b.score - a.score)[0];
|
||||
const weakestSignal = [...latestSignals].sort((a, b) => a.score - b.score)[0];
|
||||
const readinessSummary = typeof breakdown.summary === "string" ? breakdown.summary : "No projected readiness summary is available yet.";
|
||||
const lastUpdatedAt = qscoreResult?.calculated_at ?? null;
|
||||
const signalCount = typeof breakdown.signalCount === "number" ? breakdown.signalCount : latestSignals.length;
|
||||
|
||||
const response = {
|
||||
roleFit: {
|
||||
@@ -83,9 +91,9 @@ export function v1AnalyticsRoutes() {
|
||||
weakestSignal: weakestSignal?.signalId ?? null,
|
||||
},
|
||||
readinessTrend: {
|
||||
signalCount: projection?.signalCount ?? latestSignals.length,
|
||||
lastUpdatedAt: projection?.updatedAt?.toISOString() ?? latestSignals[0]?.updatedAt?.toISOString() ?? null,
|
||||
summary: projection?.summary ?? "No projected readiness summary is available yet.",
|
||||
signalCount,
|
||||
lastUpdatedAt,
|
||||
summary: readinessSummary,
|
||||
},
|
||||
activity: {
|
||||
totalEvents14d: counts?.total ?? 0,
|
||||
@@ -113,7 +121,7 @@ export function v1AnalyticsRoutes() {
|
||||
},
|
||||
payload: {
|
||||
score,
|
||||
signalCount: projection?.signalCount ?? latestSignals.length,
|
||||
signalCount,
|
||||
totalEvents14d: counts?.total ?? 0,
|
||||
source: "v1.analytics.insight-snapshot",
|
||||
},
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { actor } from "rivetkit";
|
||||
import { buildCuratorPlan, buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, buildServiceCurationPreview, todayIsoDate } from "./curator-store.js";
|
||||
import { curatorPlanSchema, curatorSprintResponseSchema, type CuratorImprovementSignal } from "./curator-types.js";
|
||||
import { buildCuratorSprint, buildCuratorStreak, buildCuratorTasks, todayIsoDate } from "./curator-store.js";
|
||||
import { curatorSprintResponseSchema, type CuratorImprovementSignal } from "./curator-types.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import { runCuratorChat } from "./curator-agent.js";
|
||||
import { prepareHandoffForTask } from "./curator-tools.js";
|
||||
import type { CuratorIcpId } from "./icp-registry.js";
|
||||
import { runCuratorOnboardingLoop } from "./curator-onboarding-loop.js";
|
||||
|
||||
type CuratorActorState = {
|
||||
userId: string;
|
||||
planGenerations: number;
|
||||
sprintReads: number;
|
||||
taskCompletions: number;
|
||||
lastActionAt?: string;
|
||||
@@ -46,33 +42,9 @@ function touch(c: { state: CuratorActorState }, input: { userId: string }) {
|
||||
}
|
||||
|
||||
export const curatorService = {
|
||||
async generatePlanRange(input: { userId: string; startDate?: string; endDate?: string; goals?: string[]; forceRegenerate?: boolean }) {
|
||||
const startDate = input.startDate ?? todayIsoDate();
|
||||
const endDate = input.endDate ?? startDate;
|
||||
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate, endDate, goals: input.goals }));
|
||||
await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.plan.generated",
|
||||
payload: {
|
||||
startDate,
|
||||
endDate,
|
||||
planId: plan.id,
|
||||
durationDays: plan.durationDays,
|
||||
goals: input.goals ?? plan.goals,
|
||||
weekCount: plan.weeks.length,
|
||||
dayCount: plan.days.length,
|
||||
plan,
|
||||
},
|
||||
});
|
||||
return { plan };
|
||||
},
|
||||
|
||||
async getPlan(input: { userId: string; startDate?: string; endDate?: string }) {
|
||||
return this.generatePlanRange(input);
|
||||
},
|
||||
|
||||
async previewCuration(input: { userId: string; startDate?: string; icpId?: CuratorIcpId; goals?: string[]; userContext?: Record<string, unknown> }) {
|
||||
return buildServiceCurationPreview(input);
|
||||
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, input.startDate));
|
||||
return { plan: sprint.plan };
|
||||
},
|
||||
|
||||
async runOnboardingLoop(input: { userId: string; completedAt?: string }) {
|
||||
@@ -106,10 +78,6 @@ export const curatorService = {
|
||||
return sprint;
|
||||
},
|
||||
|
||||
async chat(input: { userId: string; conversationId?: string; date?: string; taskId?: string; subtaskIndex?: number; subtask?: string; messages: Array<{ role: "user" | "assistant"; content: string }> }) {
|
||||
return runCuratorChat(input);
|
||||
},
|
||||
|
||||
async startTask(input: { userId: string; taskId: string; date?: string }) {
|
||||
const date = taskActionDate(input);
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
@@ -130,14 +98,6 @@ export const curatorService = {
|
||||
return { task: { ...task, status: "started" as const }, eventId: event.id };
|
||||
},
|
||||
|
||||
async prepareTaskHandoff(input: { userId: string; taskId: string; date?: string }) {
|
||||
const date = taskActionDate(input);
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
if (!task) throw new Error("curator_task_not_found");
|
||||
if (task.serviceId) return prepareHandoffForTask(input.userId, task, task.serviceId);
|
||||
throw new Error("curator_task_has_no_handoff");
|
||||
},
|
||||
|
||||
async completeTask(input: { userId: string; taskId: string; date?: string; reason?: string }) {
|
||||
const date = taskActionDate(input);
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
@@ -161,7 +121,7 @@ export const curatorService = {
|
||||
userId: input.userId,
|
||||
type: "curator.task.completed",
|
||||
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
|
||||
payload: { taskId: task.id, date, reason },
|
||||
payload: { taskId: task.id, date, reason, qxDelta: task.rewardCoins, rewardCoins: task.rewardCoins, qxImpact: task.qxImpact },
|
||||
});
|
||||
return { task: { ...task, status: "completed" as const }, eventId: event.id };
|
||||
},
|
||||
@@ -190,24 +150,14 @@ export const curatorActor = actor({
|
||||
options: { name: "Curator Actor", icon: "sparkles", noSleep: true, actionTimeout: 300_000 },
|
||||
state: {
|
||||
userId: "",
|
||||
planGenerations: 0,
|
||||
sprintReads: 0,
|
||||
taskCompletions: 0,
|
||||
} as CuratorActorState,
|
||||
actions: {
|
||||
generatePlanRange: async (c, input: Parameters<typeof curatorService.generatePlanRange>[0]) => {
|
||||
touch(c, input);
|
||||
c.state.planGenerations += 1;
|
||||
return curatorService.generatePlanRange(input);
|
||||
},
|
||||
getPlan: async (c, input: Parameters<typeof curatorService.getPlan>[0]) => {
|
||||
touch(c, input);
|
||||
return curatorService.getPlan(input);
|
||||
},
|
||||
previewCuration: async (c, input: Parameters<typeof curatorService.previewCuration>[0]) => {
|
||||
touch(c, input);
|
||||
return curatorService.previewCuration(input);
|
||||
},
|
||||
runOnboardingLoop: async (c, input: Parameters<typeof curatorService.runOnboardingLoop>[0]) => {
|
||||
touch(c, input);
|
||||
return curatorService.runOnboardingLoop(input);
|
||||
@@ -222,20 +172,12 @@ export const curatorActor = actor({
|
||||
c.state.sprintReads += 1;
|
||||
return curatorService.getSprint(input);
|
||||
},
|
||||
chat: async (c, input: Parameters<typeof curatorService.chat>[0]) => {
|
||||
touch(c, input);
|
||||
return curatorService.chat(input);
|
||||
},
|
||||
startTask: async (c, input: Parameters<typeof curatorService.startTask>[0]) => {
|
||||
touch(c, input);
|
||||
const result = await curatorService.startTask(input);
|
||||
c.state.lastEventId = result.eventId;
|
||||
return result;
|
||||
},
|
||||
prepareTaskHandoff: async (c, input: Parameters<typeof curatorService.prepareTaskHandoff>[0]) => {
|
||||
touch(c, input);
|
||||
return curatorService.prepareTaskHandoff(input);
|
||||
},
|
||||
completeTask: async (c, input: Parameters<typeof curatorService.completeTask>[0]) => {
|
||||
touch(c, input);
|
||||
const result = await curatorService.completeTask(input);
|
||||
|
||||
@@ -1,684 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { generateText } from "ai";
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents } from "../../db/schema.js";
|
||||
import { addMessagePg, createConversationPg, ensureCuratorTaskConversationPg, getConversationMetadataPg, listMessagesPg } from "../../grow/persistence.js";
|
||||
import { generateConversationResponse, getConversationModel } from "../../actors/conversation/agent.js";
|
||||
import { buildCuratorTasks, todayIsoDate } from "./curator-store.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import type { CuratorChatResponse, CuratorSubtaskStatusUpdate } from "./curator-types.js";
|
||||
import { prepareHandoffForTask } from "./curator-tools.js";
|
||||
import { fallbackCuratorRole, resolveCuratorTargetRole } from "./curator-user-context.js";
|
||||
|
||||
const CURATOR_STREAK_CHAT_PROMPT = path.resolve(process.cwd(), "prompts/curator/streak-chat.md");
|
||||
|
||||
const FALLBACK_CURATOR_STREAK_CHAT_PROMPT = [
|
||||
"You are the GrowQR V1 Curator in a daily or weekly streak chat modal.",
|
||||
"Ask at most one clarifying question before a service preview handoff is ready.",
|
||||
"If no target role is known for interview or roleplay, ask exactly: What role are you targeting?",
|
||||
"If the target role is known, do not ask again. Proceed to a short summary and let the dashboard show the CTA.",
|
||||
"Never include internal URLs, setup routes, API paths, JSON, or tool names in chat text.",
|
||||
"Interview defaults: type=behavioral, difficulty=medium, duration=5.",
|
||||
"Use ASCII punctuation only.",
|
||||
].join("\n");
|
||||
|
||||
async function loadCuratorStreakPrompt() {
|
||||
return readFile(CURATOR_STREAK_CHAT_PROMPT, "utf8").catch(() => FALLBACK_CURATOR_STREAK_CHAT_PROMPT);
|
||||
}
|
||||
|
||||
const chatExtractSchema = z.object({
|
||||
summary: z.string(),
|
||||
userGoal: z.string().optional(),
|
||||
serviceIntent: z.string().optional(),
|
||||
shouldPrepareHandoff: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const subtaskStatusUpdateSchema = z.object({
|
||||
status: z.enum(["needs_more_context", "ready_to_capture", "handoff_ready"]),
|
||||
summary: z.string().min(1).max(280),
|
||||
confidence: z.number().min(0).max(1).default(0.5),
|
||||
nextMissingInfo: z.string().max(180).optional(),
|
||||
});
|
||||
|
||||
function parseJsonObject(text: string) {
|
||||
const trimmed = text.trim();
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
const match = trimmed.match(/\{[\s\S]*\}/);
|
||||
if (!match) throw new Error("model_did_not_return_json");
|
||||
return JSON.parse(match[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function buildId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function sanitize(text: string) {
|
||||
const withoutControlLines = text
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return true;
|
||||
if (/^(date|curator task id|focused subtask|curator task title|curator task context|curator task subtasks|curator service|expected completion events|captured task memory|task title|task service|task context|all task subtasks|visible history):/i.test(trimmed)) return false;
|
||||
if (/setup route|mission instance id|curator task id|access the setup at/i.test(trimmed)) return false;
|
||||
if (/\/agents\/(interview|roleplay|resume|qscore)|\/analytics\?|\/social\?|\/pathways\?/i.test(trimmed)) return false;
|
||||
if (/^```/.test(trimmed)) return false;
|
||||
return true;
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
const withoutJsonEnvelope = withoutControlLines.replace(/^\s*\{[\s\S]*"reply"\s*:\s*"([^"]+)"[\s\S]*\}\s*$/i, "$1");
|
||||
const withoutRoutes = withoutJsonEnvelope
|
||||
.replace(/\b(?:Interview|Roleplay|Resume|Q Score)?\s*setup route:\s*\/\S+/gi, "")
|
||||
.replace(/\/agents\/(?:interview|roleplay|resume|qscore)\/?\S*/gi, "")
|
||||
.replace(/\/analytics\?\S*/gi, "")
|
||||
.replace(/\/social\?\S*/gi, "")
|
||||
.replace(/\/pathways\?\S*/gi, "");
|
||||
return withoutRoutes
|
||||
.replace(/[\u2013\u2014]/g, "-")
|
||||
.replace(/[\u2018\u2019]/g, "'")
|
||||
.replace(/[\u201C\u201D]/g, '"')
|
||||
.replace(/\u2026/g, "...")
|
||||
.replace(/^\s*(Perfect|Great|Absolutely|Sure)[.!,:;-]*\s*/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function pushField(lines: string[], label: string, value?: string | number | null) {
|
||||
if (value === undefined || value === null) return;
|
||||
const stringValue = String(value).trim();
|
||||
if (!stringValue) return;
|
||||
lines.push(`${label}: ${stringValue}`);
|
||||
}
|
||||
|
||||
function pushList(lines: string[], label: string, values?: string[]) {
|
||||
const cleanValues = values?.map((value) => value.trim()).filter(Boolean) ?? [];
|
||||
if (cleanValues.length === 0) return;
|
||||
lines.push(`${label}: ${cleanValues.join(" | ")}`);
|
||||
}
|
||||
|
||||
type CuratorMessage = Awaited<ReturnType<typeof listMessagesPg>>[number];
|
||||
|
||||
async function capturedSubtaskMemory(userId: string, taskId?: string) {
|
||||
if (!taskId) return [];
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
eq(growEvents.type, "curator.subtask.captured"),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(80);
|
||||
|
||||
return rows
|
||||
.map((row) => row.payload ?? {})
|
||||
.filter((payload) => payload.taskId === taskId)
|
||||
.map((payload) => ({
|
||||
subtaskIndex: typeof payload.subtaskIndex === "number" ? payload.subtaskIndex : undefined,
|
||||
subtask: typeof payload.subtask === "string" ? payload.subtask : undefined,
|
||||
summary: typeof (payload.statusUpdate as any)?.summary === "string" ? (payload.statusUpdate as any).summary : undefined,
|
||||
}))
|
||||
.filter((item) => item.summary)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
function visibleCuratorMessages(messages: CuratorMessage[]) {
|
||||
const filtered = messages.filter((message) => {
|
||||
const content = message.content.trim();
|
||||
if (message.role === "user") {
|
||||
if (/^start$/i.test(content)) return false;
|
||||
if (/^i opened /i.test(content)) return false;
|
||||
return true;
|
||||
}
|
||||
return !/what should i capture/i.test(content);
|
||||
});
|
||||
return filtered.filter((message, index) => {
|
||||
const previous = filtered[index - 1];
|
||||
return !previous || previous.role !== message.role || previous.content.trim() !== message.content.trim();
|
||||
});
|
||||
}
|
||||
|
||||
function usefulUserMessages(messages: CuratorMessage[]) {
|
||||
return messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.content.trim())
|
||||
.filter((content) => content && !/^start$/i.test(content) && !content.toLowerCase().includes("i opened "));
|
||||
}
|
||||
|
||||
function targetRoleState(messages: CuratorMessage[], latest: string) {
|
||||
const userMessages = usefulUserMessages(messages);
|
||||
const all = [...userMessages, latest.trim()].filter(Boolean);
|
||||
const lowerAll = all.join("\n").toLowerCase();
|
||||
const shortAnswers = all.filter((content) => content.length <= 80);
|
||||
const targetRole = shortAnswers.find((content) => {
|
||||
const lower = content.toLowerCase();
|
||||
return /manager|engineer|designer|analyst|developer|product|marketing|sales|founder|consultant|operator|lead|head|director/.test(lower);
|
||||
});
|
||||
const currentBackground = all.find((content) => {
|
||||
const lower = content.toLowerCase();
|
||||
return lower.includes("currently") || lower.includes("right now") || lower.includes("i am ") || lower.includes("i'm ") || lower.includes("my background") || lower.includes("experience");
|
||||
});
|
||||
const constraints = all.find((content) => {
|
||||
const lower = content.toLowerCase();
|
||||
return lower.includes("month") || lower.includes("week") || lower.includes("salary") || lower.includes("remote") || lower.includes("location") || lower.includes("visa") || lower.includes("timeline");
|
||||
});
|
||||
return {
|
||||
targetRole,
|
||||
currentBackground,
|
||||
constraints,
|
||||
hasAskedCurrent: lowerAll.includes("current background") || lowerAll.includes("current role") || lowerAll.includes("where you are starting"),
|
||||
hasAskedConstraints: lowerAll.includes("constraint") || lowerAll.includes("timeline"),
|
||||
};
|
||||
}
|
||||
|
||||
const CURATOR_PROMPT_FILE = path.resolve(process.cwd(), "prompts", "curator-v1.md");
|
||||
|
||||
const DEFAULT_CURATOR_PROMPT = `You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
|
||||
Own 30 day direction, streak continuity, and service handoff decisions.
|
||||
Do not ask the same question twice.
|
||||
Use captured task memory and keep the user on the focused subtask.
|
||||
When the user has answered enough, summarize what was captured and stop.
|
||||
If more detail is needed, ask exactly one follow-up question.
|
||||
For service work, prepare preview-oriented handoffs once enough context exists.`;
|
||||
|
||||
async function loadCuratorPromptTemplate() {
|
||||
try {
|
||||
return await readFile(CURATOR_PROMPT_FILE, "utf8");
|
||||
} catch {
|
||||
return DEFAULT_CURATOR_PROMPT;
|
||||
}
|
||||
}
|
||||
|
||||
async function curatorSystemAddendum(input: {
|
||||
date: string;
|
||||
taskId?: string;
|
||||
subtaskIndex?: number;
|
||||
subtask?: string;
|
||||
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
|
||||
taskMemory?: Array<{ subtaskIndex?: number; subtask?: string; summary?: string }>;
|
||||
promptText: string;
|
||||
targetRole?: string;
|
||||
}) {
|
||||
const template = await loadCuratorPromptTemplate();
|
||||
const lines = [
|
||||
input.promptText,
|
||||
"",
|
||||
...template
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean),
|
||||
];
|
||||
pushField(lines, "Known target role", input.targetRole);
|
||||
pushField(lines, "Date", input.date);
|
||||
pushField(lines, "Curator task id", input.taskId);
|
||||
pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined);
|
||||
pushField(lines, "Focused subtask title", input.subtask);
|
||||
pushField(lines, "Curator task title", input.task?.title);
|
||||
pushField(lines, "Curator task context", input.task?.contextNarrative);
|
||||
pushList(lines, "Curator task subtasks", input.task?.subtasks);
|
||||
pushField(lines, "Curator service", input.task?.serviceName);
|
||||
pushList(lines, "Expected completion events", input.task?.completionEvents);
|
||||
const memory = input.taskMemory
|
||||
?.map((item) => {
|
||||
if (!item.summary) return "";
|
||||
const subtask = item.subtask?.trim() || "Subtask";
|
||||
const index = Number.isInteger(item.subtaskIndex) ? `[${item.subtaskIndex}] ` : "";
|
||||
return `${index}${subtask}: ${item.summary}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
pushList(lines, "Captured task memory", memory);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function curatorTaskKey(taskId?: string, subtaskIndex?: number) {
|
||||
if (!taskId) return undefined;
|
||||
return `${taskId}:${subtaskIndex ?? "task"}`;
|
||||
}
|
||||
|
||||
function firstTurnPrompt(input: {
|
||||
subtask?: string;
|
||||
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
|
||||
targetRole?: string;
|
||||
}) {
|
||||
return [
|
||||
`The user opened this focused subtask: ${input.subtask ?? input.task?.title ?? "curator task"}.`,
|
||||
"Generate the first live conversational question for this exact subtask.",
|
||||
input.targetRole ? `Known target role: ${input.targetRole}. Do not ask for the role again.` : "If this is an interview or roleplay task and no target role is known, ask exactly: What role are you targeting?",
|
||||
"Ask only one question. Do not use canned wording. Do not prepare any service handoff yet.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function isExplicitHandoffRequest(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (/^start$/i.test(trimmed)) return false;
|
||||
return /\b(start|open|launch|begin|set up|setup|create|generate|room|ready|go|give)\b/i.test(trimmed);
|
||||
}
|
||||
|
||||
function shouldPrepareServiceHandoff(status: CuratorSubtaskStatusUpdate, task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number]) {
|
||||
if (!task?.serviceId) return false;
|
||||
return status.status === "ready_to_capture" || status.status === "handoff_ready";
|
||||
}
|
||||
|
||||
function isPreviewHandoffService(task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number]) {
|
||||
return task?.serviceId === "interview-service" || task?.serviceId === "roleplay-service";
|
||||
}
|
||||
|
||||
function servicePreviewSummary(task: Awaited<ReturnType<typeof buildCuratorTasks>>[number], targetRole?: string) {
|
||||
const role = fallbackCuratorRole(targetRole);
|
||||
if (task.serviceId === "interview-service") {
|
||||
return `Prepared a 5-minute behavioral interview preview for ${role}.`;
|
||||
}
|
||||
if (task.serviceId === "roleplay-service") {
|
||||
return `Prepared a 5-minute roleplay preview for ${role}.`;
|
||||
}
|
||||
return `${task.serviceName} handoff is ready.`;
|
||||
}
|
||||
|
||||
function fallbackCuratorReply(input: {
|
||||
latest: string;
|
||||
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
|
||||
subtask?: string;
|
||||
targetRole?: string;
|
||||
}) {
|
||||
const latest = input.latest.trim();
|
||||
const lowerTitle = input.task?.title.toLowerCase() ?? "";
|
||||
const lowerSubtask = input.subtask?.toLowerCase() ?? "";
|
||||
const role = fallbackCuratorRole(input.targetRole);
|
||||
|
||||
if ((input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") && !input.targetRole) {
|
||||
return "What role are you targeting?";
|
||||
}
|
||||
|
||||
if (/^start$/i.test(latest)) {
|
||||
if (input.task?.serviceId === "qscore-service") {
|
||||
return "Open your current Q Score and tell me which readiness signal looks weakest today.";
|
||||
}
|
||||
if (input.task?.serviceId === "resume-service") {
|
||||
return "Upload your current resume or paste three recent wins so I can anchor this proof task.";
|
||||
}
|
||||
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
|
||||
return `I have your target role as ${role}. Say start when you want the preview opened.`;
|
||||
}
|
||||
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
|
||||
return "Which role family do you want this sprint to optimize toward?";
|
||||
}
|
||||
if (input.task?.taskType === "measurement") {
|
||||
return "Open the current view and tell me the one gap or signal that stands out most.";
|
||||
}
|
||||
if (input.task?.taskType === "proof") {
|
||||
return "Share the strongest proof you already have so we can build from something real.";
|
||||
}
|
||||
return "What is the single outcome you want from this task today?";
|
||||
}
|
||||
|
||||
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
|
||||
if (isExplicitHandoffRequest(latest)) {
|
||||
return servicePreviewSummary(input.task, input.targetRole);
|
||||
}
|
||||
return `Captured ${role} as the target role. Say start when you want the preview opened.`;
|
||||
}
|
||||
|
||||
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
|
||||
return `Captured ${latest}. I will use that as the role direction for this sprint.`;
|
||||
}
|
||||
|
||||
if (input.task?.serviceId === "resume-service") {
|
||||
return "Captured. Open the resume flow when you are ready to turn this into proof.";
|
||||
}
|
||||
|
||||
if (input.task?.serviceId === "qscore-service") {
|
||||
return "Captured. Open the Q Score view and save the main readiness gap you want to work on.";
|
||||
}
|
||||
|
||||
if (input.task?.taskType === "measurement") {
|
||||
return "Captured the baseline signal for today.";
|
||||
}
|
||||
|
||||
if (input.task?.taskType === "proof") {
|
||||
return "Captured the proof point for today.";
|
||||
}
|
||||
|
||||
if (input.task?.taskType === "practice") {
|
||||
return "Captured the practice focus for today.";
|
||||
}
|
||||
|
||||
return "Captured. We can use this to move the task forward.";
|
||||
}
|
||||
|
||||
async function evaluateSubtaskStatus(input: {
|
||||
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
|
||||
subtask?: string;
|
||||
subtaskIndex?: number;
|
||||
latest: string;
|
||||
reply: string;
|
||||
history: CuratorMessage[];
|
||||
}): Promise<CuratorSubtaskStatusUpdate> {
|
||||
if (!input.subtask || /^start$/i.test(input.latest.trim())) {
|
||||
return { status: "needs_more_context", summary: "Subtask opened.", confidence: 0.2 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: getConversationModel(),
|
||||
system: [
|
||||
"You are the GrowQR V1 Curator Actor state evaluator.",
|
||||
"Return JSON only. Do not wrap it in markdown.",
|
||||
"Shape: {\"status\": \"needs_more_context\" | \"ready_to_capture\" | \"handoff_ready\", \"summary\": string, \"confidence\": number, \"nextMissingInfo\"?: string}.",
|
||||
"Evaluate only the focused subtask. Ignore other missions, other subtasks, and later checklist items.",
|
||||
"Use ready_to_capture only when the latest user answer directly satisfies the focused subtask.",
|
||||
"Use needs_more_context if the assistant reply asks another question or if the answer is too vague for this exact subtask.",
|
||||
"Use handoff_ready only when the focused subtask explicitly asks to open or preview a service and the service setup details are present.",
|
||||
"Use handoff_ready when the user explicitly says to start, open, launch, set up, or begin the service and the necessary setup context is already present.",
|
||||
"Never mark ready just because one message exists.",
|
||||
"Use ASCII punctuation only.",
|
||||
].join("\n"),
|
||||
prompt: (() => {
|
||||
const lines: string[] = [];
|
||||
pushField(lines, "Task title", input.task?.title);
|
||||
pushField(lines, "Task service", input.task?.serviceName);
|
||||
pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined);
|
||||
pushField(lines, "Focused subtask", input.subtask);
|
||||
pushField(lines, "Task context", input.task?.contextNarrative);
|
||||
pushList(lines, "All task subtasks", input.task?.subtasks);
|
||||
pushField(lines, "Latest user answer", input.latest);
|
||||
pushField(lines, "Assistant reply", input.reply);
|
||||
pushField(lines, "Visible history", input.history.map((message) => `${message.role}: ${message.content}`).join("\n"));
|
||||
return lines.join("\n");
|
||||
})(),
|
||||
});
|
||||
return subtaskStatusUpdateSchema.parse(parseJsonObject(result.text));
|
||||
} catch (error) {
|
||||
console.warn("curator status evaluation failed; keeping subtask open", {
|
||||
taskId: input.task?.id,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { status: "needs_more_context", summary: "The curator needs one more answer before updating this subtask.", confidence: 0.1 };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCuratorConversation(input: { userId: string; taskId?: string; date: string; subtaskIndex?: number; subtask?: string }) {
|
||||
if (!input.taskId) return createConversationPg(input.userId, "V1 Curator chat");
|
||||
const task = (await buildCuratorTasks(input.userId, input.date)).find((item) => item.id === input.taskId);
|
||||
if (task) {
|
||||
return ensureCuratorTaskConversationPg({
|
||||
userId: input.userId,
|
||||
curatorTaskId: task.id,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
subtask: input.subtask,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
missionId: task.missionId,
|
||||
stageId: task.stageId,
|
||||
title: input.subtask ?? task.title,
|
||||
});
|
||||
}
|
||||
return createConversationPg(input.userId, "V1 Curator chat");
|
||||
}
|
||||
|
||||
export async function runCuratorChat(input: {
|
||||
userId: string;
|
||||
conversationId?: string;
|
||||
taskId?: string;
|
||||
subtaskIndex?: number;
|
||||
subtask?: string;
|
||||
date?: string;
|
||||
messages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
}): Promise<CuratorChatResponse> {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const expectedTaskKey = curatorTaskKey(input.taskId, input.subtaskIndex);
|
||||
let conversation = input.conversationId ? { id: input.conversationId } : undefined;
|
||||
if (conversation?.id && expectedTaskKey) {
|
||||
const metadata = await getConversationMetadataPg(input.userId, conversation.id);
|
||||
if (metadata?.curatorTaskKey !== expectedTaskKey) {
|
||||
conversation = undefined;
|
||||
}
|
||||
}
|
||||
conversation ??= await ensureCuratorConversation({
|
||||
userId: input.userId,
|
||||
taskId: input.taskId,
|
||||
date,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
subtask: input.subtask,
|
||||
});
|
||||
const latest = [...input.messages].reverse().find((message) => message.role === "user")?.content?.trim() ?? "start";
|
||||
const tasks = await buildCuratorTasks(input.userId, date);
|
||||
const task = input.taskId ? tasks.find((item) => item.id === input.taskId) : undefined;
|
||||
const taskMemory = await capturedSubtaskMemory(input.userId, input.taskId);
|
||||
const promptText = await loadCuratorStreakPrompt();
|
||||
|
||||
await addMessagePg(input.userId, {
|
||||
id: buildId("user"),
|
||||
conversationId: conversation.id,
|
||||
role: "user",
|
||||
sender: "User",
|
||||
content: latest,
|
||||
});
|
||||
const conversationHistory = visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id));
|
||||
const targetRole = await resolveCuratorTargetRole({
|
||||
userId: input.userId,
|
||||
task,
|
||||
latest,
|
||||
history: conversationHistory,
|
||||
});
|
||||
|
||||
const isInitialOpen = /^start$/i.test(latest);
|
||||
if (isInitialOpen && isPreviewHandoffService(task) && targetRole) {
|
||||
const statusUpdate: CuratorSubtaskStatusUpdate = {
|
||||
status: "handoff_ready",
|
||||
summary: servicePreviewSummary(task!, targetRole),
|
||||
confidence: 0.95,
|
||||
};
|
||||
const handoff = await prepareHandoffForTask(input.userId, task!, task!.serviceId, targetRole);
|
||||
const reply = sanitize(statusUpdate.summary);
|
||||
|
||||
await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.subtask.captured",
|
||||
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
|
||||
payload: {
|
||||
taskId: input.taskId,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
subtask: input.subtask,
|
||||
statusUpdate,
|
||||
},
|
||||
});
|
||||
|
||||
await addMessagePg(input.userId, {
|
||||
id: buildId("assistant"),
|
||||
conversationId: conversation.id,
|
||||
role: "assistant",
|
||||
sender: "V1 Curator",
|
||||
content: reply,
|
||||
});
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
taskId: input.taskId,
|
||||
reply,
|
||||
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
|
||||
statusUpdate,
|
||||
handoff,
|
||||
};
|
||||
}
|
||||
|
||||
if (isInitialOpen && isPreviewHandoffService(task) && !targetRole) {
|
||||
const statusUpdate: CuratorSubtaskStatusUpdate = {
|
||||
status: "needs_more_context",
|
||||
summary: "Target role needed before preparing the preview.",
|
||||
confidence: 0.4,
|
||||
nextMissingInfo: "target role",
|
||||
};
|
||||
const reply = "What role are you targeting?";
|
||||
|
||||
await addMessagePg(input.userId, {
|
||||
id: buildId("assistant"),
|
||||
conversationId: conversation.id,
|
||||
role: "assistant",
|
||||
sender: "V1 Curator",
|
||||
content: reply,
|
||||
});
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
taskId: input.taskId,
|
||||
reply,
|
||||
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
|
||||
statusUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
let reply = "";
|
||||
let usedFallbackReply = false;
|
||||
try {
|
||||
try {
|
||||
const extract = await generateText({
|
||||
model: getConversationModel(),
|
||||
system: [
|
||||
"Extract compact curator memory from the user's latest message.",
|
||||
"Return JSON only: {\"summary\": string, \"userGoal\"?: string, \"serviceIntent\"?: string, \"shouldPrepareHandoff\": boolean}.",
|
||||
"Use ASCII punctuation only.",
|
||||
].join("\n"),
|
||||
prompt: (() => {
|
||||
const lines: string[] = [];
|
||||
pushField(lines, "Task", task?.title);
|
||||
pushField(lines, "Subtask", input.subtask);
|
||||
pushField(lines, "Service", task?.serviceName);
|
||||
pushField(lines, "Message", latest);
|
||||
return lines.join("\n");
|
||||
})(),
|
||||
});
|
||||
const parsedExtract = chatExtractSchema.parse(parseJsonObject(extract.text));
|
||||
await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.chat.context_extracted",
|
||||
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
|
||||
payload: { taskId: input.taskId, extract: parsedExtract },
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("curator memory extraction failed; continuing chat", {
|
||||
taskId: input.taskId,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
const modelMessages = conversationHistory.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
}));
|
||||
if (/^start$/i.test(latest) && modelMessages.length === 0) {
|
||||
modelMessages.push({ role: "user", content: firstTurnPrompt({ subtask: input.subtask, task, targetRole }) });
|
||||
}
|
||||
|
||||
const result = await generateConversationResponse(modelMessages, {
|
||||
userId: input.userId,
|
||||
conversationId: conversation.id,
|
||||
missionInstanceId: task?.missionInstanceId,
|
||||
missionId: task?.missionId,
|
||||
stageId: task?.stageId,
|
||||
source: "curator-v1",
|
||||
systemAddendum: await curatorSystemAddendum({ date, taskId: input.taskId, subtaskIndex: input.subtaskIndex, subtask: input.subtask, task, taskMemory, promptText, targetRole }),
|
||||
});
|
||||
reply = sanitize(result.text);
|
||||
if (/what should i capture next/i.test(reply) || !reply) {
|
||||
throw new Error("curator_generation_failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("curator chat generation failed", {
|
||||
taskId: input.taskId,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
subtask: input.subtask,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
reply = sanitize(fallbackCuratorReply({
|
||||
latest,
|
||||
task,
|
||||
subtask: input.subtask,
|
||||
targetRole,
|
||||
}));
|
||||
usedFallbackReply = true;
|
||||
}
|
||||
|
||||
if (!reply) {
|
||||
reply = sanitize(fallbackCuratorReply({
|
||||
latest,
|
||||
task,
|
||||
subtask: input.subtask,
|
||||
targetRole,
|
||||
}));
|
||||
usedFallbackReply = true;
|
||||
}
|
||||
|
||||
let statusUpdate = await evaluateSubtaskStatus({
|
||||
task,
|
||||
subtask: input.subtask,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
latest,
|
||||
reply,
|
||||
history: conversationHistory,
|
||||
});
|
||||
if (task?.serviceId && (isExplicitHandoffRequest(latest) || statusUpdate.status === "ready_to_capture")) {
|
||||
statusUpdate = {
|
||||
status: "handoff_ready",
|
||||
summary: servicePreviewSummary(task, targetRole),
|
||||
confidence: Math.max(statusUpdate.confidence, 0.9),
|
||||
};
|
||||
}
|
||||
if (usedFallbackReply && statusUpdate.status === "needs_more_context" && !statusUpdate.nextMissingInfo) {
|
||||
statusUpdate = {
|
||||
...statusUpdate,
|
||||
summary: reply,
|
||||
};
|
||||
}
|
||||
if (isPreviewHandoffService(task) && !isInitialOpen && usefulUserMessages(conversationHistory).length >= 1) {
|
||||
statusUpdate = {
|
||||
status: "handoff_ready",
|
||||
summary: servicePreviewSummary(task!, targetRole),
|
||||
confidence: Math.max(statusUpdate.confidence, 0.9),
|
||||
};
|
||||
}
|
||||
|
||||
const handoff = shouldPrepareServiceHandoff(statusUpdate, task)
|
||||
? await prepareHandoffForTask(input.userId, task!, task!.serviceId, targetRole)
|
||||
: undefined;
|
||||
|
||||
if (statusUpdate.status !== "needs_more_context") {
|
||||
if (reply.includes("?") || handoff) {
|
||||
reply = sanitize(statusUpdate.summary);
|
||||
}
|
||||
|
||||
await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.subtask.captured",
|
||||
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
|
||||
payload: {
|
||||
taskId: input.taskId,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
subtask: input.subtask,
|
||||
statusUpdate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await addMessagePg(input.userId, {
|
||||
id: buildId("assistant"),
|
||||
conversationId: conversation.id,
|
||||
role: "assistant",
|
||||
sender: "V1 Curator",
|
||||
content: reply,
|
||||
});
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
taskId: input.taskId,
|
||||
reply,
|
||||
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
|
||||
statusUpdate,
|
||||
handoff,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { recordGrowEvent } from "../../events/record-grow-event.js";
|
||||
import { applyQscoreProjection } from "../../events/projectors/qscore-projector.js";
|
||||
|
||||
function curatorDedupeKey(input: {
|
||||
userId: string;
|
||||
@@ -6,13 +7,16 @@ function curatorDedupeKey(input: {
|
||||
payload?: Record<string, unknown>;
|
||||
}) {
|
||||
const payload = input.payload ?? {};
|
||||
const stableId =
|
||||
payload.taskId ??
|
||||
payload.sprintId ??
|
||||
payload.startDate ??
|
||||
payload.sourceEventId ??
|
||||
payload.eventId ??
|
||||
payload.date;
|
||||
const stableId = input.type.includes("invalidated")
|
||||
? `${payload.staleSprintEventId ?? payload.staleReadyEventId ?? payload.staleEventId ?? "unknown"}:${payload.planFingerprint ?? "legacy"}`
|
||||
: payload.planFingerprint ??
|
||||
payload.taskPlanFingerprint ??
|
||||
payload.taskId ??
|
||||
payload.sprintId ??
|
||||
payload.startDate ??
|
||||
payload.sourceEventId ??
|
||||
payload.eventId ??
|
||||
payload.date;
|
||||
|
||||
return `${input.userId}:${input.type}:${stableId ?? Date.now()}`;
|
||||
}
|
||||
@@ -23,7 +27,7 @@ export async function emitCuratorEvent(input: {
|
||||
payload?: Record<string, unknown>;
|
||||
mission?: Record<string, unknown>;
|
||||
}) {
|
||||
return recordGrowEvent({
|
||||
const event = await recordGrowEvent({
|
||||
source: "curator-v1",
|
||||
type: input.type,
|
||||
category: "mission",
|
||||
@@ -33,4 +37,6 @@ export async function emitCuratorEvent(input: {
|
||||
payload: input.payload ?? {},
|
||||
dedupeKey: curatorDedupeKey(input),
|
||||
}, { userId: input.userId, source: "curator-v1" });
|
||||
await applyQscoreProjection(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { asRecord, getString } from "../../events/envelope.js";
|
||||
import { recordGrowEvent } from "../../events/record-grow-event.js";
|
||||
import { log } from "../../log.js";
|
||||
import { config } from "../../config.js";
|
||||
import { buildCuratorSprint, todayIsoDate } from "./curator-store.js";
|
||||
import { reconcileCuratorSprint, todayIsoDate } from "./curator-store.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import type { CuratorSprintResponse } from "./curator-types.js";
|
||||
|
||||
@@ -198,7 +198,7 @@ async function upsertPlanReadyNotification(userId: string, sprint: CuratorSprint
|
||||
id: notificationId,
|
||||
userId,
|
||||
moduleId: "missions",
|
||||
title: "Your 30-day streak plan is ready",
|
||||
title: `Your ${sprint.plan.durationDays}-day CareerSprint is ready`,
|
||||
subtitle: dayOneSubtitle(sprint),
|
||||
tag: "Day 1 ready",
|
||||
urgency: "today",
|
||||
@@ -259,11 +259,6 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
|
||||
const userId = input.userId.trim();
|
||||
if (!userId) return { status: "skipped", reason: "missing_user_id" };
|
||||
|
||||
const existing = await findExistingReadyEvent(userId);
|
||||
if (existing) {
|
||||
return { status: "already_ready", readyEventId: existing.id };
|
||||
}
|
||||
|
||||
const startDate = isoDateFrom(input.completedAt);
|
||||
const context = input.context ?? await findLatestOnboardingContext(userId) ?? await fetchUserServiceContext(userId);
|
||||
await recordOnboardingContextSnapshot({
|
||||
@@ -274,9 +269,57 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
|
||||
sourceEventId: input.sourceEventId,
|
||||
context,
|
||||
});
|
||||
const sprint = await buildCuratorSprint(userId, startDate);
|
||||
// Always build/reconcile before honoring an existing ready event. This is
|
||||
// required when onboarding is reset and completed again with a new choice.
|
||||
const sprint = await reconcileCuratorSprint(userId, startDate, context ?? {});
|
||||
await upsertPlanReadyNotification(userId, sprint);
|
||||
|
||||
const existing = await findExistingReadyEvent(userId);
|
||||
if (existing) {
|
||||
const existingFingerprint = typeof existing.payload?.planFingerprint === "string" ? existing.payload.planFingerprint : undefined;
|
||||
const existingIcpId = typeof existing.payload?.icpId === "string"
|
||||
? existing.payload.icpId
|
||||
: typeof existing.payload?.variantId === "string" ? existing.payload.variantId : undefined;
|
||||
const compatible = existingFingerprint === sprint.plan.planFingerprint && existingIcpId === sprint.plan.icpId;
|
||||
if (compatible) return { status: "already_ready", readyEventId: existing.id, sprint };
|
||||
|
||||
const invalidations = await db
|
||||
.select({ payload: growEvents.payload })
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
eq(growEvents.source, CURATOR_SOURCE),
|
||||
eq(growEvents.type, "curator.onboarding_plan.invalidated"),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(20);
|
||||
const alreadyInvalidated = invalidations.some((row) => (
|
||||
row.payload?.staleReadyEventId === existing.id
|
||||
&& row.payload?.planFingerprint === sprint.plan.planFingerprint
|
||||
));
|
||||
if (!alreadyInvalidated) {
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "curator.onboarding_plan.invalidated",
|
||||
payload: {
|
||||
sprintId: sprint.sprintId,
|
||||
staleReadyEventId: existing.id,
|
||||
previousIcpId: existingIcpId,
|
||||
previousDurationDays: existing.payload?.durationDays,
|
||||
previousPlanFingerprint: existingFingerprint,
|
||||
icpId: sprint.plan.icpId,
|
||||
variantId: sprint.plan.icpId,
|
||||
access: sprint.plan.access,
|
||||
durationDays: sprint.plan.durationDays,
|
||||
planFingerprint: sprint.plan.planFingerprint,
|
||||
taskPlan: sprint.plan.taskPlan,
|
||||
dayOneTasks: dayOneTasks(sprint),
|
||||
reason: "onboarding_plan_changed",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const event = await emitCuratorEvent({
|
||||
userId,
|
||||
type: ONBOARDING_READY_EVENT,
|
||||
@@ -289,10 +332,16 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
|
||||
sprintId: sprint.sprintId,
|
||||
planId: sprint.plan.id,
|
||||
durationDays: sprint.plan.durationDays,
|
||||
access: sprint.plan.access,
|
||||
icpId: sprint.plan.icpId,
|
||||
variantId: sprint.plan.icpId,
|
||||
planFingerprint: sprint.plan.planFingerprint,
|
||||
taskPlan: sprint.plan.taskPlan,
|
||||
weekCount: sprint.plan.weeks.length,
|
||||
dayCount: sprint.plan.days.length,
|
||||
activeDayIndex: sprint.activeDayIndex,
|
||||
weeklyThemes: weeklyThemes(sprint),
|
||||
// Keep the future-day visibility contract: this is Day 1 only.
|
||||
dayOneTasks: dayOneTasks(sprint),
|
||||
notificationId: `curator:onboarding-plan-ready:${userId}`,
|
||||
},
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { CuratorIcpPlaybook } from "./icp-registry.js";
|
||||
import type { CuratorUserContext } from "./curator-user-context.js";
|
||||
|
||||
export const CURATOR_PROMPT_VERSION = "service-curation-v1";
|
||||
|
||||
export type CuratorPromptAssembly = {
|
||||
version: typeof CURATOR_PROMPT_VERSION;
|
||||
hash: string;
|
||||
prompt: string;
|
||||
inputs: {
|
||||
startDate: string;
|
||||
durationDays: number;
|
||||
userContext: CuratorUserContext;
|
||||
playbook: CuratorIcpPlaybook;
|
||||
goals: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export function buildCuratorPlanPrompt(input: {
|
||||
startDate: string;
|
||||
durationDays: number;
|
||||
userContext: CuratorUserContext;
|
||||
playbook: CuratorIcpPlaybook;
|
||||
goals?: string[];
|
||||
}): CuratorPromptAssembly {
|
||||
const goals = input.goals?.filter(Boolean) ?? [input.playbook.sprintTheme, input.playbook.goal];
|
||||
const inputs = {
|
||||
startDate: input.startDate,
|
||||
durationDays: input.durationDays,
|
||||
userContext: input.userContext,
|
||||
playbook: input.playbook,
|
||||
goals,
|
||||
};
|
||||
const prompt = [
|
||||
"# GrowQR Service Curation Layer",
|
||||
"",
|
||||
"You generate deterministic 30-day streak plans from user context and an ICP playbook.",
|
||||
"Do not invent services. Use only service ids present in the playbook and Service Registry.",
|
||||
"Do not handcraft frontend URLs. Emit linkBuilder inputs; the backend Service Registry builds final deep links.",
|
||||
"No randomness, no vague tasks, no duplicate same-day service tasks.",
|
||||
"",
|
||||
"## Output Contract",
|
||||
"Return structured JSON only with:",
|
||||
"- durationDays: 30",
|
||||
"- calendarWeeks: Sunday-start calendar weeks covering all 30 days",
|
||||
"- days: exactly 30 days, where Day 1 is the subscription/start date",
|
||||
"- closeoutDays: day 29 and day 30",
|
||||
"- each day has exactly 3 tasks: measurement, proof, practice",
|
||||
"- every task includes taskType, serviceId, title, subtitle, qxImpact, effort, cta, expectedSignals, and linkBuilder input",
|
||||
"- weekly themes must follow the ICP stage labels",
|
||||
"",
|
||||
"## Staging Rules",
|
||||
"Start weekly grouping on Sunday. If the user subscribes on Monday, Day 1 is Monday inside a Sunday-start Week 1.",
|
||||
"The sprint is always exactly 30 days. Do not extend or shorten it to fit a calendar week.",
|
||||
"Use the first calendar week for Baseline + First Proof, then progress through the ICP stage labels.",
|
||||
"Use Day 29 and Day 30 for next-sprint planning and strongest-proof packaging.",
|
||||
"",
|
||||
"## Personalization Rules",
|
||||
"- Use targetRole for interview and roleplay links.",
|
||||
"- Use resume/profile context when available; if missing, day 1 proof should collect it.",
|
||||
"- Use QScore to prioritize measurement tasks.",
|
||||
"- Use past activity to avoid repeating completed or recently-used actions.",
|
||||
"- Map every goal to one of the ICP playbook service actions.",
|
||||
"",
|
||||
`Start date: ${input.startDate}`,
|
||||
`Duration days: ${input.durationDays}`,
|
||||
`Goals: ${goals.join(" | ")}`,
|
||||
"",
|
||||
"User context:",
|
||||
stableStringify(input.userContext),
|
||||
"",
|
||||
"ICP playbook:",
|
||||
stableStringify(input.playbook),
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
version: CURATOR_PROMPT_VERSION,
|
||||
hash: stableHash({ version: CURATOR_PROMPT_VERSION, inputs }),
|
||||
prompt,
|
||||
inputs,
|
||||
};
|
||||
}
|
||||
|
||||
export function stableHash(value: unknown) {
|
||||
return createHash("sha256").update(stableStringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
return JSON.stringify(sortKeys(value), null, 2);
|
||||
}
|
||||
|
||||
function sortKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortKeys);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => [key, sortKeys(item)]),
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { requireUser, type AuthContext } from "../../auth/clerk.js";
|
||||
import { config } from "../../config.js";
|
||||
import { db } from "../../db/client.js";
|
||||
import { onboarding } from "../../db/schema.js";
|
||||
import type { Registry } from "../../actors/registry.js";
|
||||
import { curatorService } from "./curator-actor.js";
|
||||
import { buildCuratorSprint, buildCuratorSprintReport, buildCuratorTasks } from "./curator-store.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
@@ -16,37 +18,10 @@ function getCuratorActor(userId: string) {
|
||||
return getClient().curatorActor.getOrCreate(["user", userId]);
|
||||
}
|
||||
|
||||
const chatSchema = z.object({
|
||||
conversationId: z.string().optional(),
|
||||
taskId: z.string().optional(),
|
||||
subtaskIndex: z.number().int().min(0).optional(),
|
||||
subtask: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
messages: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string() })).min(1).max(50),
|
||||
});
|
||||
|
||||
const curationPreviewSchema = z.object({
|
||||
startDate: z.string().optional(),
|
||||
icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional", "freelancer", "founder", "enterprise"]).optional(),
|
||||
goals: z.array(z.string()).optional(),
|
||||
userContext: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export function v1CuratorRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.post("/plan/generate", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
goals: z.array(z.string()).optional(),
|
||||
forceRegenerate: z.boolean().optional(),
|
||||
}).parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await getCuratorActor(userId).generatePlanRange({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.get("/plan", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await getCuratorActor(userId).getPlan({
|
||||
@@ -63,48 +38,30 @@ export function v1CuratorRoutes() {
|
||||
|
||||
app.get("/sprint", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const icpQuery = c.req.query("icpId");
|
||||
const dayQuery = c.req.query("dayIndex");
|
||||
const icpId = curationPreviewSchema.shape.icpId.safeParse(icpQuery).success ? icpQuery as NonNullable<z.infer<typeof curationPreviewSchema>["icpId"]> : undefined;
|
||||
const dayIndex = dayQuery ? Number.parseInt(dayQuery, 10) : undefined;
|
||||
return c.json(await buildCuratorSprint(userId, c.req.query("date"), {
|
||||
icpId,
|
||||
dayIndex: Number.isFinite(dayIndex) ? dayIndex : undefined,
|
||||
}));
|
||||
const savedOnboarding = await db.query.onboarding.findFirst({
|
||||
where: eq(onboarding.userId, userId),
|
||||
});
|
||||
if (savedOnboarding?.data?.status !== "completed") {
|
||||
return c.json({ error: "onboarding_incomplete" }, 409);
|
||||
}
|
||||
return c.json(await buildCuratorSprint(userId, c.req.query("date")));
|
||||
});
|
||||
|
||||
app.get("/sprint/report", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const icpQuery = c.req.query("icpId");
|
||||
const dayQuery = c.req.query("dayIndex");
|
||||
const icpId = curationPreviewSchema.shape.icpId.safeParse(icpQuery).success ? icpQuery as NonNullable<z.infer<typeof curationPreviewSchema>["icpId"]> : undefined;
|
||||
const dayIndex = dayQuery ? Number.parseInt(dayQuery, 10) : undefined;
|
||||
return c.json(await buildCuratorSprintReport(userId, c.req.query("date"), {
|
||||
icpId,
|
||||
dayIndex: Number.isFinite(dayIndex) ? dayIndex : undefined,
|
||||
}));
|
||||
return c.json(await buildCuratorSprintReport(userId, c.req.query("date")));
|
||||
});
|
||||
|
||||
app.post("/sprint/report/generate", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({
|
||||
date: z.string().optional(),
|
||||
icpId: curationPreviewSchema.shape.icpId.optional(),
|
||||
dayIndex: z.number().int().min(1).max(30).optional(),
|
||||
}).parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await buildCuratorSprintReport(userId, body.date, {
|
||||
icpId: body.icpId,
|
||||
dayIndex: body.dayIndex,
|
||||
save: true,
|
||||
}));
|
||||
});
|
||||
|
||||
app.post("/curation/preview", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = curationPreviewSchema.parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await curatorService.previewCuration({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.post("/onboarding/run", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({
|
||||
@@ -113,22 +70,11 @@ export function v1CuratorRoutes() {
|
||||
return c.json(await getCuratorActor(userId).runOnboardingLoop({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.post("/chat", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = chatSchema.parse(await c.req.json());
|
||||
return c.json(await getCuratorActor(userId).chat({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.post("/tasks/:taskId/start", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await getCuratorActor(userId).startTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
|
||||
});
|
||||
|
||||
app.post("/tasks/:taskId/handoff", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await getCuratorActor(userId).prepareTaskHandoff({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
|
||||
});
|
||||
|
||||
app.post("/tasks/:taskId/complete", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({ reason: z.string().optional() }).parse(await c.req.json().catch(() => ({})));
|
||||
|
||||
@@ -13,6 +13,9 @@ type ServiceRouteInput = {
|
||||
missionId?: string;
|
||||
stageId?: string;
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
taskSubtitle?: string;
|
||||
taskType?: string;
|
||||
targetRole?: string;
|
||||
durationMinutes?: number;
|
||||
difficulty?: string;
|
||||
@@ -25,13 +28,16 @@ export function serviceRoute(input: ServiceRouteInput) {
|
||||
return buildCuratorServiceRoute(input);
|
||||
}
|
||||
|
||||
export function buildCuratorTaskDeepLink(task: Pick<CuratorTask, "serviceId" | "missionId" | "missionInstanceId" | "stageId" | "id">, targetRole?: string) {
|
||||
export function buildCuratorTaskDeepLink(task: Pick<CuratorTask, "serviceId" | "missionId" | "missionInstanceId" | "stageId" | "id" | "title" | "subtitle" | "taskType">, targetRole?: string) {
|
||||
return buildCuratorServiceRoute({
|
||||
serviceId: task.serviceId,
|
||||
missionId: task.missionId,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
stageId: task.stageId,
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
taskSubtitle: task.subtitle,
|
||||
taskType: task.taskType,
|
||||
targetRole,
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,552 +0,0 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { eq, desc, and, inArray } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
|
||||
import { interviewService, resumeService, roleplayService } from "../../services/product-service-clients.js";
|
||||
import { listServices } from "../../services/service-registry.js";
|
||||
import { createMissionAction, listMissionActions } from "../../missions/actions.js";
|
||||
import { listActiveMissionsPg, listMessagesPg } from "../../grow/persistence.js";
|
||||
import { buildCuratorStreak, buildCuratorTasks, listCuratorRegistryCapabilities } from "./curator-store.js";
|
||||
import { actionLabel, serviceRoute } from "./curator-service-links.js";
|
||||
import { curatorServiceIdSchema, type CuratorServiceHandoff, type CuratorTask } from "./curator-types.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import { fallbackCuratorRole, resolveCuratorTargetRole } from "./curator-user-context.js";
|
||||
|
||||
async function findTask(userId: string, taskId: string, date: string) {
|
||||
const tasks = await buildCuratorTasks(userId, date);
|
||||
return tasks.find((task) => task.id === taskId) ?? null;
|
||||
}
|
||||
|
||||
function conciseRoleHint(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.length > 80 ? `${trimmed.slice(0, 77).trimEnd()}...` : trimmed;
|
||||
}
|
||||
|
||||
function buildRoleplayBrief(task: CuratorTask, targetRole: string) {
|
||||
return `Practice a realistic ${task.title.toLowerCase()} conversation for ${targetRole}. Include one pushback moment, concise answers, and a clear next step.`;
|
||||
}
|
||||
|
||||
async function missionGoalHint(userId: string, task: CuratorTask) {
|
||||
if (!task.missionInstanceId) return undefined;
|
||||
const active = await listActiveMissionsPg(userId);
|
||||
const match = active.find((item) => item.mission.instanceId === task.missionInstanceId);
|
||||
const goal = typeof match?.mission.goal === "string" ? match.mission.goal : undefined;
|
||||
return conciseRoleHint(goal);
|
||||
}
|
||||
|
||||
function asText(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reviewField(payload: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const direct = asText(payload[key]);
|
||||
if (direct) return direct;
|
||||
}
|
||||
const review = payload.review && typeof payload.review === "object" ? payload.review as Record<string, unknown> : undefined;
|
||||
if (!review) return undefined;
|
||||
for (const key of keys) {
|
||||
const nested = asText(review[key]);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function latestInterviewResumeEvidence(userId: string) {
|
||||
const rows = await db.select({
|
||||
id: growEvents.id,
|
||||
type: growEvents.type,
|
||||
source: growEvents.source,
|
||||
payload: growEvents.payload,
|
||||
occurredAt: growEvents.occurredAt,
|
||||
}).from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type as any, [
|
||||
"interview.feedback.generated",
|
||||
"interview.completed",
|
||||
"roleplay.feedback.generated",
|
||||
"roleplay.completed",
|
||||
]),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(5);
|
||||
|
||||
const latest = rows[0];
|
||||
if (!latest) return null;
|
||||
|
||||
const payload = latest.payload ?? {};
|
||||
const strongestAnswer = reviewField(payload, [
|
||||
"strongest_answer",
|
||||
"strongestAnswer",
|
||||
"best_answer",
|
||||
"bestAnswer",
|
||||
"top_answer",
|
||||
"topAnswer",
|
||||
]);
|
||||
const improvementArea = reviewField(payload, [
|
||||
"improvement_area",
|
||||
"improvementArea",
|
||||
"biggest_gap",
|
||||
"biggestGap",
|
||||
"coaching_note",
|
||||
"coachingNote",
|
||||
]);
|
||||
const summary = reviewField(payload, [
|
||||
"summary",
|
||||
"feedback_summary",
|
||||
"feedbackSummary",
|
||||
"overall_feedback",
|
||||
"overallFeedback",
|
||||
]);
|
||||
|
||||
const carryForward = [
|
||||
summary ? `Review summary: ${summary}` : undefined,
|
||||
strongestAnswer ? `Strongest answer to convert into proof: ${strongestAnswer}` : undefined,
|
||||
improvementArea ? `Weakest area to repair in resume positioning: ${improvementArea}` : undefined,
|
||||
].filter((item): item is string => Boolean(item));
|
||||
|
||||
return {
|
||||
eventId: latest.id,
|
||||
source: latest.source,
|
||||
type: latest.type,
|
||||
occurredAt: latest.occurredAt,
|
||||
carryForward,
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareHandoffForTask(
|
||||
userId: string,
|
||||
task: CuratorTask,
|
||||
serviceId = task.serviceId,
|
||||
targetRoleOverride?: string,
|
||||
): Promise<CuratorServiceHandoff> {
|
||||
if (!serviceId) throw new Error("Task has no service handoff.");
|
||||
const resolvedTargetRole =
|
||||
targetRoleOverride ??
|
||||
(await missionGoalHint(userId, task)) ??
|
||||
(await resolveCuratorTargetRole({ userId, task }));
|
||||
const targetRole = fallbackCuratorRole(resolvedTargetRole);
|
||||
const route = serviceRoute({
|
||||
serviceId,
|
||||
missionId: task.missionId,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
stageId: task.stageId,
|
||||
taskId: task.id,
|
||||
targetRole,
|
||||
durationMinutes: 5,
|
||||
difficulty: "medium",
|
||||
personaId: serviceId === "roleplay-service" ? "emma" : "payal",
|
||||
requestedMode: "video",
|
||||
roleplayBrief: serviceId === "roleplay-service" ? buildRoleplayBrief(task, targetRole) : undefined,
|
||||
});
|
||||
|
||||
let actionId: string | undefined;
|
||||
if (task.missionInstanceId && task.missionId !== "curator-sprint") {
|
||||
const action = await createMissionAction({
|
||||
userId,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
missionId: task.missionId,
|
||||
stageId: task.stageId,
|
||||
agentId: "curator-v1",
|
||||
agentName: "V1 Curator Actor",
|
||||
baseAgent: "Curator Agent",
|
||||
serviceId,
|
||||
toolName: task.toolName,
|
||||
mode: "suggestion",
|
||||
status: "queued",
|
||||
title: task.title,
|
||||
body: task.subtitle,
|
||||
prompt: `Prepare ${task.serviceName} handoff for ${task.title}.`,
|
||||
payload: { href: route, route, taskId: task.id, source: "curator-v1" },
|
||||
idempotencyKey: `curator-v1:${task.id}:${serviceId}`,
|
||||
priority: 50,
|
||||
urgency: "today",
|
||||
});
|
||||
actionId = action?.id;
|
||||
}
|
||||
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "task.opened",
|
||||
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
|
||||
payload: {
|
||||
taskId: task.id,
|
||||
curatorTaskId: task.id,
|
||||
serviceId,
|
||||
route,
|
||||
actionId,
|
||||
expectedCompletionEvents: task.completionEvents,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
serviceId,
|
||||
route,
|
||||
actionId,
|
||||
actionRoute: route,
|
||||
actionLabel: actionLabel({ ...task, serviceId }),
|
||||
status: "prepared",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCuratorTools(ctx: { userId: string; date: string; conversationId?: string; taskId?: string }) {
|
||||
return {
|
||||
get_onboarding_context: tool({
|
||||
description: "Read available onboarding and profile context from recent platform events.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const events = await db.select().from(growEvents)
|
||||
.where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.category, "usage")))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(20);
|
||||
return { events };
|
||||
},
|
||||
}),
|
||||
|
||||
get_user_goals: tool({
|
||||
description: "Infer currently known goals from active missions and mission goals.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const active = await listActiveMissionsPg(ctx.userId);
|
||||
return { goals: active.map((item) => ({ missionId: item.mission.missionId, title: item.mission.title, goal: item.mission.goal })) };
|
||||
},
|
||||
}),
|
||||
|
||||
get_curator_plan: tool({
|
||||
description: "Read today's curator tasks and streak state.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ date: ctx.date, tasks: await buildCuratorTasks(ctx.userId, ctx.date), streak: await buildCuratorStreak(ctx.userId) }),
|
||||
}),
|
||||
|
||||
get_today_tasks: tool({
|
||||
description: "List today's V1 curator tasks.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ tasks: await buildCuratorTasks(ctx.userId, ctx.date) }),
|
||||
}),
|
||||
|
||||
get_curator_streak: tool({
|
||||
description: "Read the user's curator streak from allowed completion events.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ streak: await buildCuratorStreak(ctx.userId) }),
|
||||
}),
|
||||
|
||||
read_curator_memory: tool({
|
||||
description: "Read recent curator memory from existing grow events.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(10) }),
|
||||
execute: async ({ limit }) => {
|
||||
const events = await db.select().from(growEvents)
|
||||
.where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.source, "curator-v1")))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(limit);
|
||||
return { events };
|
||||
},
|
||||
}),
|
||||
|
||||
write_curator_memory: tool({
|
||||
description: "Write a durable curator memory event. Use this for chat extracts and status updates.",
|
||||
inputSchema: z.object({ summary: z.string(), tags: z.array(z.string()).default([]) }),
|
||||
execute: async ({ summary, tags }) => emitCuratorEvent({ userId: ctx.userId, type: "curator.memory.updated", payload: { summary, tags } }),
|
||||
}),
|
||||
|
||||
read_conversation_context: tool({
|
||||
description: "Read the current conversation history from existing conversation storage.",
|
||||
inputSchema: z.object({ conversationId: z.string().optional() }),
|
||||
execute: async ({ conversationId }) => ({ messages: conversationId || ctx.conversationId ? await listMessagesPg(ctx.userId, conversationId ?? ctx.conversationId!) : [] }),
|
||||
}),
|
||||
|
||||
list_service_capabilities: tool({
|
||||
description: "List deterministic service capabilities from existing service registries.",
|
||||
inputSchema: z.object({}),
|
||||
execute: listCuratorRegistryCapabilities,
|
||||
}),
|
||||
|
||||
list_available_service_routes: tool({
|
||||
description: "Return known handoff routes for registered services.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = taskId ? await findTask(ctx.userId, taskId, ctx.date) : null;
|
||||
return {
|
||||
routes: listServices().map((service) => ({
|
||||
serviceId: service.id,
|
||||
route: serviceRoute({ serviceId: service.id, missionId: task?.missionId, missionInstanceId: task?.missionInstanceId, stageId: task?.stageId, taskId: task?.id }),
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
validate_service_handoff: tool({
|
||||
description: "Validate whether a requested service handoff exists in the registry.",
|
||||
inputSchema: z.object({ serviceId: curatorServiceIdSchema }),
|
||||
execute: async ({ serviceId }) => {
|
||||
const capabilities = await listCuratorRegistryCapabilities();
|
||||
return { valid: capabilities.services.some((service) => service.id === serviceId), serviceId };
|
||||
},
|
||||
}),
|
||||
|
||||
map_task_to_service: tool({
|
||||
description: "Map a curator task to its service capability and handoff route.",
|
||||
inputSchema: z.object({ taskId: z.string() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
return { task, route: task ? serviceRoute({ serviceId: task.serviceId, missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId, taskId }) : null };
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_interview_setup: tool({
|
||||
description: "Prepare an interview setup handoff. This creates a mission action and route, not the full interview workflow.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "interview-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_interview_preview: tool({
|
||||
description: "Prepare the interview preview route after setup context exists.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "interview-service");
|
||||
},
|
||||
}),
|
||||
|
||||
read_interview_report: tool({
|
||||
description: "Read available interview page/report state from the existing interview service.",
|
||||
inputSchema: z.object({ sessionId: z.string().optional() }),
|
||||
execute: async ({ sessionId }) => sessionId ? interviewService.review(sessionId) : interviewService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
list_interview_sessions: tool({
|
||||
description: "List interview service page state for the user.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => interviewService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
get_interview_latest_status: tool({
|
||||
description: "Read the latest interview-related events.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(20).default(5) }),
|
||||
execute: async ({ limit }) => db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.source, "interview"))).orderBy(desc(growEvents.occurredAt)).limit(limit),
|
||||
}),
|
||||
|
||||
prepare_resume_upload: tool({
|
||||
description: "Prepare resume upload or resume builder handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
extract_resume_context: tool({
|
||||
description: "Extract basic context from pasted resume text for curator reasoning.",
|
||||
inputSchema: z.object({ text: z.string().min(1) }),
|
||||
execute: async ({ text }) => ({
|
||||
length: text.length,
|
||||
hasExperience: /experience|work|employment/i.test(text),
|
||||
hasEducation: /education|degree|university|college/i.test(text),
|
||||
hasSkills: /skills|tools|technologies/i.test(text),
|
||||
preview: text.slice(0, 500),
|
||||
}),
|
||||
}),
|
||||
|
||||
read_resume_report: tool({
|
||||
description: "Read existing resume service state.",
|
||||
inputSchema: z.object({ resumeId: z.string().optional() }),
|
||||
execute: async ({ resumeId }) => resumeId ? resumeService.getResume(resumeId) : resumeService.state(ctx.userId),
|
||||
}),
|
||||
|
||||
prepare_resume_rewrite: tool({
|
||||
description: "Prepare a resume rewrite handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_resume_talking_points: tool({
|
||||
description: "Prepare resume talking point handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_resume_gap_scan: tool({
|
||||
description: "Prepare resume gap scan handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_resume_from_interview_evidence: tool({
|
||||
description: "Prepare a resume handoff that carries forward recent interview or roleplay review evidence into the proof task.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
const handoff = await prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
const evidence = await latestInterviewResumeEvidence(ctx.userId);
|
||||
if (!evidence?.carryForward?.length) {
|
||||
const interviewFallback = await prepareHandoffForTask(ctx.userId, task, "interview-service");
|
||||
return {
|
||||
handoff,
|
||||
carryForward: [],
|
||||
requiresInterviewEvidence: true,
|
||||
recommendedNextAction: "No recent interview or roleplay review evidence is available yet. Run an interview rep first so the resume proof can be generated from real conversation evidence.",
|
||||
fallbackHandoff: interviewFallback,
|
||||
};
|
||||
}
|
||||
return {
|
||||
handoff,
|
||||
carryForward: evidence?.carryForward ?? [],
|
||||
sourceEventId: evidence?.eventId,
|
||||
sourceEventType: evidence?.type,
|
||||
sourceService: evidence?.source,
|
||||
sourceOccurredAt: evidence?.occurredAt,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_roleplay_setup: tool({
|
||||
description: "Prepare roleplay setup handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "roleplay-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_roleplay_preview: tool({
|
||||
description: "Prepare roleplay preview handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "roleplay-service");
|
||||
},
|
||||
}),
|
||||
|
||||
suggest_roleplay_scenario: tool({
|
||||
description: "Suggest a roleplay scenario from current task context.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
return { scenario: task?.title ?? "Practice a high-stakes workplace conversation", outcome: "Clear next step and confident response" };
|
||||
},
|
||||
}),
|
||||
|
||||
read_roleplay_report: tool({
|
||||
description: "Read roleplay service report or page state.",
|
||||
inputSchema: z.object({ sessionId: z.string().optional() }),
|
||||
execute: async ({ sessionId }) => sessionId ? roleplayService.review(sessionId) : roleplayService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
list_roleplay_sessions: tool({
|
||||
description: "List roleplay service page state for the user.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => roleplayService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
read_qscore_state: tool({
|
||||
description: "Read current Q-score projection state.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, ctx.userId)).limit(1),
|
||||
}),
|
||||
|
||||
read_qscore_signals: tool({
|
||||
description: "Read latest Q-score signals.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(20) }),
|
||||
execute: async ({ limit }) => db.select().from(growQscoreLatest).where(eq(growQscoreLatest.userId, ctx.userId)).orderBy(desc(growQscoreLatest.updatedAt)).limit(limit),
|
||||
}),
|
||||
|
||||
explain_qscore_movement: tool({
|
||||
description: "Explain recent Q-score movement from available signals.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ state: await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, ctx.userId)).limit(1), signals: await db.select().from(growQscoreLatest).where(eq(growQscoreLatest.userId, ctx.userId)).orderBy(desc(growQscoreLatest.updatedAt)).limit(10) }),
|
||||
}),
|
||||
|
||||
map_task_to_qscore_signals: tool({
|
||||
description: "Map a curator task to the Q-score signals it can affect.",
|
||||
inputSchema: z.object({ taskId: z.string() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
return { taskId, signals: task?.signals ?? [] };
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_qscore_review: tool({
|
||||
description: "Disabled for curator task handoffs; QScore is read-only for dashboard scoring and should not be assigned as a curator task.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async () => ({ error: "qscore_curator_handoff_disabled", replacementServices: ["interview-service", "roleplay-service", "resume-service", "matchmaking-service", "courses-service"] }),
|
||||
}),
|
||||
|
||||
emit_curator_event: tool({
|
||||
description: "Emit a curator event through existing Grow event ingestion.",
|
||||
inputSchema: z.object({ type: z.string(), payload: z.record(z.string(), z.unknown()).default({}) }),
|
||||
execute: async ({ type, payload }) => emitCuratorEvent({ userId: ctx.userId, type, payload }),
|
||||
}),
|
||||
|
||||
read_recent_grow_events: tool({
|
||||
description: "Read recent Grow events for this user.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(20) }),
|
||||
execute: async ({ limit }) => db.select().from(growEvents).where(eq(growEvents.userId, ctx.userId)).orderBy(desc(growEvents.occurredAt)).limit(limit),
|
||||
}),
|
||||
|
||||
find_matching_service_event: tool({
|
||||
description: "Find service events that can complete a task.",
|
||||
inputSchema: z.object({ taskId: z.string() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
if (!task) return { task: null, events: [] };
|
||||
const events = await db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), inArray(growEvents.type as any, task.completionEvents))).orderBy(desc(growEvents.occurredAt)).limit(20);
|
||||
return { task, events };
|
||||
},
|
||||
}),
|
||||
|
||||
complete_task_from_event: tool({
|
||||
description: "Complete a task only when a valid service or platform event exists.",
|
||||
inputSchema: z.object({ taskId: z.string(), eventId: z.string() }),
|
||||
execute: async ({ taskId, eventId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
const [event] = await db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.id, eventId))).limit(1);
|
||||
if (!event || !task.completionEvents.includes(event.type)) return { completed: false, reason: "event_not_allowed" };
|
||||
return emitCuratorEvent({ userId: ctx.userId, type: "curator.task.completed", mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId }, payload: { taskId, sourceEventId: eventId } });
|
||||
},
|
||||
}),
|
||||
|
||||
update_streak_from_completion: tool({
|
||||
description: "Read streak after completion events.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ streak: await buildCuratorStreak(ctx.userId) }),
|
||||
}),
|
||||
|
||||
list_mission_actions: tool({
|
||||
description: "List existing mission actions so the curator does not duplicate handoffs.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => listMissionActions(ctx.userId, { openOnly: false }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -36,9 +36,9 @@ export const curatorWeekPerformanceSchema = z.enum(["Missed", "Okayish", "Avg",
|
||||
export const curatorTaskSchema = z.object({
|
||||
id: z.string(),
|
||||
date: z.string(),
|
||||
dayIndex: z.number().int().min(1).max(30),
|
||||
dayIndex: z.number().int().min(1).max(7),
|
||||
dayIndexInWeek: z.number().int().min(1).max(7),
|
||||
weekIndex: z.number().int().min(1).max(6),
|
||||
weekIndex: z.number().int().min(1).max(1),
|
||||
taskType: curatorTaskTypeSchema,
|
||||
title: z.string(),
|
||||
subtitle: z.string(),
|
||||
@@ -61,19 +61,26 @@ export const curatorTaskSchema = z.object({
|
||||
signals: z.array(z.string()),
|
||||
completionEvents: z.array(z.string()),
|
||||
source: z.enum(["curator-v1", "mission-registry", "service-registry"]),
|
||||
isTrialTask: z.boolean().default(false),
|
||||
isPaidTask: z.boolean().default(true),
|
||||
isRecoveryPractice: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const curatorStreakSchema = z.object({
|
||||
current: z.number().int().min(0),
|
||||
longest: z.number().int().min(0),
|
||||
lastCompletedDate: z.string().nullable(),
|
||||
policy_version: z.literal("qualifying-activity-v1"),
|
||||
timezone: z.literal("Asia/Kolkata"),
|
||||
as_of: z.string(),
|
||||
current_days: z.number().int().min(0),
|
||||
longest_days: z.number().int().min(0),
|
||||
last_completed_on: z.string().nullable(),
|
||||
completed_today: z.boolean(),
|
||||
});
|
||||
|
||||
export const curatorPlanDaySchema = z.object({
|
||||
date: z.string(),
|
||||
dayIndex: z.number().int().min(1).max(30),
|
||||
dayIndex: z.number().int().min(1).max(7),
|
||||
dayIndexInWeek: z.number().int().min(1).max(7),
|
||||
weekIndex: z.number().int().min(1).max(6),
|
||||
weekIndex: z.number().int().min(1).max(1),
|
||||
weekTheme: z.string(),
|
||||
weekSummary: z.string(),
|
||||
focus: z.string().optional(),
|
||||
@@ -81,6 +88,9 @@ export const curatorPlanDaySchema = z.object({
|
||||
plannedServices: z.array(curatorServiceIdSchema).max(4).default([]),
|
||||
generationStatus: z.enum(["seeded", "generated", "adapted"]).default("seeded"),
|
||||
adaptationReason: z.string().optional(),
|
||||
isTrialDay: z.boolean().default(false),
|
||||
isPaidDay: z.boolean().default(true),
|
||||
hasRecoveryPractice: z.boolean().default(false),
|
||||
completedCount: z.number().int().min(0),
|
||||
totalCount: z.number().int().min(0),
|
||||
unlockState: z.enum(["completed", "active", "upcoming"]),
|
||||
@@ -88,12 +98,12 @@ export const curatorPlanDaySchema = z.object({
|
||||
});
|
||||
|
||||
export const curatorWeekSchema = z.object({
|
||||
weekIndex: z.number().int().min(1).max(6),
|
||||
weekIndex: z.number().int().min(1).max(1),
|
||||
title: z.string(),
|
||||
theme: z.string(),
|
||||
summary: z.string(),
|
||||
startDayIndex: z.number().int().min(1).max(30),
|
||||
endDayIndex: z.number().int().min(1).max(30),
|
||||
startDayIndex: z.number().int().min(1).max(7),
|
||||
endDayIndex: z.number().int().min(1).max(7),
|
||||
lifecycle: curatorWeekLifecycleSchema,
|
||||
performance: curatorWeekPerformanceSchema,
|
||||
completedTaskCount: z.number().int().min(0),
|
||||
@@ -109,9 +119,16 @@ export const curatorPlanSchema = z.object({
|
||||
endDate: z.string(),
|
||||
goals: z.array(z.string()),
|
||||
generatedAt: z.string(),
|
||||
durationDays: z.literal(30),
|
||||
weeks: z.array(curatorWeekSchema).min(5).max(6),
|
||||
days: z.array(curatorPlanDaySchema).length(30),
|
||||
durationDays: z.union([z.literal(2), z.literal(7)]),
|
||||
access: z.enum(["trial", "full"]),
|
||||
icpId: z.string(),
|
||||
planFingerprint: z.string(),
|
||||
taskPlan: z.array(z.object({
|
||||
dayIndex: z.number().int().min(1),
|
||||
tasks: z.array(z.object({ id: z.string(), title: z.string() })),
|
||||
})),
|
||||
weeks: z.array(curatorWeekSchema).min(1).max(1),
|
||||
days: z.array(curatorPlanDaySchema).min(2).max(7),
|
||||
streak: curatorStreakSchema,
|
||||
source: z.literal("curator-v1"),
|
||||
});
|
||||
@@ -134,9 +151,9 @@ export const curatorSprintResponseSchema = z.object({
|
||||
sprintId: z.string(),
|
||||
plan: curatorPlanSchema,
|
||||
activeWeek: curatorWeekSchema,
|
||||
activeWeekIndex: z.number().int().min(1).max(6),
|
||||
activeWeekIndex: z.number().int().min(1).max(1),
|
||||
activeDay: curatorPlanDaySchema,
|
||||
activeDayIndex: z.number().int().min(1).max(30),
|
||||
activeDayIndex: z.number().int().min(1).max(7),
|
||||
todayTasks: z.array(curatorTaskSchema).min(3).max(4),
|
||||
streak: curatorStreakSchema,
|
||||
completedCount: z.number().int().min(0),
|
||||
@@ -208,29 +225,3 @@ export type CuratorTodayResponse = {
|
||||
totalCount: number;
|
||||
source: "curator-v1";
|
||||
};
|
||||
|
||||
export type CuratorSubtaskStatusUpdate = {
|
||||
status: "needs_more_context" | "ready_to_capture" | "handoff_ready";
|
||||
summary: string;
|
||||
confidence: number;
|
||||
nextMissingInfo?: string;
|
||||
};
|
||||
|
||||
export type CuratorChatResponse = {
|
||||
conversationId: string;
|
||||
taskId?: string;
|
||||
reply: string;
|
||||
messages: Array<{ id: string; role: "user" | "assistant"; sender: string; content: string; createdAt: number }>;
|
||||
statusUpdate?: CuratorSubtaskStatusUpdate;
|
||||
handoff?: CuratorServiceHandoff;
|
||||
};
|
||||
|
||||
export type CuratorServiceHandoff = {
|
||||
taskId: string;
|
||||
serviceId: CuratorServiceId;
|
||||
route: string;
|
||||
actionId?: string;
|
||||
actionRoute: string;
|
||||
actionLabel: string;
|
||||
status: "prepared";
|
||||
};
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, growQscoreProjectionState } from "../../db/schema.js";
|
||||
import { growEvents, onboarding } from "../../db/schema.js";
|
||||
import { asRecord, getString } from "../../events/envelope.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
|
||||
import { config } from "../../config.js";
|
||||
import type { CuratorTask } from "./curator-types.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const ROLE_PATTERN = /\b(pm|swe|manager|engineer|designer|analyst|developer|product|marketing|sales|founder|consultant|operator|lead|head|director|recruiter|student|intern|data scientist|software)\b/i;
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
@@ -14,13 +20,17 @@ function stringArray(value: unknown): string[] {
|
||||
|
||||
export type CuratorUserContext = {
|
||||
userId: string;
|
||||
targetRole: string;
|
||||
targetRole: string | null;
|
||||
experienceLevel: "student" | "intern" | "early" | "experienced" | "unknown";
|
||||
resume: {
|
||||
available: boolean;
|
||||
latestSummary?: string;
|
||||
skills: string[];
|
||||
};
|
||||
profile: {
|
||||
resumeAvailable: boolean;
|
||||
linkedinAvailable: boolean;
|
||||
};
|
||||
goals: string[];
|
||||
pastActivity: {
|
||||
recentEventCount: number;
|
||||
@@ -123,6 +133,10 @@ export async function resolveCuratorTargetRole(input: {
|
||||
latest?: string;
|
||||
history?: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
}) {
|
||||
const saved = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, input.userId) });
|
||||
const savedRole = getString(asRecord(saved?.payload).target_role);
|
||||
if (savedRole) return savedRole;
|
||||
|
||||
const latestRole = inferRoleFromText(input.latest);
|
||||
if (latestRole) return latestRole;
|
||||
|
||||
@@ -151,10 +165,12 @@ export async function resolveCuratorTargetRole(input: {
|
||||
}
|
||||
|
||||
export function fallbackCuratorRole(role?: string) {
|
||||
return role?.trim() || "Product Manager";
|
||||
return role?.trim() || undefined;
|
||||
}
|
||||
|
||||
export async function buildCuratorUserContext(userId: string): Promise<CuratorUserContext> {
|
||||
const savedOnboarding = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
|
||||
const onboardingPayload = asRecord(savedOnboarding?.payload);
|
||||
const rows = await db
|
||||
.select({ type: growEvents.type, source: growEvents.source, payload: growEvents.payload, occurredAt: growEvents.occurredAt })
|
||||
.from(growEvents)
|
||||
@@ -162,35 +178,41 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(80);
|
||||
|
||||
const [qscore] = await db
|
||||
.select({
|
||||
score: growQscoreProjectionState.score,
|
||||
signalCount: growQscoreProjectionState.signalCount,
|
||||
summary: growQscoreProjectionState.summary,
|
||||
dimensions: growQscoreProjectionState.dimensions,
|
||||
})
|
||||
.from(growQscoreProjectionState)
|
||||
.where(eq(growQscoreProjectionState.userId, userId))
|
||||
.limit(1);
|
||||
const [qscoreResult, resumeState, socialState] = await Promise.all([
|
||||
getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
fetchProfileState(config.resumeServiceUrl, userId),
|
||||
fetchProfileState(config.socialBrandingServiceUrl, userId),
|
||||
]);
|
||||
const breakdown = qscoreResult?.breakdown ?? {};
|
||||
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
|
||||
|
||||
const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId }));
|
||||
const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId })) ?? null;
|
||||
const corpus = rows.map((row) => `${row.type} ${row.source} ${payloadText(row.payload)}`).join(" ").toLowerCase();
|
||||
const goals = uniqueStrings(rows.flatMap((row) => goalsFromPayload(row.payload)));
|
||||
const goals = uniqueStrings([
|
||||
...stringArray(onboardingPayload.goals),
|
||||
...rows.flatMap((row) => goalsFromPayload(row.payload)),
|
||||
]);
|
||||
const skills = uniqueStrings(rows.flatMap((row) => stringArray(asRecord(row.payload).skills))).slice(0, 12);
|
||||
const latestResume = rows
|
||||
const latestResume = getString(resumeState.resume_summary ?? resumeState.resumeSummary) ?? rows
|
||||
.map((row) => resumeSummaryFromPayload(row.payload))
|
||||
.find(Boolean);
|
||||
const resumeAvailable = Number(resumeState.resume_count ?? 0) > 0 || Boolean(latestResume);
|
||||
const linkedinAvailable = Boolean(
|
||||
socialState.linkedin_connected ||
|
||||
getString(socialState.linkedin_profile_id ?? socialState.profile_url ?? socialState.headline ?? socialState.summary),
|
||||
);
|
||||
|
||||
return {
|
||||
userId,
|
||||
targetRole,
|
||||
experienceLevel: inferExperienceLevel(corpus),
|
||||
experienceLevel: experienceLevelFromOnboarding(onboardingPayload) ?? inferExperienceLevel(corpus),
|
||||
resume: {
|
||||
available: Boolean(latestResume || /\bresume|cv|linkedin\b/i.test(corpus)),
|
||||
available: resumeAvailable,
|
||||
latestSummary: latestResume,
|
||||
skills,
|
||||
},
|
||||
goals: goals.length ? goals.slice(0, 8) : [targetRole],
|
||||
profile: { resumeAvailable, linkedinAvailable },
|
||||
goals: goals.length ? goals.slice(0, 8) : targetRole ? [targetRole] : [],
|
||||
pastActivity: {
|
||||
recentEventCount: rows.length,
|
||||
serviceSources: uniqueStrings(rows.map((row) => row.source)).slice(0, 12),
|
||||
@@ -202,14 +224,38 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
|
||||
})),
|
||||
},
|
||||
qscore: {
|
||||
score: typeof qscore?.score === "number" ? qscore.score : null,
|
||||
signalCount: typeof qscore?.signalCount === "number" ? qscore.signalCount : 0,
|
||||
summary: qscore?.summary ?? null,
|
||||
dimensions: qscore?.dimensions ?? null,
|
||||
score: qscoreResult?.q_score ?? null,
|
||||
signalCount: typeof breakdown.signalCount === "number" ? breakdown.signalCount : breakdownSignals.length,
|
||||
summary: typeof breakdown.summary === "string" ? breakdown.summary : null,
|
||||
dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : (qscoreResult ? qscoreResult.quotients : null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchProfileState(baseUrl: string, userId: string): Promise<Record<string, unknown>> {
|
||||
if (!baseUrl) return {};
|
||||
try {
|
||||
const response = await fetch(new URL(`/api/state/${encodeURIComponent(userId)}`, baseUrl), {
|
||||
headers: config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : undefined,
|
||||
signal: AbortSignal.timeout(2_500),
|
||||
});
|
||||
if (!response.ok) return {};
|
||||
const value: unknown = await response.json();
|
||||
return isRecord(value) ? value : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function experienceLevelFromOnboarding(payload: Record<string, unknown>): CuratorUserContext["experienceLevel"] | null {
|
||||
const icp = getString(payload.onboarding_icp);
|
||||
if (icp === "student") return "student";
|
||||
if (icp === "intern") return "intern";
|
||||
if (icp === "fresher") return "early";
|
||||
if (icp === "experienced") return "experienced";
|
||||
return null;
|
||||
}
|
||||
|
||||
function goalsFromPayload(payload: Record<string, unknown>) {
|
||||
const preferences = asRecord(payload.preferences);
|
||||
return [
|
||||
|
||||
72
src/v1/curator/streak-utils.ts
Normal file
72
src/v1/curator/streak-utils.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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 indiaTodayIso(date = new Date()): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Kolkata",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
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 = indiaTodayIso(),
|
||||
): CuratorStreak {
|
||||
// Deduplicate defensively: a repeated day string must never inflate the count.
|
||||
const uniqueDesc = [...new Set(days)];
|
||||
|
||||
const daySet = new Set(uniqueDesc);
|
||||
const completedToday = daySet.has(today);
|
||||
let current = 0;
|
||||
let cursor = completedToday ? today : addDaysIso(today, -1);
|
||||
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 {
|
||||
policy_version: "qualifying-activity-v1",
|
||||
timezone: "Asia/Kolkata",
|
||||
as_of: new Date().toISOString(),
|
||||
current_days: current,
|
||||
longest_days: Math.max(longest, current),
|
||||
last_completed_on: uniqueDesc[0] ?? null,
|
||||
completed_today: completedToday,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type TaskSeed = {
|
||||
subtitle: string;
|
||||
effort: string;
|
||||
qxImpact: string;
|
||||
rewardCoins?: number;
|
||||
cta: string;
|
||||
signals: string[];
|
||||
};
|
||||
@@ -33,6 +34,28 @@ export type CuratorIcpTemplateSet = {
|
||||
weeks: WeekTemplate[];
|
||||
};
|
||||
|
||||
export type TrialDayTemplate = {
|
||||
focus: string;
|
||||
outcome: string;
|
||||
socialTitle: string;
|
||||
socialTask: string;
|
||||
measurementTitle: string;
|
||||
measurementTask: string;
|
||||
proofTitle: string;
|
||||
proofTask: string;
|
||||
practiceTitle: string;
|
||||
practiceTask: string;
|
||||
practiceServiceId: Extract<CuratorServiceId, "interview-service" | "roleplay-service">;
|
||||
};
|
||||
|
||||
export type RecoveryPracticeTemplate = {
|
||||
focus: string;
|
||||
outcome: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
serviceId: Extract<CuratorServiceId, "interview-service" | "roleplay-service">;
|
||||
};
|
||||
|
||||
function unavailableCta(serviceId: CuratorServiceId, fallback: string) {
|
||||
if (
|
||||
serviceId === "social-branding-service" ||
|
||||
@@ -113,6 +136,68 @@ export const CURATOR_TASK_REGISTRY: Record<CuratorIcpId, CuratorIcpTemplateSet>
|
||||
]),
|
||||
};
|
||||
|
||||
export const CURATOR_TRIAL_TASK_REGISTRY: Record<CuratorIcpId, [TrialDayTemplate, TrialDayTemplate]> = {
|
||||
student_recent_grad: [
|
||||
trialDay("Launch Your Journey", "Professional profile created and career readiness baseline established.", "Complete GrowQR Profile", "Complete GrowQR profile and career interests.", "Career Baseline", "Complete Career Assessment and view QX Score.", "First AI Resume", "Create your first AI resume.", "Self-Introduction", "Record a 60-second self-introduction.", "interview-service"),
|
||||
trialDay("Step Into Opportunity", "Ready to apply for internships with improved confidence.", "Add First Proof", "Add one project or certification.", "Review Progress", "Review Dashboard improvements.", "Internship Cover Letter", "Generate a cover letter for an internship.", "Group Discussion", "Practice a group discussion roleplay.", "roleplay-service"),
|
||||
],
|
||||
intern: [
|
||||
trialDay("Build Career Momentum", "Stronger professional positioning and career baseline.", "Internship Profile", "Update internship achievements and LinkedIn headline.", "Career Baseline", "Complete Career Assessment and QX Score.", "Target Role Resume", "Optimize resume for your target role.", "HR Interview", "Complete an HR interview simulation.", "interview-service"),
|
||||
trialDay("Turn Experience Into Opportunity", "Better interview readiness and stronger recruiter profile.", "Publish Achievement", "Publish one internship or project achievement.", "Review Progress", "Review Dashboard progress.", "Role-Specific Resume", "Create a role-specific resume version.", "Manager Conversation", "Practice a workplace manager conversation.", "roleplay-service"),
|
||||
],
|
||||
fresher_early_professional: [
|
||||
trialDay("Get Recruiter Ready", "Resume optimized and recruiter visibility improved.", "Recruiter Profile", "Complete professional profile and LinkedIn summary.", "QX + ATS Baseline", "Complete QX Score and ATS Assessment.", "ATS-Ready Resume", "Generate an ATS-ready resume.", "Recruiter Screening", "Complete a recruiter screening interview.", "interview-service"),
|
||||
trialDay("Apply With Confidence", "Applications submitted with greater confidence.", "Career Milestone", "Publish a career milestone.", "Review Analytics", "Review Dashboard analytics.", "Personalized Cover Letter", "Generate a personalized cover letter.", "Behavioural Interview", "Practice a behavioural interview.", "roleplay-service"),
|
||||
],
|
||||
experienced_professional: [
|
||||
trialDay("Lead Your Next Chapter", "Executive brand established.", "Executive Profile", "Complete executive profile and leadership summary.", "Executive Baseline", "Complete Leadership Assessment and Executive QX Score.", "Executive Resume", "Create an executive resume.", "Executive Introduction", "Practice an executive introduction.", "interview-service"),
|
||||
trialDay("Influence With Impact", "Leadership readiness strengthened.", "Leadership Insight", "Publish a leadership insight.", "Executive Dashboard", "Review Executive Dashboard.", "Executive Portfolio", "Build an executive portfolio.", "Boardroom Simulation", "Practice a boardroom simulation.", "roleplay-service"),
|
||||
],
|
||||
freelancer: [
|
||||
trialDay("Win Your First Client", "Professional freelance identity established.", "Freelancer Profile", "Complete freelancer profile and service catalogue.", "Business Baseline", "Complete Business Readiness Assessment and QX Score.", "Portfolio Upload", "Upload portfolio and service offerings.", "Client Introduction", "Practice a client introduction.", "interview-service"),
|
||||
trialDay("Build Client Trust", "Client-ready portfolio and stronger business confidence.", "Portfolio Proof", "Publish a portfolio item or testimonial.", "Business Dashboard", "Review Business Dashboard.", "Client Cover Letter", "Generate a client-ready cover letter.", "Client Negotiation", "Practice client negotiation roleplay.", "roleplay-service"),
|
||||
],
|
||||
founder: [
|
||||
trialDay("Build Your Venture", "Founder readiness baseline established.", "Founder Profile", "Complete founder profile and startup overview.", "Founder Baseline", "Complete Founder Assessment and QX Score.", "Founder Portfolio", "Create founder portfolio or pitch summary.", "Investor Pitch", "Record an investor pitch.", "interview-service"),
|
||||
trialDay("Pitch. Validate. Grow.", "Investor-ready profile with validated next steps.", "Startup Vision", "Publish startup vision.", "Founder Dashboard", "Review Founder Dashboard.", "Founder Assets", "Upload resume to update it in different formats.", "Customer Discovery", "Practice customer discovery roleplay.", "roleplay-service"),
|
||||
],
|
||||
enterprise: [
|
||||
trialDay("Empower Your Workforce", "Workforce capability baseline established.", "Employer Profile", "Complete employer profile.", "Enterprise Baseline", "Complete Workforce Assessment and Enterprise QX Score.", "Competency Framework", "Upload competency framework.", "Leadership Calibration", "Practice leadership interview calibration.", "interview-service"),
|
||||
trialDay("Lead Workforce Transformation", "AI-powered workforce capability roadmap initiated.", "Employer Branding", "Publish employer branding update.", "Enterprise Dashboard", "Review Enterprise Dashboard.", "Hiring Toolkit", "Generate hiring competency toolkit.", "Leadership Coaching", "Practice leadership coaching simulation.", "roleplay-service"),
|
||||
],
|
||||
};
|
||||
|
||||
export const CURATOR_TRIAL_RECOVERY_REGISTRY: Record<CuratorIcpId, [RecoveryPracticeTemplate, RecoveryPracticeTemplate]> = {
|
||||
student_recent_grad: [
|
||||
recovery("Own Your Introduction", "Improved confidence in introducing yourself during internships and campus interviews.", "Self-Introduction Interview", "Record a 60-second introduction covering your education, interests and career goals. AI provides instant communication feedback.", "interview-service"),
|
||||
recovery("Team Player Mode", "Enhanced communication and collaboration readiness for placements.", "Group Discussion Roleplay", "Participate in a simulated campus group discussion, demonstrating teamwork and communication skills.", "roleplay-service"),
|
||||
],
|
||||
intern: [
|
||||
recovery("Recruiter Ready", "Improved interview performance and stronger recruiter readiness.", "HR Screening Interview", "Complete a recruiter screening interview focused on internship experience, responsibilities and achievements.", "interview-service"),
|
||||
recovery("Workplace Ready", "Greater confidence in workplace communication and professional interactions.", "Manager Conversation Roleplay", "Practice discussing work progress, deadlines and feedback with a reporting manager.", "roleplay-service"),
|
||||
],
|
||||
fresher_early_professional: [
|
||||
recovery("Interview Recharge", "Better behavioural interview performance and higher callback readiness.", "Behavioural Interview", "Answer behavioural interview questions using structured STAR responses and receive AI feedback.", "interview-service"),
|
||||
recovery("Think On Your Feet", "Improved problem-solving and collaboration skills.", "Workplace Collaboration Roleplay", "Simulate working with teammates to resolve a workplace challenge.", "roleplay-service"),
|
||||
],
|
||||
experienced_professional: [
|
||||
recovery("Executive Presence", "Stronger executive interview readiness and leadership communication.", "Executive Leadership Interview", "Complete a leadership interview focused on strategic thinking, business impact and decision-making.", "interview-service"),
|
||||
recovery("Lead The Room", "Increased executive presence and stakeholder management skills.", "Boardroom Strategy Roleplay", "Simulate presenting recommendations during a senior leadership meeting.", "roleplay-service"),
|
||||
],
|
||||
freelancer: [
|
||||
recovery("Win The Client", "Improved client communication and proposal confidence.", "Client Discovery Interview", "Practice introducing your services, asking discovery questions and understanding client needs.", "interview-service"),
|
||||
recovery("Handle The Objection", "Greater confidence in closing freelance opportunities.", "Client Negotiation Roleplay", "Simulate handling pricing objections and negotiating project scope professionally.", "roleplay-service"),
|
||||
],
|
||||
founder: [
|
||||
recovery("Pitch With Purpose", "Refined startup pitch with stronger clarity and impact.", "Founder Pitch Interview", "Deliver a concise investor or customer pitch highlighting the business problem, solution and vision.", "interview-service"),
|
||||
recovery("Validate Your Vision", "Better customer validation and product-market fit understanding.", "Customer Discovery Roleplay", "Practice validating customer problems through realistic founder-customer conversations.", "roleplay-service"),
|
||||
],
|
||||
enterprise: [
|
||||
recovery("Hire Smarter", "Improved interview quality and standardized hiring practices.", "Structured Hiring Interview", "Conduct an AI-guided interview using competency-based questioning for hiring consistency.", "interview-service"),
|
||||
recovery("Coach For Growth", "Enhanced leadership coaching and people management capability.", "Leadership Coaching Roleplay", "Simulate a performance coaching conversation with an employee to improve leadership effectiveness.", "roleplay-service"),
|
||||
],
|
||||
};
|
||||
|
||||
export function seed(
|
||||
taskType: CuratorTaskType,
|
||||
serviceId: CuratorServiceId,
|
||||
@@ -122,8 +207,9 @@ export function seed(
|
||||
qxImpact: string,
|
||||
cta: string,
|
||||
signals: string[],
|
||||
rewardCoins?: number,
|
||||
): TaskSeed {
|
||||
return { taskType, serviceId, title, subtitle, effort, qxImpact, cta: unavailableCta(serviceId, cta), signals };
|
||||
return { taskType, serviceId, title, subtitle, effort, qxImpact, rewardCoins, cta: unavailableCta(serviceId, cta), signals };
|
||||
}
|
||||
|
||||
|
||||
@@ -178,11 +264,209 @@ function day(
|
||||
};
|
||||
}
|
||||
|
||||
function staticWeekTemplate(days: StaticSprintDay[]): WeekTemplate {
|
||||
function trialDay(
|
||||
focus: string,
|
||||
outcome: string,
|
||||
socialTitle: string,
|
||||
socialTask: string,
|
||||
measurementTitle: string,
|
||||
measurementTask: string,
|
||||
proofTitle: string,
|
||||
proofTask: string,
|
||||
practiceTitle: string,
|
||||
practiceTask: string,
|
||||
practiceServiceId: Extract<CuratorServiceId, "interview-service" | "roleplay-service">,
|
||||
): TrialDayTemplate {
|
||||
return {
|
||||
focus,
|
||||
outcome,
|
||||
socialTitle,
|
||||
socialTask,
|
||||
measurementTitle,
|
||||
measurementTask,
|
||||
proofTitle,
|
||||
proofTask,
|
||||
practiceTitle,
|
||||
practiceTask,
|
||||
practiceServiceId,
|
||||
};
|
||||
}
|
||||
|
||||
function recovery(
|
||||
focus: string,
|
||||
outcome: string,
|
||||
title: string,
|
||||
subtitle: string,
|
||||
serviceId: Extract<CuratorServiceId, "interview-service" | "roleplay-service">,
|
||||
): RecoveryPracticeTemplate {
|
||||
return { focus, outcome, title, subtitle, serviceId };
|
||||
}
|
||||
|
||||
type QxDimension = "IQ" | "EQ" | "SQ";
|
||||
|
||||
type QxKpiWeight = {
|
||||
weight: number;
|
||||
dims: QxDimension[];
|
||||
};
|
||||
|
||||
type QxProfile = "Student" | "Prof_Leadership" | "Prof_Creative" | "Entrepreneur";
|
||||
|
||||
function qxProfileForIcp(icpId: CuratorIcpId): QxProfile {
|
||||
if (icpId === "experienced_professional" || icpId === "enterprise") return "Prof_Leadership";
|
||||
if (icpId === "freelancer") return "Prof_Creative";
|
||||
if (icpId === "founder") return "Entrepreneur";
|
||||
return "Student";
|
||||
}
|
||||
|
||||
function qxProfileKpiWeights(profile: QxProfile): Record<number, QxKpiWeight> {
|
||||
const weights: Record<QxProfile, Record<number, QxKpiWeight>> = {
|
||||
Student: {
|
||||
1: { weight: 2.84, dims: ["SQ"] },
|
||||
4: { weight: 1.41, dims: [] },
|
||||
10: { weight: 3.78, dims: [] },
|
||||
12: { weight: 1.89, dims: [] },
|
||||
14: { weight: 4.72, dims: ["IQ"] },
|
||||
18: { weight: 5.66, dims: ["IQ"] },
|
||||
22: { weight: 6.62, dims: ["IQ", "EQ", "SQ"] },
|
||||
23: { weight: 3.78, dims: ["EQ", "SQ"] },
|
||||
25: { weight: 4.25, dims: ["SQ"] },
|
||||
29: { weight: 1.41, dims: ["SQ"] },
|
||||
37: { weight: 2.8, dims: [] },
|
||||
39: { weight: 1.89, dims: ["IQ", "EQ", "SQ"] },
|
||||
41: { weight: 1.89, dims: ["EQ", "SQ"] },
|
||||
},
|
||||
Prof_Leadership: {
|
||||
1: { weight: 3.34, dims: ["SQ"] },
|
||||
5: { weight: 2.5, dims: ["IQ"] },
|
||||
6: { weight: 3.34, dims: ["SQ"] },
|
||||
10: { weight: 2.92, dims: [] },
|
||||
12: { weight: 1.67, dims: [] },
|
||||
14: { weight: 2.5, dims: ["IQ"] },
|
||||
18: { weight: 3.72, dims: ["IQ"] },
|
||||
22: { weight: 4.98, dims: ["IQ", "EQ", "SQ"] },
|
||||
23: { weight: 3.75, dims: ["EQ", "SQ"] },
|
||||
25: { weight: 4.14, dims: ["SQ"] },
|
||||
29: { weight: 1.67, dims: ["SQ"] },
|
||||
37: { weight: 3.4, dims: [] },
|
||||
39: { weight: 1.67, dims: ["IQ", "EQ", "SQ"] },
|
||||
41: { weight: 2.08, dims: ["EQ", "SQ"] },
|
||||
},
|
||||
Prof_Creative: {
|
||||
1: { weight: 2.58, dims: ["SQ"] },
|
||||
4: { weight: 4.27, dims: [] },
|
||||
5: { weight: 3.44, dims: ["IQ"] },
|
||||
6: { weight: 2.58, dims: ["SQ"] },
|
||||
10: { weight: 3.01, dims: [] },
|
||||
12: { weight: 1.72, dims: [] },
|
||||
14: { weight: 3.44, dims: ["IQ"] },
|
||||
18: { weight: 4.27, dims: ["IQ"] },
|
||||
22: { weight: 5.1, dims: ["IQ", "EQ", "SQ"] },
|
||||
23: { weight: 3.44, dims: ["EQ", "SQ"] },
|
||||
25: { weight: 4.27, dims: ["SQ"] },
|
||||
29: { weight: 1.72, dims: ["SQ"] },
|
||||
37: { weight: 2.8, dims: [] },
|
||||
39: { weight: 1.72, dims: ["IQ", "EQ", "SQ"] },
|
||||
41: { weight: 1.72, dims: ["EQ", "SQ"] },
|
||||
},
|
||||
Entrepreneur: {
|
||||
1: { weight: 2.05, dims: ["SQ"] },
|
||||
4: { weight: 2.05, dims: [] },
|
||||
5: { weight: 2.05, dims: ["IQ"] },
|
||||
6: { weight: 2.05, dims: ["SQ"] },
|
||||
10: { weight: 2.05, dims: [] },
|
||||
12: { weight: 1.03, dims: [] },
|
||||
14: { weight: 2.05, dims: ["IQ"] },
|
||||
18: { weight: 3.05, dims: ["IQ"] },
|
||||
22: { weight: 3.87, dims: ["IQ", "EQ", "SQ"] },
|
||||
23: { weight: 2.71, dims: ["EQ", "SQ"] },
|
||||
25: { weight: 3.05, dims: ["SQ"] },
|
||||
29: { weight: 1.71, dims: ["SQ"] },
|
||||
37: { weight: 6, dims: [] },
|
||||
39: { weight: 1.37, dims: ["IQ", "EQ", "SQ"] },
|
||||
41: { weight: 1.37, dims: ["EQ", "SQ"] },
|
||||
},
|
||||
};
|
||||
return weights[profile];
|
||||
}
|
||||
|
||||
function fallbackQxDimsForService(serviceId: CuratorServiceId): QxDimension[] {
|
||||
if (serviceId === "social-branding-service") return ["SQ"];
|
||||
if (serviceId === "qscore-service" || serviceId === "assessment-service" || serviceId === "resume-service" || serviceId === "courses-service") return ["IQ"];
|
||||
if (serviceId === "interview-service") return ["EQ"];
|
||||
if (serviceId === "roleplay-service" || serviceId === "expert-service") return ["EQ", "SQ"];
|
||||
if (serviceId === "matchmaking-service" || serviceId === "events-service") return ["SQ"];
|
||||
return ["IQ"];
|
||||
}
|
||||
|
||||
type StaticTaskRef = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
serviceId: CuratorServiceId;
|
||||
};
|
||||
|
||||
function qxKpiIdForTask(task: StaticTaskRef): number {
|
||||
const text = `${task.title} ${task.subtitle}`.toLowerCase();
|
||||
if (task.serviceId === "social-branding-service") {
|
||||
if (/(publish|share|post|content|insight|article|story|build in public|update)/.test(text)) return 5;
|
||||
if (/(speaking|introduction|pitch|video)/.test(text)) return 6;
|
||||
return 1;
|
||||
}
|
||||
if (task.serviceId === "qscore-service") {
|
||||
if (/(assessment|baseline|know your number|skill gap|capability)/.test(text)) return 39;
|
||||
return 18;
|
||||
}
|
||||
if (task.serviceId === "assessment-service") return 18;
|
||||
if (task.serviceId === "resume-service") {
|
||||
if (text.includes("cover letter")) return 12;
|
||||
if (/(portfolio|project|case study|pitch deck|mvp|proof|showcase|blueprint|framework|playbook|roadmap)/.test(text)) return 4;
|
||||
return 10;
|
||||
}
|
||||
if (task.serviceId === "interview-service") {
|
||||
if (/(introduction|pitch|presentation|video|record|deliver)/.test(text)) return 25;
|
||||
return 22;
|
||||
}
|
||||
if (task.serviceId === "roleplay-service") return 23;
|
||||
if (task.serviceId === "courses-service") return 14;
|
||||
if (task.serviceId === "matchmaking-service") return 37;
|
||||
if (task.serviceId === "expert-service") return 41;
|
||||
if (task.serviceId === "events-service") return 29;
|
||||
return 39;
|
||||
}
|
||||
|
||||
function qxRawWeightForTask(icpId: CuratorIcpId, task: StaticTaskRef): number {
|
||||
const kpiWeights = qxProfileKpiWeights(qxProfileForIcp(icpId));
|
||||
const kpi = kpiWeights[qxKpiIdForTask(task)];
|
||||
const dims = kpi?.dims.length ? kpi.dims : fallbackQxDimsForService(task.serviceId);
|
||||
return kpi?.weight ?? dims.length;
|
||||
}
|
||||
|
||||
function staticTaskRefs(dayItem: StaticSprintDay): StaticTaskRef[] {
|
||||
return [
|
||||
{ title: dayItem.socialTitle, subtitle: dayItem.socialTask, serviceId: dayItem.socialServiceId },
|
||||
{ title: dayItem.measurementTitle, subtitle: dayItem.measurementTask, serviceId: dayItem.measurementServiceId },
|
||||
{ title: dayItem.proofTitle, subtitle: dayItem.proofTask, serviceId: dayItem.proofServiceId },
|
||||
{ title: dayItem.practiceTitle, subtitle: dayItem.practiceTask, serviceId: dayItem.practiceServiceId },
|
||||
];
|
||||
}
|
||||
|
||||
function qxImpactForTask(icpId: CuratorIcpId, days: StaticSprintDay[], task: StaticTaskRef) {
|
||||
const totalRaw = days
|
||||
.flatMap(staticTaskRefs)
|
||||
.reduce((sum, item) => sum + qxRawWeightForTask(icpId, item), 0);
|
||||
const qxDelta = totalRaw > 0 ? (qxRawWeightForTask(icpId, task) / totalRaw) * 100 : 0;
|
||||
const rounded = Math.max(1, Math.round(qxDelta));
|
||||
return { qxImpact: `+${rounded} QX`, rewardCoins: rounded };
|
||||
}
|
||||
|
||||
function staticWeekTemplate(icpId: CuratorIcpId, days: StaticSprintDay[]): WeekTemplate {
|
||||
return {
|
||||
theme: "Static 7-Day CareerSprint",
|
||||
summary: "Static day-wise sprint tasks sourced from the GrowQR task library and 7-day sprint document.",
|
||||
days: days.map((item) => {
|
||||
const socialImpact = qxImpactForTask(icpId, days, { title: item.socialTitle, subtitle: item.socialTask, serviceId: item.socialServiceId });
|
||||
const measurementImpact = qxImpactForTask(icpId, days, { title: item.measurementTitle, subtitle: item.measurementTask, serviceId: item.measurementServiceId });
|
||||
const proofImpact = qxImpactForTask(icpId, days, { title: item.proofTitle, subtitle: item.proofTask, serviceId: item.proofServiceId });
|
||||
const practiceImpact = qxImpactForTask(icpId, days, { title: item.practiceTitle, subtitle: item.practiceTask, serviceId: item.practiceServiceId });
|
||||
return {
|
||||
focus: item.focus,
|
||||
outcome: item.outcome,
|
||||
@@ -192,9 +476,10 @@ function staticWeekTemplate(days: StaticSprintDay[]): WeekTemplate {
|
||||
item.socialTitle,
|
||||
item.socialTask,
|
||||
"10 min",
|
||||
"+6 projected",
|
||||
socialImpact.qxImpact,
|
||||
"Open social proof",
|
||||
["static social proof", item.socialTitle.toLowerCase()],
|
||||
socialImpact.rewardCoins,
|
||||
),
|
||||
proof: seed(
|
||||
"measurement",
|
||||
@@ -202,9 +487,10 @@ function staticWeekTemplate(days: StaticSprintDay[]): WeekTemplate {
|
||||
item.measurementTitle,
|
||||
item.measurementTask,
|
||||
"10 min",
|
||||
"+6 projected",
|
||||
measurementImpact.qxImpact,
|
||||
"Open measurement",
|
||||
["static measurement", item.measurementTitle.toLowerCase()],
|
||||
measurementImpact.rewardCoins,
|
||||
),
|
||||
practice: seed(
|
||||
"proof",
|
||||
@@ -212,9 +498,10 @@ function staticWeekTemplate(days: StaticSprintDay[]): WeekTemplate {
|
||||
item.proofTitle,
|
||||
item.proofTask,
|
||||
"10 min",
|
||||
"+7 projected",
|
||||
proofImpact.qxImpact,
|
||||
"Open proof task",
|
||||
["static proof", item.proofTitle.toLowerCase()],
|
||||
proofImpact.rewardCoins,
|
||||
),
|
||||
roleplay: seed(
|
||||
"practice",
|
||||
@@ -222,9 +509,10 @@ function staticWeekTemplate(days: StaticSprintDay[]): WeekTemplate {
|
||||
item.practiceTitle,
|
||||
item.practiceTask,
|
||||
"10 min",
|
||||
"+10 projected",
|
||||
practiceImpact.qxImpact,
|
||||
"Open practice task",
|
||||
["static practice", item.practiceTitle.toLowerCase()],
|
||||
practiceImpact.rewardCoins,
|
||||
),
|
||||
};
|
||||
}),
|
||||
@@ -243,7 +531,7 @@ function staticIcpTemplate(
|
||||
label,
|
||||
sprintTheme,
|
||||
goal,
|
||||
weeks: [staticWeekTemplate(days)],
|
||||
weeks: [staticWeekTemplate(id, days)],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,3 +543,12 @@ export function normalizeActiveCuratorTaskSeed(task: TaskSeed): TaskSeed {
|
||||
export function templateSetFor(variantId: CuratorIcpId): CuratorIcpTemplateSet {
|
||||
return CURATOR_TASK_REGISTRY[variantId] ?? CURATOR_TASK_REGISTRY.fresher_early_professional;
|
||||
}
|
||||
|
||||
export function trialDaysFor(variantId: CuratorIcpId) {
|
||||
return CURATOR_TRIAL_TASK_REGISTRY[variantId] ?? CURATOR_TRIAL_TASK_REGISTRY.fresher_early_professional;
|
||||
}
|
||||
|
||||
export function trialRecoveryFor(variantId: CuratorIcpId, missedDayIndex: number) {
|
||||
const index = missedDayIndex === 2 ? 1 : 0;
|
||||
return (CURATOR_TRIAL_RECOVERY_REGISTRY[variantId] ?? CURATOR_TRIAL_RECOVERY_REGISTRY.fresher_early_professional)[index];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { requireUser, type AuthContext } from "../../auth/clerk.js";
|
||||
import { applyQscoreProjection } from "../../events/projectors/qscore-projector.js";
|
||||
import { applyServiceSessionProjection } from "../../events/projectors/service-session-projector.js";
|
||||
import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing, recordGrowEvent } from "../../events/record-grow-event.js";
|
||||
import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing, recordGrowEventWithResult } from "../../events/record-grow-event.js";
|
||||
import { ensureOnboardingSideEffectsForEvent } from "../../events/onboarding-ledger.js";
|
||||
|
||||
const eventTrackSchema = z.object({
|
||||
@@ -62,7 +62,7 @@ export function v1EventRoutes() {
|
||||
serviceId: body.serviceId ?? body.service_id,
|
||||
});
|
||||
|
||||
const event = await recordGrowEvent({
|
||||
const { event, inserted } = await recordGrowEventWithResult({
|
||||
id: body.id,
|
||||
source: body.source,
|
||||
type,
|
||||
@@ -78,14 +78,17 @@ export function v1EventRoutes() {
|
||||
dedupeKey: body.dedupeKey ?? body.dedupe_key ?? body.id,
|
||||
}, { userId: authUserId, source: body.source });
|
||||
|
||||
if (event.processingStatus === "processed") {
|
||||
// Dedupe hit: event was already recorded. Return idempotent response with
|
||||
// the existing processingStatus — no in-process re-projection.
|
||||
if (!inserted) {
|
||||
return c.json({
|
||||
eventId: event.id,
|
||||
processingStatus: "processed",
|
||||
processingStatus: event.processingStatus,
|
||||
idempotent: true,
|
||||
}, 202);
|
||||
}
|
||||
|
||||
|
||||
await markGrowEventProcessing(event.id);
|
||||
try {
|
||||
const serviceSession = await applyServiceSessionProjection(event);
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { Hono } from "hono";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { requireUser, type AuthContext } from "../../auth/clerk.js";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
|
||||
|
||||
function groupDimensions(signals: Array<typeof growQscoreLatest.$inferSelect>) {
|
||||
const grouped = new Map<string, { score: number; count: number; sources: Set<string> }>();
|
||||
for (const signal of signals) {
|
||||
const id = signal.signalId.split(".")[0] || "readiness";
|
||||
const current = grouped.get(id) ?? { score: 0, count: 0, sources: new Set<string>() };
|
||||
current.score += signal.score;
|
||||
current.count += 1;
|
||||
if (signal.source) current.sources.add(signal.source);
|
||||
grouped.set(id, current);
|
||||
}
|
||||
return Array.from(grouped.entries()).map(([id, group]) => ({
|
||||
id,
|
||||
label: id.replace(/-/g, " ").replace(/^./, (char) => char.toUpperCase()),
|
||||
score: Math.round(group.score / Math.max(group.count, 1)),
|
||||
signalCount: group.count,
|
||||
sources: Array.from(group.sources),
|
||||
}));
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function numberOr(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function dimensionFromKey(id: string, value: unknown) {
|
||||
const num =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: isRecord(value) && typeof value.score === "number"
|
||||
? value.score
|
||||
: null;
|
||||
if (num === null) return null;
|
||||
const label = id.replace(/-/g, " ").replace(/^./, (char) => char.toUpperCase());
|
||||
const signalCount = isRecord(value) && typeof value.signalCount === "number" ? value.signalCount : 0;
|
||||
const sources =
|
||||
isRecord(value) && Array.isArray(value.sources)
|
||||
? value.sources.filter((s): s is string => typeof s === "string")
|
||||
: [];
|
||||
return { id, label, score: Math.round(num), signalCount, sources };
|
||||
}
|
||||
|
||||
export function v1QscoreRoutes() {
|
||||
@@ -29,18 +33,9 @@ export function v1QscoreRoutes() {
|
||||
|
||||
app.get("/latest", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const [projection] = await db
|
||||
.select()
|
||||
.from(growQscoreProjectionState)
|
||||
.where(eq(growQscoreProjectionState.userId, userId))
|
||||
.limit(1);
|
||||
const signals = await db
|
||||
.select()
|
||||
.from(growQscoreLatest)
|
||||
.where(eq(growQscoreLatest.userId, userId))
|
||||
.orderBy(desc(growQscoreLatest.updatedAt));
|
||||
const result = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
|
||||
|
||||
if (!projection && signals.length === 0) {
|
||||
if (!result) {
|
||||
return c.json({
|
||||
status: "baseline_needed",
|
||||
score: null,
|
||||
@@ -50,33 +45,44 @@ export function v1QscoreRoutes() {
|
||||
explanation: "No onboarding, service completion, or readiness signals have been projected for this user.",
|
||||
signalCount: 0,
|
||||
signals: [],
|
||||
source: "grow_qscore_projection_state",
|
||||
source: "qscore_service",
|
||||
});
|
||||
}
|
||||
|
||||
const score = projection?.score && projection.score > 0
|
||||
? projection.score
|
||||
: Math.round(signals.reduce((sum, signal) => sum + signal.score, 0) / Math.max(signals.length, 1));
|
||||
const lastUpdatedAt = projection?.updatedAt ?? signals[0]?.updatedAt ?? null;
|
||||
const breakdown = result.breakdown;
|
||||
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
|
||||
const dimensions = Object.entries(result.quotients)
|
||||
.map(([id, value]) => dimensionFromKey(id, value))
|
||||
.filter((d): d is { id: string; label: string; score: number; signalCount: number; sources: string[] } => d !== null);
|
||||
const lastUpdatedAt = result.calculated_at || null;
|
||||
|
||||
return c.json({
|
||||
status: "ready",
|
||||
score,
|
||||
dimensions: groupDimensions(signals),
|
||||
trendLabel: signals.length > 1 ? "Updated from recent activity" : "Baseline established",
|
||||
lastUpdatedAt: lastUpdatedAt?.toISOString() ?? null,
|
||||
explanation: projection?.summary ?? `Readiness score computed from ${signals.length} current signal${signals.length === 1 ? "" : "s"}.`,
|
||||
signalCount: projection?.signalCount ?? signals.length,
|
||||
signals: signals.map((signal) => ({
|
||||
signalId: signal.signalId,
|
||||
score: Math.round(signal.score),
|
||||
present: signal.present,
|
||||
source: signal.source,
|
||||
sourceEventId: signal.sourceEventId,
|
||||
occurredAt: signal.occurredAt.toISOString(),
|
||||
updatedAt: signal.updatedAt.toISOString(),
|
||||
})),
|
||||
source: "grow_qscore_projection_state",
|
||||
score: result.q_score,
|
||||
dimensions,
|
||||
trendLabel: breakdownSignals.length > 1 ? "Updated from recent activity" : "Baseline established",
|
||||
lastUpdatedAt,
|
||||
explanation:
|
||||
typeof breakdown.summary === "string"
|
||||
? breakdown.summary
|
||||
: `Readiness score computed from ${breakdownSignals.length} current signal${breakdownSignals.length === 1 ? "" : "s"}.`,
|
||||
signalCount: numberOr(breakdown.signalCount, breakdownSignals.length),
|
||||
iq_score: result.iq_score,
|
||||
eq_score: result.eq_score,
|
||||
sq_score: result.sq_score,
|
||||
signals: breakdownSignals.map((signal) => {
|
||||
const s = isRecord(signal) ? signal : {};
|
||||
return {
|
||||
signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "",
|
||||
score: typeof s.score === "number" ? Math.round(s.score) : 0,
|
||||
present: typeof s.present === "boolean" ? s.present : true,
|
||||
source: typeof s.source === "string" ? s.source : "",
|
||||
sourceEventId: typeof s.sourceEventId === "string" ? s.sourceEventId : typeof s.source_event_id === "string" ? s.source_event_id : null,
|
||||
occurredAt: typeof s.occurredAt === "string" ? s.occurredAt : result.calculated_at,
|
||||
updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : result.calculated_at,
|
||||
};
|
||||
}),
|
||||
source: "qscore_service",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user