PRM-71 Backend QA Curator streak loop #10
154
docs/qa/prm-71-backend-qa-evidence.md
Normal file
154
docs/qa/prm-71-backend-qa-evidence.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# PRM-71 Backend QA Evidence
|
||||
|
||||
This file keeps the PRM-71 backend QA proof inside the backend PR. The checks below were run against the real deployed API at `https://app.sai-onchain.me/api/growqr`, not against mocks or fallback-only fixtures.
|
||||
|
||||
## Deployed Target
|
||||
|
||||
- Public backend base: `https://app.sai-onchain.me/api/growqr`
|
||||
- Local backend base on VPS: `http://127.0.0.1:4000`
|
||||
- Branch: `prm-71-backend-qa-curator-streak-loop`
|
||||
- Runtime implementation commit verified: `0bfc18305bd2462fc7c0fcbfb2a3f5cd76df3f9d`
|
||||
- PR: `https://git.openputer.com/growqr-app/growqr-backend/pulls/10`
|
||||
|
||||
## Service Commit SHAs
|
||||
|
||||
- `growqr-backend`: `0bfc18305bd2462fc7c0fcbfb2a3f5cd76df3f9d`
|
||||
- `growqr-dashboard`: `c4e79d7a17767a083f19f02ba1ca4065f1d415d7`
|
||||
- `interview-service`: `61b238b00463bc3a1e283bf3b850c97279d94ece`
|
||||
- `roleplay-service`: `b4a4913df28c00985578e3af5f1a95e12cf4260e`
|
||||
- `resume-service`: `ebcc6e0826c2e7762251080b6365ebb6b5439c93`
|
||||
- `qscore-service`: `058903f9686067398640a6a56aebce0b57408ccb`
|
||||
- `matchmaking-service`: `e36e831794cccb0e176df4e9113ab1957d4c3612`
|
||||
- `courses-service`: `f702728247bb4e66edf4552d792d25825ceb44fe`
|
||||
- `assessment-service`: `d2885ad2c83c86a95b6a8d9a46dafe5415678422`
|
||||
- `pathways-service`: `b20abed9d7a5fb9c68804b986a9d46a1015d54af`
|
||||
- `social-branding-service`: `98463cdcf75f720a3035c2954b2a847956df24f2`
|
||||
|
||||
## Health Proof
|
||||
|
||||
- Backend container: `growqr-backend Up ... (healthy)`
|
||||
- Local backend health: `GET http://127.0.0.1:4000/healthz` returned `{"ok":true}`
|
||||
- Public API health was exercised through authenticated real API calls at `https://app.sai-onchain.me/api/growqr/...`
|
||||
- Gateway health passed for `interview`, `roleplay`, `resume`, and `social`
|
||||
- Direct declared health paths passed for `qscore-service`, `matchmaking-service`, `courses-service`, `assessment-service`, and `pathways-service`
|
||||
|
||||
## Real API Evidence Users
|
||||
|
||||
- Full evidence flow user: `qa-prm71-full-flow-1782248569`
|
||||
- Full handoff sample user: `qa-prm71-handoffs-1782248569`
|
||||
- Final battle-test flow user: `qa-prm71-battle-flow-1782248509`
|
||||
- Final battle-test all-complete user: `qa-prm71-battle-complete-1782248509`
|
||||
|
||||
## API Contract Evidence
|
||||
|
||||
The full evidence run captured:
|
||||
|
||||
- `GET /v1/curator/today?date=2026-06-23` for a fresh test seeker
|
||||
- `POST /v1/curator/tasks/:taskId/handoff` samples for:
|
||||
- `interview-service`
|
||||
- `roleplay-service`
|
||||
- `resume-service`
|
||||
- `qscore-service`
|
||||
- `POST /v1/events/track` sample payloads for:
|
||||
- `service.started`
|
||||
- `service.abandoned`
|
||||
- `service.completed`
|
||||
- `GET /v1/qscore/latest` before and after completion
|
||||
- `GET /v1/analytics/insight-snapshot` before and after completion
|
||||
- `GET /v1/analytics/activity-history` after event ingestion
|
||||
|
||||
The battle-test run additionally checked auth rejection, malformed event rejection, idempotent duplicate event replay, cross-user isolation, large activity-history limit clamping, all-complete Day 1 behavior, and recovery Day 2 behavior.
|
||||
|
||||
## Day 1 To Day 2 Replan Proof
|
||||
|
||||
Fresh seeker flow:
|
||||
|
||||
- Day 1 returned exactly 3 tasks: `measurement`, `proof`, `practice`
|
||||
- A practice handoff recorded `task.opened`
|
||||
- Real event payloads recorded `service.started` and `service.abandoned`
|
||||
- Day 2 returned 4 tasks with a `recovery` task
|
||||
- Day 1 statuses after replan included `skipped`, `skipped`, and `abandoned`
|
||||
- Adaptation reason: `day 1 incomplete: 1 abandoned/partial, 2 skipped`
|
||||
|
||||
All-complete control flow:
|
||||
|
||||
- Day 1 tasks were completed with real `service.completed` events
|
||||
- Duplicate completion replays returned idempotent responses
|
||||
- Day 2 did not include a recovery task
|
||||
- Day 1 statuses were all `completed`
|
||||
|
||||
## QScore And Analytics Proof
|
||||
|
||||
- QScore before completion: `null` / `baseline_needed`
|
||||
- QScore after completion: `89` / `ready`
|
||||
- Analytics roleFit before completion: `baseline_needed`
|
||||
- Analytics roleFit after completion: `strong` with score `89`
|
||||
- Follow-up battle test verified a scored `service.completed` event updates QScore/readiness state, closing the earlier gap where generic scored completions could process without moving QScore.
|
||||
|
||||
## Event Storage Proof
|
||||
|
||||
Database proof for the full evidence flow:
|
||||
|
||||
```text
|
||||
curator.day.opened|pending|4
|
||||
curator.onboarding_plan.ready|pending|1
|
||||
curator.sprint.started|pending|1
|
||||
service.abandoned|processed|1
|
||||
service.completed|processed|1
|
||||
service.started|processed|1
|
||||
task.opened|pending|2
|
||||
```
|
||||
|
||||
API proof was also captured through `GET /v1/analytics/activity-history`, which returned the ingested event stream for the test seeker.
|
||||
|
||||
## Battle-Test Checklist
|
||||
|
||||
Final battle-test result on the deployed real API: `23/23` checks passed.
|
||||
|
||||
- [x] Public health endpoint is reachable
|
||||
- [x] Protected endpoint rejects missing auth
|
||||
- [x] Event contract rejects missing type/action
|
||||
- [x] Fresh QScore is `baseline_needed`
|
||||
- [x] Fresh analytics roleFit is `baseline_needed`
|
||||
- [x] Onboarding run succeeds
|
||||
- [x] Day 1 returns three frontend-consumable tasks
|
||||
- [x] Day 1 tasks include service routing metadata
|
||||
- [x] Curator handoff succeeds
|
||||
- [x] `service.started` processes
|
||||
- [x] Duplicate started event is idempotent
|
||||
- [x] `service.abandoned` processes
|
||||
- [x] Day 2 adds recovery after abandoned Day 1
|
||||
- [x] Day 1 statuses reflect skipped/abandoned work
|
||||
- [x] `service.completed` processes
|
||||
- [x] Duplicate completed event is idempotent
|
||||
- [x] QScore updates after real completion
|
||||
- [x] Analytics updates after real completion
|
||||
- [x] Activity history clamps large limits
|
||||
- [x] Duplicate completed event is stored only once
|
||||
- [x] All-complete Day 1 has no recovery on Day 2
|
||||
- [x] All-complete Day 1 statuses are completed
|
||||
- [x] Payload `userId` cannot write into another user's stream
|
||||
|
||||
## Rollback Notes
|
||||
|
||||
If the deployed VPS backend must be rolled back to staging:
|
||||
|
||||
```bash
|
||||
cd /opt/growqr/growqr-backend
|
||||
git fetch origin --prune
|
||||
git checkout staging
|
||||
git reset --hard origin/staging
|
||||
docker compose up -d --build backend
|
||||
curl -fsS http://127.0.0.1:4000/healthz
|
||||
```
|
||||
|
||||
Revert alternative from the PR branch:
|
||||
|
||||
```bash
|
||||
git revert $(git rev-list --reverse origin/staging..HEAD)
|
||||
docker compose up -d --build backend
|
||||
```
|
||||
|
||||
## Current Formal Caveat
|
||||
|
||||
PRM-71's real API/backend production-slice evidence is satisfied by this PR and the deployed checks above. The Linear parent DoD also says grouped backend child issues must be merged/deployed or explicitly deferred with owner approval. At the time of this evidence pass, the PRM-71 parent has PR #10 attached and several grouped child Linear issues are still not formally marked done in Linear. This PR therefore provides the deployed PRM-71 proof, while final parent closure still depends on the owner's desired handling of those child issue statuses.
|
||||
@@ -113,11 +113,57 @@ function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
|
||||
return signals;
|
||||
}
|
||||
|
||||
function sourceSignalPrefix(source: string) {
|
||||
return source
|
||||
.toLowerCase()
|
||||
.replace(/-service$/, "")
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "service";
|
||||
}
|
||||
|
||||
function extractScoredServiceSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
const status = String(review.status ?? payload.status ?? "");
|
||||
const isCompletion =
|
||||
event.type.includes("completed") ||
|
||||
event.type.includes("updated") ||
|
||||
event.type.includes("signal_projected") ||
|
||||
status === "completed";
|
||||
if (!isCompletion) return [];
|
||||
|
||||
const score = getNumber(
|
||||
payload.score ??
|
||||
payload.qscore ??
|
||||
payload.q_score ??
|
||||
payload.readiness_score ??
|
||||
payload.overall_score ??
|
||||
review.score ??
|
||||
review.qscore ??
|
||||
review.q_score ??
|
||||
review.readiness_score ??
|
||||
review.overall_score,
|
||||
);
|
||||
if (score === undefined) return [];
|
||||
|
||||
const prefix = sourceSignalPrefix(event.source);
|
||||
return [
|
||||
signal(`${prefix}.service_completion_score`, score, {
|
||||
eventId: event.id,
|
||||
source: event.source,
|
||||
type: event.type,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
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("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 })];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { Hono } from "hono";
|
||||
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 { v1AnalyticsActor } from "./analytics-actor.js";
|
||||
|
||||
function daysAgo(days: number) {
|
||||
return new Date(Date.now() - days * 86400000);
|
||||
}
|
||||
|
||||
function sourceBucket(source: string) {
|
||||
if (source.includes("interview")) return "interview";
|
||||
if (source.includes("roleplay")) return "roleplay";
|
||||
if (source.includes("resume")) return "resume";
|
||||
if (source.includes("qscore")) return "qscore";
|
||||
if (source.includes("curator")) return "curator";
|
||||
if (source.includes("match")) return "opportunities";
|
||||
return source || "unknown";
|
||||
}
|
||||
|
||||
export function v1AnalyticsRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
@@ -19,6 +36,98 @@ export function v1AnalyticsRoutes() {
|
||||
return c.json(await v1AnalyticsActor.getUserActivity({ userId }));
|
||||
});
|
||||
|
||||
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 recentEvents = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14))))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(100);
|
||||
const [counts] = await db
|
||||
.select({
|
||||
total: sql<number>`count(*)::int`,
|
||||
completed: sql<number>`count(*) filter (where ${growEvents.type} ilike '%completed%' or ${growEvents.type} ilike '%review_completed%')::int`,
|
||||
opened: sql<number>`count(*) filter (where ${growEvents.type} = 'task.opened' or ${growEvents.type} ilike '%started%')::int`,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14))));
|
||||
|
||||
const serviceCounts = new Map<string, number>();
|
||||
for (const event of recentEvents) {
|
||||
const bucket = sourceBucket(event.source);
|
||||
serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + 1);
|
||||
}
|
||||
const score = projection?.score ?? null;
|
||||
const strongestSignal = [...latestSignals].sort((a, b) => b.score - a.score)[0];
|
||||
const weakestSignal = [...latestSignals].sort((a, b) => a.score - b.score)[0];
|
||||
|
||||
return c.json({
|
||||
roleFit: {
|
||||
score,
|
||||
label: score === null ? "baseline_needed" : score >= 75 ? "strong" : score >= 55 ? "building" : "needs_focus",
|
||||
strongestSignal: strongestSignal?.signalId ?? null,
|
||||
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.",
|
||||
},
|
||||
activity: {
|
||||
totalEvents14d: counts?.total ?? 0,
|
||||
completedEvents14d: counts?.completed ?? 0,
|
||||
openedEvents14d: counts?.opened ?? 0,
|
||||
services: Array.from(serviceCounts.entries()).map(([service, count]) => ({ service, count })),
|
||||
},
|
||||
opportunities: {
|
||||
events14d: recentEvents.filter((event) => sourceBucket(event.source) === "opportunities").length,
|
||||
latestEventAt: recentEvents.find((event) => sourceBucket(event.source) === "opportunities")?.occurredAt.toISOString() ?? null,
|
||||
},
|
||||
source: "grow_events",
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/activity-history", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const limit = Math.min(200, Math.max(1, Number(c.req.query("limit") ?? 80)));
|
||||
const since = c.req.query("since");
|
||||
const sinceDate = since ? new Date(since) : daysAgo(30);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, Number.isNaN(sinceDate.getTime()) ? daysAgo(30) : sinceDate)))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(limit);
|
||||
return c.json({
|
||||
events: rows.map((event) => ({
|
||||
id: event.id,
|
||||
source: event.source,
|
||||
type: event.type,
|
||||
category: event.category,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
processingStatus: event.processingStatus,
|
||||
mission: event.mission,
|
||||
subject: event.subject,
|
||||
correlation: event.correlation,
|
||||
payload: event.payload,
|
||||
})),
|
||||
count: rows.length,
|
||||
source: "grow_events",
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/nightly/run", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
import { buildCuratorTaskDeepLink, completionEventsForService, serviceName, serviceToolName } from "./curator-service-links.js";
|
||||
|
||||
const VALID_COMPLETION_TYPES = [
|
||||
"service.completed",
|
||||
"resume.analysis_completed",
|
||||
"resume.parsed",
|
||||
"resume.updated",
|
||||
@@ -82,7 +83,7 @@ type PlanDaySeed = {
|
||||
weekTheme: string;
|
||||
weekSummary: string;
|
||||
focus: string;
|
||||
plannedTasks: [PlannedTask, PlannedTask, PlannedTask];
|
||||
plannedTasks: PlannedTask[];
|
||||
generationStatus: "seeded" | "generated" | "adapted";
|
||||
adaptationReason?: string;
|
||||
};
|
||||
@@ -873,6 +874,7 @@ function planWeekCount(startDate: string) {
|
||||
function planFocus(weekTheme: string, seedTask: TaskSeed) {
|
||||
if (seedTask.taskType === "measurement") return `Measure today against the ${weekTheme.toLowerCase()} theme.`;
|
||||
if (seedTask.taskType === "proof") return `Turn progress from ${weekTheme.toLowerCase()} into visible proof.`;
|
||||
if (seedTask.taskType === "recovery") return `Recover momentum from the last incomplete day before adding more work.`;
|
||||
return `Practice one concrete move that advances ${weekTheme.toLowerCase()}.`;
|
||||
}
|
||||
|
||||
@@ -1058,6 +1060,13 @@ function performanceLabel(percent: number): CuratorWeek["performance"] {
|
||||
}
|
||||
|
||||
function subtaskCopy(seedTask: TaskSeed, weekTheme: string) {
|
||||
if (seedTask.taskType === "recovery") {
|
||||
return [
|
||||
"Review the missed or abandoned task trail",
|
||||
"Choose the smallest useful recovery action",
|
||||
`Save the next move for ${weekTheme.toLowerCase()}`,
|
||||
];
|
||||
}
|
||||
if (seedTask.taskType === "measurement") {
|
||||
return [
|
||||
"Open the current score or readiness view",
|
||||
@@ -1080,6 +1089,13 @@ function subtaskCopy(seedTask: TaskSeed, weekTheme: string) {
|
||||
}
|
||||
|
||||
function contextNarrative(seedTask: TaskSeed, weekTheme: string, weekSummary: string) {
|
||||
if (seedTask.taskType === "recovery") {
|
||||
return [
|
||||
`This recovery task appears because the previous curator day was not fully completed.`,
|
||||
weekSummary,
|
||||
"Use the linked service to convert the missed, partial, or abandoned work into a constructive next action.",
|
||||
].join(" ");
|
||||
}
|
||||
return [
|
||||
`This ${seedTask.taskType} task belongs to the ${weekTheme} week of the curator sprint.`,
|
||||
weekSummary,
|
||||
@@ -1123,7 +1139,7 @@ function templateSetFor(variantId: CuratorIcpId) {
|
||||
function clonePlanDay(day: PlanDaySeed): PlanDaySeed {
|
||||
return {
|
||||
...day,
|
||||
plannedTasks: day.plannedTasks.map((task) => ({ ...task })) as PlanDaySeed["plannedTasks"],
|
||||
plannedTasks: day.plannedTasks.map((task) => ({ ...task })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1203,7 +1219,13 @@ async function loadSprintState(userId: string, todayDate = todayIso()): Promise<
|
||||
|
||||
async function loadRecentContextRows(userId: string, sinceDate: string) {
|
||||
return db
|
||||
.select({ type: growEvents.type, source: growEvents.source, payload: growEvents.payload, occurredAt: growEvents.occurredAt })
|
||||
.select({
|
||||
type: growEvents.type,
|
||||
source: growEvents.source,
|
||||
payload: growEvents.payload,
|
||||
correlation: growEvents.correlation,
|
||||
occurredAt: growEvents.occurredAt,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, new Date(`${sinceDate}T00:00:00.000Z`))))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
@@ -1235,8 +1257,18 @@ function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows:
|
||||
return rows.some((row) => {
|
||||
const payload = row.payload ?? {};
|
||||
const correlation = row.correlation ?? {};
|
||||
const eventTaskId = payload.taskId ?? correlation.taskId;
|
||||
return eventTaskId === taskId && (row.type === "curator.task.completed" || completionEvents.includes(row.type));
|
||||
const eventTaskId =
|
||||
payload.taskId ??
|
||||
payload.curatorTaskId ??
|
||||
payload.curator_task_id ??
|
||||
correlation.taskId ??
|
||||
correlation.curatorTaskId ??
|
||||
correlation.curator_task_id;
|
||||
return eventTaskId === taskId && (
|
||||
row.type === "curator.task.completed" ||
|
||||
row.type === "service.completed" ||
|
||||
completionEvents.includes(row.type)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1248,6 +1280,71 @@ function hasRecentServiceSignal(rows: Awaited<ReturnType<typeof loadRecentContex
|
||||
return rows.some((row) => pattern.test(`${row.source} ${row.type} ${eventText(row.payload)}`.toLowerCase()));
|
||||
}
|
||||
|
||||
function taskEventId(row: Pick<Awaited<ReturnType<typeof loadRecentContextRows>>[number], "payload" | "correlation">) {
|
||||
const payload = row.payload ?? {};
|
||||
const correlation = row.correlation ?? {};
|
||||
const value =
|
||||
payload.taskId ??
|
||||
payload.curatorTaskId ??
|
||||
payload.curator_task_id ??
|
||||
correlation.taskId ??
|
||||
correlation.curatorTaskId ??
|
||||
correlation.curator_task_id;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function rowsForTask(taskId: string, rows: Awaited<ReturnType<typeof loadRecentContextRows>>) {
|
||||
return rows.filter((row) => taskEventId(row) === taskId);
|
||||
}
|
||||
|
||||
function taskOpenedOrStarted(taskRows: Awaited<ReturnType<typeof loadRecentContextRows>>) {
|
||||
return taskRows.some((row) => (
|
||||
row.type === "task.opened" ||
|
||||
row.type === "curator.task.started" ||
|
||||
row.type === "curator.service_handoff.opened" ||
|
||||
row.type.includes("started") ||
|
||||
row.type.includes("configured") ||
|
||||
row.type.includes("abandoned") ||
|
||||
row.payload?.status === "abandoned"
|
||||
));
|
||||
}
|
||||
|
||||
function taskHandoffPrepared(taskRows: Awaited<ReturnType<typeof loadRecentContextRows>>) {
|
||||
return taskRows.some((row) => row.type === "task.opened" || row.type === "curator.service_handoff.opened");
|
||||
}
|
||||
|
||||
function classifyTaskStatus(input: {
|
||||
task: CuratorTask;
|
||||
focusDate: string;
|
||||
completionRows: Awaited<ReturnType<typeof loadCompletionRows>>;
|
||||
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>;
|
||||
}): CuratorTask["status"] {
|
||||
if (taskCompletedByEvents(input.task, input.completionRows)) return "completed";
|
||||
const taskRows = rowsForTask(input.task.id, input.recentRows);
|
||||
if (input.task.date < input.focusDate) {
|
||||
return taskOpenedOrStarted(taskRows) ? "abandoned" : "skipped";
|
||||
}
|
||||
if (taskHandoffPrepared(taskRows)) return "handoff_prepared";
|
||||
if (taskOpenedOrStarted(taskRows)) return "started";
|
||||
return "ready";
|
||||
}
|
||||
|
||||
function recoveryTaskSeed(previousDayIndex: number, openedIncompleteCount: number, skippedCount: number): TaskSeed {
|
||||
const reason = openedIncompleteCount > 0
|
||||
? `${openedIncompleteCount} opened task${openedIncompleteCount === 1 ? "" : "s"} did not produce completion events`
|
||||
: `${skippedCount} planned task${skippedCount === 1 ? "" : "s"} had no opened or completion events`;
|
||||
return seed(
|
||||
"recovery",
|
||||
"qscore-service",
|
||||
`Recover Day ${previousDayIndex} momentum`,
|
||||
`Yesterday's event trail shows ${reason}. Review the blocker, pick one constructive adjustment, and keep the streak aligned.`,
|
||||
"5 min",
|
||||
"+5 projected",
|
||||
"Review Q Score",
|
||||
["recovery", "alignment", "event trail"],
|
||||
);
|
||||
}
|
||||
|
||||
function adaptCurrentDayPlan(
|
||||
sprintStartDate: string,
|
||||
focusDayIndex: number,
|
||||
@@ -1277,7 +1374,7 @@ function adaptCurrentDayPlan(
|
||||
reasons.push("resume baseline missing");
|
||||
}
|
||||
|
||||
if (!hasInterviewSignal && !hasRoleplaySignal && focusDayIndex >= 3 && current.plannedTasks[2].serviceId === "matchmaking-service") {
|
||||
if (!hasInterviewSignal && !hasRoleplaySignal && focusDayIndex >= 3 && current.plannedTasks[2]?.serviceId === "matchmaking-service") {
|
||||
current.plannedTasks[2] = makePlannedTask(
|
||||
seed("practice", "interview-service", "Run a real interview warm-up rep", "Start a live interview-style practice so the sprint captures real readiness movement, not just planning.", "10 min", "+10 projected", "Open interview preview", ["interview warm-up", "first rep"]),
|
||||
current.weekTheme,
|
||||
@@ -1302,14 +1399,32 @@ function adaptCurrentDayPlan(
|
||||
)).length;
|
||||
|
||||
if (previousCompleted < 3) {
|
||||
const previousClassifications = previous.plannedTasks.map((task) => {
|
||||
const taskId = taskIdFor(sprintStartDate, previous.dayIndex, task.taskType);
|
||||
const taskRows = rowsForTask(taskId, recentRows);
|
||||
return {
|
||||
taskId,
|
||||
opened: taskOpenedOrStarted(taskRows),
|
||||
completed: taskCompletedByTaskId(taskId, completionEventsForService(task.serviceId), completionRows),
|
||||
};
|
||||
});
|
||||
const openedIncompleteCount = previousClassifications.filter((item) => item.opened && !item.completed).length;
|
||||
const skippedCount = previousClassifications.filter((item) => !item.opened && !item.completed).length;
|
||||
current.plannedTasks[0] = makePlannedTask(
|
||||
seed("measurement", "qscore-service", `Review what blocked Day ${previous.dayIndex}`, "Check the strongest blocker from yesterday before generating more blind work.", "5 min", "+5 projected", "Review Q Score", ["blocker review", "momentum"]),
|
||||
current.weekTheme,
|
||||
current.weekSummary,
|
||||
);
|
||||
if (!current.plannedTasks.some((task) => task.taskType === "recovery")) {
|
||||
current.plannedTasks.push(makePlannedTask(
|
||||
recoveryTaskSeed(previous.dayIndex, openedIncompleteCount, skippedCount),
|
||||
current.weekTheme,
|
||||
current.weekSummary,
|
||||
));
|
||||
}
|
||||
current.focus = `Review what blocked Day ${previous.dayIndex}, then continue with today’s proof and practice plan.`;
|
||||
adapted = true;
|
||||
reasons.push(`day ${previous.dayIndex} incomplete`);
|
||||
reasons.push(`day ${previous.dayIndex} incomplete: ${openedIncompleteCount} abandoned/partial, ${skippedCount} skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1342,7 +1457,7 @@ function adaptCurrentDayPlan(
|
||||
current.generationStatus = "generated";
|
||||
}
|
||||
|
||||
current.plannedTasks = current.plannedTasks.map((task) => normalizeEventBackedTask(task, current, recentRows)) as PlanDaySeed["plannedTasks"];
|
||||
current.plannedTasks = current.plannedTasks.map((task) => normalizeEventBackedTask(task, current, recentRows));
|
||||
|
||||
plan[focusDayIndex - 1] = current;
|
||||
return plan;
|
||||
@@ -1357,6 +1472,8 @@ function buildTask(
|
||||
weekSummary: string,
|
||||
seedTask: TaskSeed,
|
||||
completionRows: Awaited<ReturnType<typeof loadCompletionRows>>,
|
||||
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
|
||||
focusDate: string,
|
||||
targetRole?: string,
|
||||
): CuratorTask {
|
||||
const dayOfWeek = dayIndexInWeek(sprintStartDate, dayIndex);
|
||||
@@ -1380,7 +1497,7 @@ function buildTask(
|
||||
actorName: "Curator sprint planner",
|
||||
toolName: serviceToolName(seedTask.serviceId),
|
||||
status: "ready",
|
||||
rewardCoins: seedTask.taskType === "measurement" ? 12 : seedTask.taskType === "proof" ? 15 : 18,
|
||||
rewardCoins: seedTask.taskType === "measurement" ? 12 : seedTask.taskType === "proof" ? 15 : seedTask.taskType === "recovery" ? 8 : 18,
|
||||
qxImpact: seedTask.qxImpact,
|
||||
effort: seedTask.effort,
|
||||
route: "",
|
||||
@@ -1401,7 +1518,7 @@ function buildTask(
|
||||
const taskWithRoute = {
|
||||
...task,
|
||||
route: buildCuratorTaskDeepLink(task, targetRole),
|
||||
status: taskCompletedByEvents(task, completionRows) ? "completed" as const : "ready" as const,
|
||||
status: classifyTaskStatus({ task, focusDate, completionRows, recentRows }),
|
||||
} satisfies CuratorTask;
|
||||
return taskWithRoute;
|
||||
}
|
||||
@@ -1411,6 +1528,8 @@ function buildTasksForPlanDay(
|
||||
date: string,
|
||||
planDay: PlanDaySeed,
|
||||
completionRows: Awaited<ReturnType<typeof loadCompletionRows>>,
|
||||
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
|
||||
focusDate: string,
|
||||
targetRole?: string,
|
||||
) {
|
||||
return planDay.plannedTasks.map((task) => (
|
||||
@@ -1423,6 +1542,8 @@ function buildTasksForPlanDay(
|
||||
planDay.weekSummary,
|
||||
task,
|
||||
completionRows,
|
||||
recentRows,
|
||||
focusDate,
|
||||
targetRole,
|
||||
)
|
||||
));
|
||||
@@ -1446,7 +1567,7 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom
|
||||
);
|
||||
const planDay = adaptedPlanDays[dayIndex - 1];
|
||||
if (!planDay) return [];
|
||||
return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, userContext.targetRole);
|
||||
return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, date, userContext.targetRole);
|
||||
}
|
||||
|
||||
export async function buildCuratorStreak(userId: string): Promise<CuratorStreak> {
|
||||
@@ -1505,7 +1626,9 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()
|
||||
const dayIndex = index + 1;
|
||||
const date = addDaysIso(sprintStartDate, index);
|
||||
const planDay = planDays[index]!;
|
||||
const tasks = date <= focusDate ? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, userContext.targetRole) : [];
|
||||
const tasks = date <= focusDate
|
||||
? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, focusDate, userContext.targetRole)
|
||||
: [];
|
||||
const completedCount = tasks.filter((task) => task.status === "completed").length;
|
||||
const unlockState = focusDate > date ? "completed" : focusDate === date ? "active" : "upcoming";
|
||||
days.push({
|
||||
@@ -1520,7 +1643,7 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()
|
||||
generationStatus: planDay.generationStatus,
|
||||
adaptationReason: planDay.adaptationReason,
|
||||
completedCount,
|
||||
totalCount: 3,
|
||||
totalCount: planDay.plannedTasks.length,
|
||||
unlockState,
|
||||
tasks,
|
||||
});
|
||||
@@ -1622,7 +1745,7 @@ export async function buildServiceCurationPreview(input: ServiceCurationPreviewI
|
||||
return {
|
||||
...planDay,
|
||||
date,
|
||||
tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, userContext.targetRole),
|
||||
tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, [], date, userContext.targetRole),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -176,9 +176,16 @@ export async function prepareHandoffForTask(
|
||||
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "curator.service_handoff.opened",
|
||||
type: "task.opened",
|
||||
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
|
||||
payload: { taskId: task.id, serviceId, route, actionId },
|
||||
payload: {
|
||||
taskId: task.id,
|
||||
curatorTaskId: task.id,
|
||||
serviceId,
|
||||
route,
|
||||
actionId,
|
||||
expectedCompletionEvents: task.completionEvents,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const curatorServiceIdSchema = z.enum([
|
||||
|
||||
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;
|
||||
|
||||
export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice"]);
|
||||
export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice", "recovery"]);
|
||||
export type CuratorTaskType = z.infer<typeof curatorTaskTypeSchema>;
|
||||
|
||||
export const curatorTaskStatusSchema = z.enum([
|
||||
@@ -24,6 +24,9 @@ export const curatorTaskStatusSchema = z.enum([
|
||||
"handoff_prepared",
|
||||
"completed",
|
||||
"blocked",
|
||||
"partial",
|
||||
"skipped",
|
||||
"abandoned",
|
||||
]);
|
||||
|
||||
export const curatorWeekLifecycleSchema = z.enum(["done", "active", "upcoming"]);
|
||||
@@ -73,7 +76,7 @@ export const curatorPlanDaySchema = z.object({
|
||||
weekTheme: z.string(),
|
||||
weekSummary: z.string(),
|
||||
focus: z.string().optional(),
|
||||
plannedServices: z.array(curatorServiceIdSchema).max(3).default([]),
|
||||
plannedServices: z.array(curatorServiceIdSchema).max(4).default([]),
|
||||
generationStatus: z.enum(["seeded", "generated", "adapted"]).default("seeded"),
|
||||
adaptationReason: z.string().optional(),
|
||||
completedCount: z.number().int().min(0),
|
||||
@@ -132,7 +135,7 @@ export const curatorSprintResponseSchema = z.object({
|
||||
activeWeekIndex: z.number().int().min(1).max(6),
|
||||
activeDay: curatorPlanDaySchema,
|
||||
activeDayIndex: z.number().int().min(1).max(30),
|
||||
todayTasks: z.array(curatorTaskSchema).length(3),
|
||||
todayTasks: z.array(curatorTaskSchema).min(3).max(4),
|
||||
streak: curatorStreakSchema,
|
||||
completedCount: z.number().int().min(0),
|
||||
totalCount: z.number().int().min(0),
|
||||
|
||||
113
src/v1/events/events-routes.ts
Normal file
113
src/v1/events/events-routes.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Hono } from "hono";
|
||||
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 { runCuratorOnboardingLoopForEventSafely } from "../curator/curator-onboarding-loop.js";
|
||||
|
||||
const eventTrackSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
source: z.string().min(1),
|
||||
type: z.string().min(1).optional(),
|
||||
action: z.string().min(1).optional(),
|
||||
category: z.enum(["mission", "service", "artifact", "usage", "qscore", "entitlement", "system"]).default("service"),
|
||||
userId: z.string().optional(),
|
||||
user_id: z.string().optional(),
|
||||
orgId: z.string().optional(),
|
||||
org_id: z.string().optional(),
|
||||
timestamp: z.string().optional(),
|
||||
occurredAt: z.string().optional(),
|
||||
occurred_at: z.string().optional(),
|
||||
mission: z.record(z.unknown()).optional(),
|
||||
subject: z.record(z.unknown()).optional(),
|
||||
correlation: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
payload: z.record(z.unknown()).optional(),
|
||||
dedupeKey: z.string().optional(),
|
||||
dedupe_key: z.string().optional(),
|
||||
taskId: z.string().optional(),
|
||||
curatorTaskId: z.string().optional(),
|
||||
curator_task_id: z.string().optional(),
|
||||
serviceId: z.string().optional(),
|
||||
service_id: z.string().optional(),
|
||||
});
|
||||
|
||||
function compactRecord(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
||||
}
|
||||
|
||||
export function v1EventRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.post("/track", async (c) => {
|
||||
const authUserId = c.get("userId");
|
||||
const body = eventTrackSchema.parse(await c.req.json());
|
||||
const type = body.type ?? body.action;
|
||||
if (!type) return c.json({ error: "event_type_required" }, 400);
|
||||
|
||||
const payload = {
|
||||
...(body.payload ?? {}),
|
||||
...(body.metadata ? { metadata: body.metadata } : {}),
|
||||
taskId: body.taskId,
|
||||
curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId,
|
||||
serviceId: body.serviceId ?? body.service_id,
|
||||
status: (body.payload?.status ?? body.metadata?.status) as unknown,
|
||||
};
|
||||
const correlation = compactRecord({
|
||||
...(body.correlation ?? {}),
|
||||
taskId: body.taskId,
|
||||
curatorTaskId: body.curatorTaskId ?? body.curator_task_id ?? body.taskId,
|
||||
serviceId: body.serviceId ?? body.service_id,
|
||||
});
|
||||
|
||||
const event = await recordGrowEvent({
|
||||
id: body.id,
|
||||
source: body.source,
|
||||
type,
|
||||
category: body.category,
|
||||
userId: authUserId,
|
||||
orgId: body.orgId ?? body.org_id,
|
||||
occurredAt: body.occurredAt ?? body.occurred_at ?? body.timestamp ?? new Date().toISOString(),
|
||||
mission: body.mission,
|
||||
subject: body.subject,
|
||||
correlation,
|
||||
payload: compactRecord(payload),
|
||||
raw: body,
|
||||
dedupeKey: body.dedupeKey ?? body.dedupe_key ?? body.id,
|
||||
}, { userId: authUserId, source: body.source });
|
||||
|
||||
if (event.processingStatus === "processed") {
|
||||
return c.json({
|
||||
eventId: event.id,
|
||||
processingStatus: "processed",
|
||||
idempotent: true,
|
||||
}, 202);
|
||||
}
|
||||
|
||||
await markGrowEventProcessing(event.id);
|
||||
try {
|
||||
const serviceSession = await applyServiceSessionProjection(event);
|
||||
const qscore = await applyQscoreProjection(event);
|
||||
const curatorOnboarding = await runCuratorOnboardingLoopForEventSafely(event);
|
||||
await markGrowEventProcessed(event.id);
|
||||
return c.json({
|
||||
eventId: event.id,
|
||||
processingStatus: "processed",
|
||||
serviceSession,
|
||||
qscore,
|
||||
curatorOnboarding,
|
||||
}, 202);
|
||||
} catch (err) {
|
||||
await markGrowEventFailed(event.id, err);
|
||||
return c.json({
|
||||
eventId: event.id,
|
||||
processingStatus: "failed",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Hono } from "hono";
|
||||
import { v1CuratorRoutes } from "./curator/curator-routes.js";
|
||||
import { v1AnalyticsRoutes } from "./analytics/analytics-routes.js";
|
||||
import { v1EventRoutes } from "./events/events-routes.js";
|
||||
import { v1QscoreRoutes } from "./qscore/qscore-routes.js";
|
||||
|
||||
export function v1Routes() {
|
||||
const app = new Hono();
|
||||
app.route("/curator", v1CuratorRoutes());
|
||||
app.route("/analytics", v1AnalyticsRoutes());
|
||||
app.route("/events", v1EventRoutes());
|
||||
app.route("/qscore", v1QscoreRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
84
src/v1/qscore/qscore-routes.ts
Normal file
84
src/v1/qscore/qscore-routes.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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";
|
||||
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
export function v1QscoreRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
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));
|
||||
|
||||
if (!projection && signals.length === 0) {
|
||||
return c.json({
|
||||
status: "baseline_needed",
|
||||
score: null,
|
||||
dimensions: [],
|
||||
trendLabel: "No QScore signals yet",
|
||||
lastUpdatedAt: null,
|
||||
explanation: "No onboarding, service completion, or readiness signals have been projected for this user.",
|
||||
signalCount: 0,
|
||||
signals: [],
|
||||
source: "grow_qscore_projection_state",
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
Reference in New Issue
Block a user