Compare commits
35 Commits
backend/se
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3329eeb2fd | ||
|
|
760103f838 | ||
|
|
592bbf0f57 | ||
|
|
57b31d58cc | ||
|
|
e13dfe7d46 | ||
|
|
b895d6be79 | ||
|
|
91600e4e8c | ||
|
|
eaba7f95e3 | ||
|
|
a442f1f53a | ||
| e88bc02012 | |||
|
|
13e82e0a52 | ||
|
|
750a6ab03b | ||
| 1ecd964104 | |||
|
|
97ed70a921 | ||
|
|
0bfc18305b | ||
|
|
a83a27eb50 | ||
|
|
2de70d3b8c | ||
| b379d5b9fc | |||
| 71f18fde9d | |||
| dfdde7fa4d | |||
|
|
dbc984ed7f | ||
|
|
4092025693 | ||
|
|
29ed0a15cd | ||
|
|
7bad0a46c2 | ||
|
|
f888a6fc0d | ||
|
|
1cbd3e1a84 | ||
|
|
bff336baa7 | ||
|
|
cad24ea089 | ||
|
|
459832a2a3 | ||
|
|
610975561f | ||
|
|
a3a84faae7 | ||
|
|
d493ce8f33 | ||
|
|
fe62662cb6 | ||
|
|
6a77bb5d2e | ||
|
|
c48c28fdb3 |
@@ -2,14 +2,15 @@ FROM node:22-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npx tsc -p tsconfig.json
|
||||
RUN ./node_modules/.bin/tsc -p tsconfig.json
|
||||
|
||||
FROM base AS runtime
|
||||
ARG RIVET_RUNNER_VERSION=dev
|
||||
|
||||
@@ -105,6 +105,8 @@ services:
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-https://opencode.ai/zen/v1}
|
||||
LLM_MODEL: ${LLM_MODEL:-kimi-k2.6}
|
||||
GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-kimi-k2.6}
|
||||
HOME_FEED_AGENT_TIMEOUT_MS: ${HOME_FEED_AGENT_TIMEOUT_MS:-90000}
|
||||
HOME_FEED_AGENT_ATTEMPTS: ${HOME_FEED_AGENT_ATTEMPTS:-2}
|
||||
# Per-user OpenCode containers
|
||||
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-growqr/opencode:dev}
|
||||
USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal}
|
||||
@@ -116,6 +118,11 @@ services:
|
||||
ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008}
|
||||
QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000}
|
||||
RESUME_SERVICE_URL: ${RESUME_SERVICE_URL:-http://host.docker.internal:8002}
|
||||
USER_SERVICE_URL: ${USER_SERVICE_URL:-http://host.docker.internal:8003}
|
||||
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}
|
||||
PATHWAYS_SERVICE_URL: ${PATHWAYS_SERVICE_URL:-http://host.docker.internal:8009}
|
||||
# Frontend
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
volumes:
|
||||
|
||||
121
docs/prm-80-pr12-final-audit.md
Normal file
121
docs/prm-80-pr12-final-audit.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# PRM-80 PR #12 Final Audit
|
||||
|
||||
Date: 2026-06-25
|
||||
|
||||
PR: https://git.openputer.com/growqr-app/growqr-backend/pulls/12
|
||||
|
||||
Branch: `prm-80-canonical-events` -> `staging`
|
||||
|
||||
Latest verified code commit before this audit doc: `e13dfe7d468209685596385edc749e5506f9f8a2`
|
||||
|
||||
## Commits In PR
|
||||
|
||||
- `b895d6b` - `fix: emit canonical service events for PRM-80`
|
||||
- `e13dfe7` - `fix: keep qscore out of curator tasks`
|
||||
|
||||
## Scope
|
||||
|
||||
This PR now covers the PRM-80 canonical Grow Events contract work plus the curator task policy update requested after QA:
|
||||
|
||||
- Workflow/service events are normalized into canonical PRM-80 event names before ingestion.
|
||||
- Workflow bridge events carry `subject` and service identity details instead of storing `subject: null`.
|
||||
- Resume, roleplay, QScore analytics/read, and matchmaking workflow paths emit or bridge their canonical service events into `grow_events`.
|
||||
- Duplicate canonical service events are kept idempotent through deterministic dedupe behavior.
|
||||
- Curator no longer assigns `qscore-service` as a task or handoff service.
|
||||
|
||||
## Curator QScore Policy Change
|
||||
|
||||
QScore remains available as a scoring/readiness projection service for dashboard and backend consumers. It is no longer offered as a curator task.
|
||||
|
||||
Files changed:
|
||||
|
||||
- `src/v1/curator/curator-store.ts`
|
||||
- `src/v1/curator/curator-tools.ts`
|
||||
|
||||
Behavior added:
|
||||
|
||||
- Curator seed generation remaps `qscore-service` tasks to non-QScore services.
|
||||
- Measurement tasks that previously pointed at QScore now point at `assessment-service`.
|
||||
- Proof tasks that previously pointed at QScore now point at `resume-service`.
|
||||
- Practice tasks that previously pointed at QScore now point at `interview-service`.
|
||||
- Recovery tasks that previously pointed at QScore now point at `roleplay-service`.
|
||||
- Curator capability listing filters out `qscore-service`.
|
||||
- `prepare_qscore_review` is disabled for curator task handoffs and returns `qscore_curator_handoff_disabled`.
|
||||
|
||||
## Curator Services Allowed After Change
|
||||
|
||||
- `interview-service`
|
||||
- `roleplay-service`
|
||||
- `resume-service`
|
||||
- `cover-letter-service`
|
||||
- `courses-service`
|
||||
- `assessment-service`
|
||||
- `matchmaking-service`
|
||||
- `social-branding-service`
|
||||
|
||||
Excluded from curator task assignment:
|
||||
|
||||
- `qscore-service`
|
||||
|
||||
## Runtime Verification
|
||||
|
||||
Backend container:
|
||||
|
||||
- `growqr-backend` rebuilt successfully with Docker.
|
||||
- `growqr-backend` restarted and is healthy.
|
||||
- Root backend health response returned `200 application/json` with `{"name":"growqr-backend","status":"ok","env":"production"}`.
|
||||
|
||||
Curator today API verification:
|
||||
|
||||
```text
|
||||
task_count 3
|
||||
qscore_task_count 0
|
||||
|
||||
measurement | assessment-service | Assessment | Check whether confidence is improving | Open assessment
|
||||
proof | resume-service | Resume | Generate a cleaner role-fit artifact | Open resume workspace
|
||||
practice | interview-service | Interview | Run one focused interview rep | Open interview preview
|
||||
```
|
||||
|
||||
Curator 30-day sprint verification:
|
||||
|
||||
```text
|
||||
planned_service_count 90
|
||||
planned_qscore_count 0
|
||||
task_qscore_count 0
|
||||
|
||||
assessment-service: 30
|
||||
interview-service: 11
|
||||
matchmaking-service: 11
|
||||
resume-service: 21
|
||||
roleplay-service: 8
|
||||
social-branding-service: 9
|
||||
```
|
||||
|
||||
Build verification:
|
||||
|
||||
```text
|
||||
docker compose build backend
|
||||
Image growqr-backend-backend Built
|
||||
```
|
||||
|
||||
Remote branch verification:
|
||||
|
||||
```text
|
||||
refs/heads/prm-80-canonical-events -> e13dfe7d468209685596385edc749e5506f9f8a2
|
||||
```
|
||||
|
||||
PR page verification:
|
||||
|
||||
```text
|
||||
https://git.openputer.com/growqr-app/growqr-backend/pulls/12
|
||||
HTTP/2 200
|
||||
PR title: #12 - PRM-80: Emit canonical service events for Grow Events
|
||||
Latest commit visible: e13dfe7 fix: keep qscore out of curator tasks
|
||||
```
|
||||
|
||||
## Audit Notes
|
||||
|
||||
- Only the intended tracked files were committed for the curator update.
|
||||
- VPS has untracked `.bak.*` backup files from prior QA/debug work; these were intentionally not staged or committed.
|
||||
- QScore was not removed from the service registry because dashboard scoring, backend QScore reads, and projection consumers still depend on it.
|
||||
- The change is limited to curator task assignment/handoff behavior.
|
||||
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.
|
||||
143
scripts/service-registry-acceptance.mjs
Executable file
143
scripts/service-registry-acceptance.mjs
Executable file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const args = new Map();
|
||||
for (let i = 2; i < process.argv.length; i += 1) {
|
||||
const key = process.argv[i];
|
||||
if (!key.startsWith("--")) continue;
|
||||
const next = process.argv[i + 1];
|
||||
args.set(key.slice(2), next && !next.startsWith("--") ? next : "true");
|
||||
if (next && !next.startsWith("--")) i += 1;
|
||||
}
|
||||
|
||||
const requiredServices = [
|
||||
"interview-service",
|
||||
"roleplay-service",
|
||||
"courses-service",
|
||||
"assessment-service",
|
||||
"matchmaking-service",
|
||||
"resume-service",
|
||||
"cover-letter-service",
|
||||
"qscore-service",
|
||||
"social-branding-service",
|
||||
];
|
||||
|
||||
const registry = await import("../dist/services/service-registry.js");
|
||||
const capabilities = await import("../dist/workflows/service-capabilities.js");
|
||||
|
||||
function assert(condition, message, detail) {
|
||||
if (condition) return;
|
||||
const suffix = detail === undefined ? "" : `\n${JSON.stringify(detail, null, 2).slice(0, 3000)}`;
|
||||
throw new Error(`${message}${suffix}`);
|
||||
}
|
||||
|
||||
function assertEndpoint(serviceId, endpointId, endpoint) {
|
||||
assert(endpoint, `${serviceId} missing endpoint ${endpointId}`);
|
||||
assert(["GET", "POST", "PUT", "PATCH", "DELETE"].includes(endpoint.method), `${serviceId}.${endpointId} invalid method`, endpoint);
|
||||
assert(typeof endpoint.path === "string" && endpoint.path.startsWith("/"), `${serviceId}.${endpointId} invalid path`, endpoint);
|
||||
assert(typeof endpoint.contract === "string" && endpoint.contract.length > 8, `${serviceId}.${endpointId} missing contract`, endpoint);
|
||||
assert(typeof endpoint.usage === "string" && endpoint.usage.length > 8, `${serviceId}.${endpointId} missing usage`, endpoint);
|
||||
}
|
||||
|
||||
function assertPage(serviceId, pageId, page) {
|
||||
assert(page, `${serviceId} missing frontend page ${pageId}`);
|
||||
assert(typeof page.path === "string" && page.path.startsWith("/"), `${serviceId}.${pageId} invalid frontend path`, page);
|
||||
assert(Array.isArray(page.queryParams), `${serviceId}.${pageId} queryParams must be an array`, page);
|
||||
assert(typeof page.usage === "string" && page.usage.length > 8, `${serviceId}.${pageId} missing frontend usage`, page);
|
||||
}
|
||||
|
||||
const services = registry.listServices();
|
||||
assert(Array.isArray(services), "listServices did not return an array");
|
||||
assert(new Set(services.map((service) => service.id)).size === services.length, "registry contains duplicate service ids", services.map((s) => s.id));
|
||||
|
||||
for (const id of requiredServices) {
|
||||
const service = registry.getService(id);
|
||||
assert(service, `missing first-class service ${id}`);
|
||||
assert(service.id === id, `getService returned wrong id for ${id}`, service);
|
||||
assert(typeof service.label === "string" && service.label.length > 1, `${id} missing label`, service);
|
||||
assert(typeof service.description === "string" && service.description.length > 8, `${id} missing description`, service);
|
||||
assert(typeof service.featureId === "string" && service.featureId.length > 1, `${id} missing featureId`, service);
|
||||
assert(typeof service.promptModulePath === "string" && service.promptModulePath.length > 1, `${id} missing promptModulePath`, service);
|
||||
|
||||
assert(service.backend, `${id} missing backend`);
|
||||
assert(typeof service.backend.healthPath === "string" && service.backend.healthPath.startsWith("/"), `${id} missing healthPath`, service.backend);
|
||||
assert(typeof service.backend.usage === "string" && service.backend.usage.length > 8, `${id} missing backend usage`, service.backend);
|
||||
assert(service.backend.endpoints && Object.keys(service.backend.endpoints).length > 0, `${id} missing backend endpoints`, service.backend);
|
||||
for (const [endpointId, endpoint] of Object.entries(service.backend.endpoints)) assertEndpoint(id, endpointId, endpoint);
|
||||
|
||||
assert(service.frontend, `${id} missing frontend`);
|
||||
assert(typeof service.frontend.baseUrl === "string" && service.frontend.baseUrl.length > 0, `${id} missing frontend baseUrl`, service.frontend);
|
||||
assert(typeof service.frontend.usage === "string" && service.frontend.usage.length > 8, `${id} missing frontend usage`, service.frontend);
|
||||
assert(service.frontend.pages && Object.keys(service.frontend.pages).length > 0, `${id} missing frontend pages`, service.frontend);
|
||||
for (const [pageId, page] of Object.entries(service.frontend.pages)) assertPage(id, pageId, page);
|
||||
|
||||
assert(service.curator, `${id} missing curator`);
|
||||
assert(service.frontend.pages[service.curator.defaultPage], `${id} curator defaultPage is not a real page`, service.curator);
|
||||
assert(typeof service.curator.defaultActionLabel === "string" && service.curator.defaultActionLabel.length > 3, `${id} missing default action label`, service.curator);
|
||||
assert(Array.isArray(service.curator.completionEvents) && service.curator.completionEvents.length > 0, `${id} missing completion events`, service.curator);
|
||||
assert(typeof service.curator.toolName === "string" && service.curator.toolName.length > 3, `${id} missing curator toolName`, service.curator);
|
||||
|
||||
assert(Array.isArray(service.usageDocs) && service.usageDocs.length > 0, `${id} missing usageDocs`, service);
|
||||
assert(registry.getServiceBackend(id) === service.backend, `${id} getServiceBackend mismatch`);
|
||||
assert(registry.getServiceFrontend(id) === service.frontend, `${id} getServiceFrontend mismatch`);
|
||||
assert(registry.getCompletionEvents(id).length === service.curator.completionEvents.length, `${id} getCompletionEvents mismatch`);
|
||||
assert(registry.getServiceActionLabel(id, "start").length > 0, `${id} action label is empty`);
|
||||
|
||||
const endpoint = registry.getServiceEndpoint(id, Object.keys(service.backend.endpoints)[0]);
|
||||
assert(endpoint, `${id} getServiceEndpoint returned nothing`);
|
||||
const link = registry.buildServiceLink(id, service.curator.defaultPage, {
|
||||
source: "acceptance",
|
||||
missionInstanceId: "mission-acceptance",
|
||||
curatorTaskId: "task-acceptance",
|
||||
});
|
||||
assert(typeof link === "string" && link.startsWith("/"), `${id} buildServiceLink returned invalid link`, { link });
|
||||
assert(link.includes("source=acceptance"), `${id} buildServiceLink did not preserve state`, { link });
|
||||
assert(!link.includes("undefined") && !link.includes("null"), `${id} buildServiceLink leaked nullish values`, { link });
|
||||
}
|
||||
|
||||
assert(registry.getService("jobs-service")?.id === "matchmaking-service", "matchmaking alias failed");
|
||||
assert(registry.getService("coverletter-service")?.id === "cover-letter-service", "cover-letter alias failed");
|
||||
assert(registry.getService("q-score-service")?.id === "qscore-service", "qscore alias failed");
|
||||
assert(registry.getService("social-service")?.id === "social-branding-service", "social alias failed");
|
||||
|
||||
const catalog = registry.listServicesForCatalog();
|
||||
assert(catalog.length === services.length, "listServicesForCatalog count mismatch", { catalog: catalog.length, services: services.length });
|
||||
assert(!catalog.some((service) => service.backend?.baseUrl), "catalog leaks backend.baseUrl", catalog);
|
||||
|
||||
const publicCapabilities = capabilities.listServiceCapabilities({ public: true });
|
||||
const capabilityServices = publicCapabilities.filter((service) => requiredServices.includes(service.id));
|
||||
assert(publicCapabilities.length === services.length, "public capabilities should only expose canonical registry services", publicCapabilities.map((s) => s.id));
|
||||
assert(capabilityServices.length === requiredServices.length, "public capabilities missing required services", capabilityServices.map((s) => s.id));
|
||||
assert(!capabilityServices.some((service) => service.internalUrl || service.backend?.baseUrl), "public capabilities leak internal URL", capabilityServices);
|
||||
assert(!publicCapabilities.some((service) => service.id === "mission-planning"), "public capabilities leak internal mission-planning module", publicCapabilities);
|
||||
for (const service of capabilityServices) {
|
||||
const record = registry.getService(service.id);
|
||||
assert(record, `capability references unknown registry service ${service.id}`);
|
||||
assert(JSON.stringify(service.operations) === JSON.stringify(Object.keys(record.backend.endpoints)), `${service.id} operations not derived from endpoints`, {
|
||||
operations: service.operations,
|
||||
endpoints: Object.keys(record.backend.endpoints),
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = args.get("base-url") || process.env.BACKEND_BASE_URL;
|
||||
const serviceToken = process.env.SERVICE_TOKEN;
|
||||
if (baseUrl) {
|
||||
assert(serviceToken, "SERVICE_TOKEN is required when --base-url/BACKEND_BASE_URL is provided");
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/services/catalog`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${serviceToken}`,
|
||||
"x-growqr-user": "registry-acceptance",
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok, `live /services/catalog returned HTTP ${response.status}`, text);
|
||||
const live = JSON.parse(text);
|
||||
assert(Array.isArray(live.services), "live catalog missing services", live);
|
||||
assert(live.services.length === services.length, "live catalog should only expose canonical registry services", live.services.map((service) => service.id));
|
||||
for (const id of requiredServices) {
|
||||
assert(live.services.some((service) => service.id === id), `live catalog missing ${id}`, live);
|
||||
}
|
||||
assert(!live.services.some((service) => service.backend?.baseUrl), "live catalog leaks backend.baseUrl", live);
|
||||
assert(!live.services.some((service) => service.id === "mission-planning"), "live catalog leaks internal mission-planning module", live);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, services: services.length, requiredServices: requiredServices.length, liveCatalog: Boolean(baseUrl) }));
|
||||
148
scripts/service-registry-content-quality.mjs
Executable file
148
scripts/service-registry-content-quality.mjs
Executable file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const args = new Map();
|
||||
for (let i = 2; i < process.argv.length; i += 1) {
|
||||
const key = process.argv[i];
|
||||
if (!key.startsWith("--")) continue;
|
||||
const next = process.argv[i + 1];
|
||||
args.set(key.slice(2), next && !next.startsWith("--") ? next : "true");
|
||||
if (next && !next.startsWith("--")) i += 1;
|
||||
}
|
||||
|
||||
const baseUrl = (args.get("base-url") || process.env.BACKEND_BASE_URL || "http://127.0.0.1:4000").replace(/\/$/, "");
|
||||
const userId = args.get("user-id") || process.env.SMOKE_USER_ID || "registry-content-quality";
|
||||
const iterations = Number(args.get("iterations") || process.env.SMOKE_ITERATIONS || 1);
|
||||
const previewTimeoutMs = Number(args.get("preview-timeout-ms") || process.env.SMOKE_PREVIEW_TIMEOUT_MS || 180000);
|
||||
const serviceToken = process.env.SERVICE_TOKEN;
|
||||
|
||||
if (!serviceToken) {
|
||||
throw new Error("SERVICE_TOKEN is required for authenticated content-quality probes.");
|
||||
}
|
||||
|
||||
const badMarkers = [/placeholder/i, /dummy/i, /not implemented/i, /fallback/i, /lorem/i, /todo/i, /undefined/i];
|
||||
|
||||
function assert(condition, message, detail) {
|
||||
if (condition) return;
|
||||
const suffix = detail === undefined ? "" : `\n${JSON.stringify(detail, null, 2).slice(0, 3000)}`;
|
||||
throw new Error(`${message}${suffix}`);
|
||||
}
|
||||
|
||||
function outlineOf(json) {
|
||||
return Array.isArray(json?.question_outline) ? json.question_outline : json?.prompt_outline;
|
||||
}
|
||||
|
||||
function walk(value, path = "$", strings = [], nulls = []) {
|
||||
if (value === null) nulls.push(path);
|
||||
else if (typeof value === "string") strings.push(value);
|
||||
else if (Array.isArray(value)) value.forEach((item, index) => walk(item, `${path}[${index}]`, strings, nulls));
|
||||
else if (value && typeof value === "object") Object.entries(value).forEach(([key, item]) => walk(item, `${path}.${key}`, strings, nulls));
|
||||
return { strings, nulls };
|
||||
}
|
||||
|
||||
async function post(name, path, payload) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), previewTimeoutMs);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
authorization: `Bearer ${serviceToken}`,
|
||||
"x-growqr-user": userId,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await response.text();
|
||||
const durationMs = Date.now() - started;
|
||||
assert(response.ok, `${name} returned HTTP ${response.status}`, { text, durationMs });
|
||||
return { json: JSON.parse(text), durationMs };
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
throw new Error(`${name} timed out after ${Date.now() - started}ms`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePreview(name, json) {
|
||||
const outline = outlineOf(json);
|
||||
assert(Array.isArray(outline) && outline.length >= 3, `${name} needs at least 3 outline items`, outline);
|
||||
|
||||
const { strings, nulls } = walk(json);
|
||||
assert(nulls.length === 0, `${name} contains null fields`, nulls.slice(0, 30));
|
||||
|
||||
const cleanStrings = strings.map((item) => item.trim()).filter(Boolean);
|
||||
for (const marker of badMarkers) {
|
||||
assert(!cleanStrings.some((item) => marker.test(item)), `${name} contains marker ${marker}`, cleanStrings.filter((item) => marker.test(item)).slice(0, 10));
|
||||
}
|
||||
|
||||
const prompts = outline
|
||||
.map((item) => String(item.question || item.prompt || item.text || "").replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
assert(prompts.length >= 3, `${name} outline prompts are missing text`, outline);
|
||||
assert(prompts.every((prompt) => prompt.length >= 35), `${name} outline prompts are too shallow`, prompts);
|
||||
assert(new Set(prompts.map((prompt) => prompt.toLowerCase())).size === prompts.length, `${name} outline prompts duplicate`, prompts);
|
||||
assert(String(json.opening_prompt || "").trim().length >= 35, `${name} opening prompt too short`, json.opening_prompt);
|
||||
|
||||
const briefText = walk(json.candidate_brief).strings.join(" ").replace(/\s+/g, " ").trim();
|
||||
assert(briefText.length >= 300, `${name} candidate brief too thin`, briefText);
|
||||
}
|
||||
|
||||
async function runIteration(iteration) {
|
||||
const user = `${userId}-${iteration}`;
|
||||
const interview = await post(`[content ${iteration}] interview preview`, "/services/interview/preview", {
|
||||
user_id: user,
|
||||
org_id: "growqr",
|
||||
persona_id: "emma",
|
||||
interview_type: "behavioral",
|
||||
duration_minutes: 5,
|
||||
context: {
|
||||
target_role: "Product Manager",
|
||||
company_name: "GrowQR Quality",
|
||||
difficulty: "medium",
|
||||
source: "registry-content-quality",
|
||||
personalize: false,
|
||||
},
|
||||
});
|
||||
validatePreview(`[content ${iteration}] interview preview`, interview.json);
|
||||
|
||||
const roleplay = await post(`[content ${iteration}] roleplay preview`, "/services/roleplay/preview", {
|
||||
user_id: user,
|
||||
org_id: "growqr",
|
||||
persona_id: "emma",
|
||||
duration_minutes: 5,
|
||||
roleplay_type: "custom",
|
||||
brief: "Practice a concise salary negotiation opening for a product manager offer.",
|
||||
metadata: {
|
||||
target_role: "Product Manager",
|
||||
candidate_role: "Product Manager",
|
||||
difficulty: "medium",
|
||||
source: "registry-content-quality",
|
||||
personalize: false,
|
||||
},
|
||||
});
|
||||
validatePreview(`[content ${iteration}] roleplay preview`, roleplay.json);
|
||||
assert(roleplay.json.scenario?.candidate_role === "Product Manager", `[content ${iteration}] roleplay did not expose explicit candidate_role`, roleplay.json.scenario);
|
||||
assert(typeof roleplay.json.scenario?.persona_role === "string" && roleplay.json.scenario.persona_role.length > 0, `[content ${iteration}] roleplay did not expose persona_role`, roleplay.json.scenario);
|
||||
|
||||
return {
|
||||
iteration,
|
||||
interviewSession: interview.json.session_id,
|
||||
interviewPreviewMs: interview.durationMs,
|
||||
roleplaySession: roleplay.json.session_id,
|
||||
roleplayPreviewMs: roleplay.durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let i = 1; i <= iterations; i += 1) {
|
||||
const result = await runIteration(i);
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, iterations, results }));
|
||||
216
scripts/service-registry-smoke.mjs
Normal file
216
scripts/service-registry-smoke.mjs
Normal file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const args = new Map();
|
||||
for (let i = 2; i < process.argv.length; i += 1) {
|
||||
const key = process.argv[i];
|
||||
if (!key.startsWith("--")) continue;
|
||||
const next = process.argv[i + 1];
|
||||
args.set(key.slice(2), next && !next.startsWith("--") ? next : "true");
|
||||
if (next && !next.startsWith("--")) i += 1;
|
||||
}
|
||||
|
||||
const baseUrl = (args.get("base-url") || process.env.BACKEND_BASE_URL || "http://127.0.0.1:4000").replace(/\/$/, "");
|
||||
const userId = args.get("user-id") || process.env.SMOKE_USER_ID || "registry-smoke";
|
||||
const iterations = Number(args.get("iterations") || process.env.SMOKE_ITERATIONS || 1);
|
||||
const previewTimeoutMs = Number(args.get("preview-timeout-ms") || process.env.SMOKE_PREVIEW_TIMEOUT_MS || 180000);
|
||||
const serviceToken = process.env.SERVICE_TOKEN;
|
||||
|
||||
if (!serviceToken) {
|
||||
throw new Error("SERVICE_TOKEN is required for authenticated backend smoke probes.");
|
||||
}
|
||||
|
||||
const requiredServices = [
|
||||
"interview-service",
|
||||
"roleplay-service",
|
||||
"resume-service",
|
||||
"cover-letter-service",
|
||||
"courses-service",
|
||||
"assessment-service",
|
||||
"matchmaking-service",
|
||||
"qscore-service",
|
||||
"social-branding-service",
|
||||
];
|
||||
|
||||
const directHealth = [
|
||||
["interview", "http://127.0.0.1:8007/health"],
|
||||
["roleplay", "http://127.0.0.1:8008/health"],
|
||||
["resume", "http://127.0.0.1:8002/health"],
|
||||
["qscore", "http://127.0.0.1:8000/health"],
|
||||
["courses", "http://127.0.0.1:8060/api/v1/health"],
|
||||
["assessment", "http://127.0.0.1:8070/api/v1/health"],
|
||||
["matchmaking", "http://127.0.0.1:8006/api/v1/health"],
|
||||
["pathways", "http://127.0.0.1:8009/api/v1/health"],
|
||||
["social", "http://127.0.0.1:8015/health"],
|
||||
];
|
||||
|
||||
function assert(condition, message, detail) {
|
||||
if (condition) return;
|
||||
const suffix = detail === undefined ? "" : `\n${JSON.stringify(detail, null, 2).slice(0, 3000)}`;
|
||||
throw new Error(`${message}${suffix}`);
|
||||
}
|
||||
|
||||
function authHeaders(extra = {}) {
|
||||
return {
|
||||
authorization: `Bearer ${serviceToken}`,
|
||||
"x-growqr-user": userId,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function request(name, url, init = {}, timeoutMs = 15000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal });
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
json = undefined;
|
||||
}
|
||||
const durationMs = Date.now() - started;
|
||||
assert(res.ok, `${name} returned HTTP ${res.status}`, { text, durationMs });
|
||||
return { json, text, durationMs };
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
const durationMs = Date.now() - started;
|
||||
throw new Error(`${name} timed out after ${durationMs}ms`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectFallbackLike(name, value) {
|
||||
if (value && typeof value === "object") {
|
||||
assert(!("error" in value), `${name} contains error field`, value);
|
||||
assert(!("detail" in value && /internal|fallback|not implemented/i.test(String(value.detail))), `${name} contains error detail`, value);
|
||||
}
|
||||
const text = JSON.stringify(value).toLowerCase();
|
||||
const bad = ["placeholder", "dummy", "not implemented", "fallback"];
|
||||
const found = bad.find((needle) => text.includes(needle));
|
||||
assert(!found, `${name} contains fallback/error-like marker: ${found}`, value);
|
||||
}
|
||||
|
||||
function assertGeneratedPreview(name, json) {
|
||||
rejectFallbackLike(name, json);
|
||||
assert(typeof json.session_id === "string" && json.session_id.length > 12, `${name} missing session_id`, json);
|
||||
assert(json.status === "draft", `${name} should create draft preview`, json);
|
||||
assert(json.needs_approval === true, `${name} should require approval`, json);
|
||||
|
||||
const outline = Array.isArray(json.question_outline) ? json.question_outline : json.prompt_outline;
|
||||
assert(Array.isArray(outline) && outline.length >= 2, `${name} missing generated outline`, json);
|
||||
assert(Boolean(json.opening_prompt), `${name} missing opening_prompt`, json);
|
||||
assert(Boolean(json.candidate_brief), `${name} missing candidate_brief`, json);
|
||||
}
|
||||
|
||||
async function runIteration(iteration) {
|
||||
const prefix = `[smoke ${iteration}]`;
|
||||
const health = await request(`${prefix} backend health`, `${baseUrl}/healthz`);
|
||||
assert(health.json?.ok === true, `${prefix} backend health payload invalid`, health.json);
|
||||
|
||||
const catalog = await request(`${prefix} services catalog`, `${baseUrl}/services/catalog`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
const services = catalog.json?.services;
|
||||
assert(Array.isArray(services), `${prefix} catalog missing services`, catalog.json);
|
||||
for (const id of requiredServices) {
|
||||
assert(services.some((service) => service.id === id), `${prefix} catalog missing ${id}`, catalog.json);
|
||||
}
|
||||
assert(!services.some((service) => service.backend?.baseUrl), `${prefix} catalog leaks internal backend baseUrl`, catalog.json);
|
||||
assert(
|
||||
services.find((service) => service.id === "courses-service")?.backend?.healthPath === "/api/v1/health",
|
||||
`${prefix} courses health path is not canonical`,
|
||||
catalog.json,
|
||||
);
|
||||
|
||||
for (const [name, url] of directHealth) {
|
||||
const res = await request(`${prefix} ${name} direct health`, url, {}, 8000);
|
||||
rejectFallbackLike(`${prefix} ${name} direct health`, res.json ?? res.text);
|
||||
}
|
||||
|
||||
for (const service of ["interview", "roleplay", "resume", "social"]) {
|
||||
const res = await request(`${prefix} ${service} gateway health`, `${baseUrl}/services/${service}/health`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
rejectFallbackLike(`${prefix} ${service} gateway health`, res.json ?? res.text);
|
||||
}
|
||||
|
||||
const interviewState = await request(`${prefix} interview page-state`, `${baseUrl}/services/interview/page-state`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
assert(Array.isArray(interviewState.json?.recent_sessions), `${prefix} interview page-state missing recent_sessions`, interviewState.json);
|
||||
|
||||
const roleplayState = await request(`${prefix} roleplay page-state`, `${baseUrl}/services/roleplay/page-state`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
assert(Array.isArray(roleplayState.json?.recent_sessions), `${prefix} roleplay page-state missing recent_sessions`, roleplayState.json);
|
||||
|
||||
const qscore = await request(`${prefix} qscore current`, `${baseUrl}/services/qscore/current`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
assert("signals" in qscore.json && Array.isArray(qscore.json.signals), `${prefix} qscore current missing signals`, qscore.json);
|
||||
|
||||
const interviewPayload = {
|
||||
user_id: userId,
|
||||
org_id: "growqr",
|
||||
persona_id: "emma",
|
||||
interview_type: "behavioral",
|
||||
duration_minutes: 5,
|
||||
context: {
|
||||
target_role: "Product Manager",
|
||||
company_name: "GrowQR Smoke Test",
|
||||
difficulty: "medium",
|
||||
source: "registry-smoke",
|
||||
personalize: false,
|
||||
},
|
||||
};
|
||||
const interviewPreview = await request(`${prefix} interview preview generation`, `${baseUrl}/services/interview/preview`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(interviewPayload),
|
||||
}, previewTimeoutMs);
|
||||
assertGeneratedPreview(`${prefix} interview preview generation`, interviewPreview.json);
|
||||
|
||||
const roleplayPayload = {
|
||||
user_id: userId,
|
||||
org_id: "growqr",
|
||||
persona_id: "emma",
|
||||
duration_minutes: 5,
|
||||
roleplay_type: "custom",
|
||||
brief: "Practice a concise salary negotiation opening for a product manager offer.",
|
||||
metadata: {
|
||||
target_role: "Product Manager",
|
||||
difficulty: "medium",
|
||||
source: "registry-smoke",
|
||||
personalize: false,
|
||||
},
|
||||
};
|
||||
const roleplayPreview = await request(`${prefix} roleplay preview generation`, `${baseUrl}/services/roleplay/preview`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(roleplayPayload),
|
||||
}, previewTimeoutMs);
|
||||
assertGeneratedPreview(`${prefix} roleplay preview generation`, roleplayPreview.json);
|
||||
|
||||
return {
|
||||
iteration,
|
||||
catalogCount: services.length,
|
||||
interviewSession: interviewPreview.json.session_id,
|
||||
interviewPreviewMs: interviewPreview.durationMs,
|
||||
roleplaySession: roleplayPreview.json.session_id,
|
||||
roleplayPreviewMs: roleplayPreview.durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let i = 1; i <= iterations; i += 1) {
|
||||
const result = await runIteration(i);
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, iterations, results }));
|
||||
236
scripts/service-registry-write-flow.mjs
Executable file
236
scripts/service-registry-write-flow.mjs
Executable file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const args = new Map();
|
||||
for (let i = 2; i < process.argv.length; i += 1) {
|
||||
const key = process.argv[i];
|
||||
if (!key.startsWith("--")) continue;
|
||||
const next = process.argv[i + 1];
|
||||
args.set(key.slice(2), next && !next.startsWith("--") ? next : "true");
|
||||
if (next && !next.startsWith("--")) i += 1;
|
||||
}
|
||||
|
||||
const baseUrl = (args.get("base-url") || process.env.BACKEND_BASE_URL || "http://127.0.0.1:4000").replace(/\/$/, "");
|
||||
const userId = args.get("user-id") || process.env.SMOKE_USER_ID || "registry-write-smoke";
|
||||
const iterations = Number(args.get("iterations") || process.env.SMOKE_ITERATIONS || 1);
|
||||
const previewTimeoutMs = Number(args.get("preview-timeout-ms") || process.env.SMOKE_PREVIEW_TIMEOUT_MS || 180000);
|
||||
const serviceToken = process.env.SERVICE_TOKEN;
|
||||
|
||||
if (!serviceToken) {
|
||||
throw new Error("SERVICE_TOKEN is required for authenticated backend write-flow probes.");
|
||||
}
|
||||
|
||||
function assert(condition, message, detail) {
|
||||
if (condition) return;
|
||||
const suffix = detail === undefined ? "" : `\n${JSON.stringify(detail, null, 2).slice(0, 3000)}`;
|
||||
throw new Error(`${message}${suffix}`);
|
||||
}
|
||||
|
||||
function authHeaders(extra = {}) {
|
||||
return {
|
||||
authorization: `Bearer ${serviceToken}`,
|
||||
"x-growqr-user": userId,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function request(name, path, init = {}, timeoutMs = 90000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}${path}`, { ...init, signal: controller.signal });
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
json = undefined;
|
||||
}
|
||||
const durationMs = Date.now() - started;
|
||||
assert(res.ok, `${name} returned HTTP ${res.status}`, { text, durationMs });
|
||||
return { json, text, durationMs };
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
const durationMs = Date.now() - started;
|
||||
throw new Error(`${name} timed out after ${durationMs}ms`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectFallbackLike(name, value) {
|
||||
if (value && typeof value === "object") {
|
||||
assert(!("error" in value), `${name} contains error field`, value);
|
||||
assert(!("detail" in value && /internal|fallback|not implemented/i.test(String(value.detail))), `${name} contains error detail`, value);
|
||||
}
|
||||
const text = JSON.stringify(value).toLowerCase();
|
||||
const bad = ["placeholder", "dummy", "not implemented", "fallback"];
|
||||
const found = bad.find((needle) => text.includes(needle));
|
||||
assert(!found, `${name} contains fallback/error-like marker: ${found}`, value);
|
||||
}
|
||||
|
||||
function outlineOf(json) {
|
||||
return Array.isArray(json?.question_outline) ? json.question_outline : json?.prompt_outline;
|
||||
}
|
||||
|
||||
function assertDraftPreview(name, json) {
|
||||
rejectFallbackLike(name, json);
|
||||
assert(typeof json.session_id === "string" && json.session_id.length > 12, `${name} missing session_id`, json);
|
||||
assert(json.status === "draft", `${name} should create draft`, json);
|
||||
assert(json.needs_approval === true, `${name} should require approval`, json);
|
||||
assert(Array.isArray(outlineOf(json)) && outlineOf(json).length >= 2, `${name} missing generated outline`, json);
|
||||
assert(Boolean(json.opening_prompt), `${name} missing opening_prompt`, json);
|
||||
assert(Boolean(json.candidate_brief), `${name} missing candidate_brief`, json);
|
||||
}
|
||||
|
||||
function asInterviewQuestions(preview, iteration) {
|
||||
return outlineOf(preview).slice(0, 3).map((item, index) => ({
|
||||
text: `${String(item.question || item.text || "").replace(/\s+/g, " ").trim()} [write-flow ${iteration}.${index + 1}]`,
|
||||
topic: String(item.topic || `Smoke interview ${index + 1}`),
|
||||
expected_framework: String(item.expected_framework || "none"),
|
||||
}));
|
||||
}
|
||||
|
||||
function asRoleplayPrompts(preview, iteration) {
|
||||
return outlineOf(preview).slice(0, 3).map((item, index) => ({
|
||||
text: `${String(item.prompt || item.question || item.text || "").replace(/\s+/g, " ").trim()} [write-flow ${iteration}.${index + 1}]`,
|
||||
topic: String(item.topic || `Smoke roleplay ${index + 1}`),
|
||||
}));
|
||||
}
|
||||
|
||||
async function runInterviewFlow(iteration) {
|
||||
const prefix = `[write ${iteration}] interview`;
|
||||
const previewPayload = {
|
||||
user_id: userId,
|
||||
org_id: "growqr",
|
||||
persona_id: "emma",
|
||||
interview_type: "behavioral",
|
||||
duration_minutes: 5,
|
||||
context: {
|
||||
target_role: "Product Manager",
|
||||
company_name: "GrowQR Write Flow",
|
||||
difficulty: "medium",
|
||||
source: "registry-write-flow",
|
||||
personalize: false,
|
||||
},
|
||||
};
|
||||
const preview = await request(`${prefix} preview`, "/services/interview/preview", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(previewPayload),
|
||||
}, previewTimeoutMs);
|
||||
assertDraftPreview(`${prefix} preview`, preview.json);
|
||||
|
||||
const questions = asInterviewQuestions(preview.json, iteration);
|
||||
assert(questions.every((item) => item.text.includes("[write-flow")), `${prefix} question edit payload invalid`, questions);
|
||||
const edited = await request(`${prefix} questions edit`, "/services/interview/questions", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify({ session_id: preview.json.session_id, questions }),
|
||||
});
|
||||
rejectFallbackLike(`${prefix} questions edit`, edited.json);
|
||||
assert(edited.json?.status === "draft", `${prefix} edit should keep draft status`, edited.json);
|
||||
assert(edited.json?.questions_edited === true, `${prefix} edit should mark questions_edited`, edited.json);
|
||||
assert(outlineOf(edited.json)?.[0]?.question?.includes("[write-flow"), `${prefix} edited question not persisted`, edited.json);
|
||||
|
||||
const approved = await request(`${prefix} approve`, "/services/interview/approve", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify({ session_id: preview.json.session_id }),
|
||||
});
|
||||
rejectFallbackLike(`${prefix} approve`, approved.json);
|
||||
assert(approved.json?.status === "configured", `${prefix} approve should configure session`, approved.json);
|
||||
assert(approved.json?.approved === true, `${prefix} approve missing approved flag`, approved.json);
|
||||
|
||||
const review = await request(`${prefix} review`, `/services/interview/review/${encodeURIComponent(preview.json.session_id)}`, {
|
||||
headers: authHeaders(),
|
||||
}, 15000);
|
||||
rejectFallbackLike(`${prefix} review`, review.json);
|
||||
assert(review.json?.status === "processing" || typeof review.json?.overall_score === "number", `${prefix} review shape invalid`, review.json);
|
||||
|
||||
return {
|
||||
sessionId: preview.json.session_id,
|
||||
reviewStatus: review.json?.status ?? "complete",
|
||||
durationsMs: {
|
||||
preview: preview.durationMs,
|
||||
edit: edited.durationMs,
|
||||
approve: approved.durationMs,
|
||||
review: review.durationMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runRoleplayFlow(iteration) {
|
||||
const prefix = `[write ${iteration}] roleplay`;
|
||||
const previewPayload = {
|
||||
user_id: userId,
|
||||
org_id: "growqr",
|
||||
persona_id: "emma",
|
||||
duration_minutes: 5,
|
||||
roleplay_type: "custom",
|
||||
brief: "Practice a concise salary negotiation opening for a product manager offer.",
|
||||
metadata: {
|
||||
target_role: "Product Manager",
|
||||
difficulty: "medium",
|
||||
source: "registry-write-flow",
|
||||
personalize: false,
|
||||
},
|
||||
};
|
||||
const preview = await request(`${prefix} preview`, "/services/roleplay/preview", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(previewPayload),
|
||||
}, previewTimeoutMs);
|
||||
assertDraftPreview(`${prefix} preview`, preview.json);
|
||||
|
||||
const questions = asRoleplayPrompts(preview.json, iteration);
|
||||
assert(questions.every((item) => item.text.includes("[write-flow")), `${prefix} prompt edit payload invalid`, questions);
|
||||
const edited = await request(`${prefix} prompt edit`, "/services/roleplay/questions", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify({ session_id: preview.json.session_id, questions }),
|
||||
});
|
||||
rejectFallbackLike(`${prefix} prompt edit`, edited.json);
|
||||
assert(edited.json?.status === "draft", `${prefix} edit should keep draft status`, edited.json);
|
||||
assert(edited.json?.questions_edited === true, `${prefix} edit should mark questions_edited`, edited.json);
|
||||
assert(outlineOf(edited.json)?.[0]?.prompt?.includes("[write-flow"), `${prefix} edited prompt not persisted`, edited.json);
|
||||
|
||||
const approved = await request(`${prefix} approve`, "/services/roleplay/approve", {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify({ session_id: preview.json.session_id }),
|
||||
});
|
||||
rejectFallbackLike(`${prefix} approve`, approved.json);
|
||||
assert(approved.json?.status === "configured", `${prefix} approve should configure session`, approved.json);
|
||||
assert(approved.json?.approved === true, `${prefix} approve missing approved flag`, approved.json);
|
||||
|
||||
const review = await request(`${prefix} review`, `/services/roleplay/review/${encodeURIComponent(preview.json.session_id)}`, {
|
||||
headers: authHeaders(),
|
||||
}, 15000);
|
||||
rejectFallbackLike(`${prefix} review`, review.json);
|
||||
assert(review.json?.status === "processing" || typeof review.json?.overall_score === "number", `${prefix} review shape invalid`, review.json);
|
||||
|
||||
return {
|
||||
sessionId: preview.json.session_id,
|
||||
reviewStatus: review.json?.status ?? "complete",
|
||||
durationsMs: {
|
||||
preview: preview.durationMs,
|
||||
edit: edited.durationMs,
|
||||
approve: approved.durationMs,
|
||||
review: review.durationMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let i = 1; i <= iterations; i += 1) {
|
||||
const interview = await runInterviewFlow(i);
|
||||
const roleplay = await runRoleplayFlow(i);
|
||||
const result = { iteration: i, interview, roleplay };
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, iterations, results }));
|
||||
@@ -115,11 +115,9 @@ async function userQscoreAnalytics(userId: string) {
|
||||
}
|
||||
|
||||
async function userActivityAnalytics(userId: string) {
|
||||
const [events, activeMissions, actions] = await Promise.all([
|
||||
db.select().from(growEvents).where(eq(growEvents.userId, userId)).orderBy(desc(growEvents.occurredAt)).limit(100),
|
||||
listActiveMissionsPg(userId),
|
||||
listMissionActions(userId, { openOnly: false }),
|
||||
]);
|
||||
const events = await db.select().from(growEvents).where(eq(growEvents.userId, userId)).orderBy(desc(growEvents.occurredAt)).limit(100);
|
||||
const activeMissions = await listActiveMissionsPg(userId).catch(() => []);
|
||||
const actions = await listMissionActions(userId, { openOnly: false }).catch(() => []);
|
||||
|
||||
return {
|
||||
kind: "user-activity",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { memoryActor } from "./memory/index.js";
|
||||
import { growActor } from "./grow/index.js";
|
||||
import { userEventActor } from "./events/index.js";
|
||||
import { analyticsActor } from "./analytics/index.js";
|
||||
import { curatorActor } from "../v1/curator/curator-actor.js";
|
||||
import {
|
||||
careerTransitionMissionActor,
|
||||
interviewToOfferMissionActor,
|
||||
@@ -20,6 +21,7 @@ export const registry = setup({
|
||||
growActor,
|
||||
userEventActor,
|
||||
analyticsActor,
|
||||
curatorActor,
|
||||
conversationActor,
|
||||
memoryActor,
|
||||
interviewToOfferMissionActor,
|
||||
|
||||
@@ -77,10 +77,28 @@ export const config = {
|
||||
process.env.USER_SERVICE_URL ?? "http://localhost:8003",
|
||||
resumePublicUrl:
|
||||
process.env.RESUME_PUBLIC_URL ?? process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
|
||||
coursesServiceUrl:
|
||||
process.env.COURSES_SERVICE_URL ?? "http://localhost:8060",
|
||||
coursesPublicUrl:
|
||||
process.env.COURSES_PUBLIC_URL ?? process.env.COURSES_SERVICE_URL ?? "http://localhost:8060",
|
||||
assessmentServiceUrl:
|
||||
process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070",
|
||||
assessmentPublicUrl:
|
||||
process.env.ASSESSMENT_PUBLIC_URL ?? process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070",
|
||||
matchmakingServiceUrl:
|
||||
process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
|
||||
matchmakingPublicUrl:
|
||||
process.env.MATCHMAKING_PUBLIC_URL ?? process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
|
||||
pathwaysServiceUrl:
|
||||
process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009",
|
||||
pathwaysPublicUrl:
|
||||
process.env.PATHWAYS_PUBLIC_URL ?? process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009",
|
||||
socialBrandingServiceUrl:
|
||||
process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
|
||||
socialBrandingPublicUrl:
|
||||
process.env.SOCIAL_BRANDING_PUBLIC_URL ?? process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
|
||||
qscorePublicUrl:
|
||||
process.env.QSCORE_PUBLIC_URL ?? process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
|
||||
workflowsDashboardUrl:
|
||||
process.env.WORKFLOWS_DASHBOARD_URL ??
|
||||
process.env.FRONTEND_ORIGIN ??
|
||||
|
||||
14
src/db/ensure-runtime-schema.ts
Normal file
14
src/db/ensure-runtime-schema.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { db } from "./client.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
async function ensureGrowConversationsMetadataColumn() {
|
||||
await db.execute(`
|
||||
ALTER TABLE grow_conversations
|
||||
ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
`);
|
||||
}
|
||||
|
||||
export async function ensureRuntimeSchema() {
|
||||
await ensureGrowConversationsMetadataColumn();
|
||||
log.info("runtime schema ensured");
|
||||
}
|
||||
@@ -333,6 +333,12 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||
const existing = await db.query.userStacks.findFirst({
|
||||
where: eq(userStacks.userId, userId),
|
||||
});
|
||||
if (existing && existing.status === "provisioning") {
|
||||
const ageMs = Date.now() - existing.updatedAt.getTime();
|
||||
if (ageMs < 5 * 60_000) return existing;
|
||||
log.warn({ userId, updatedAt: existing.updatedAt }, "stale OpenCode provisioning row; retrying");
|
||||
await stopUserStack(userId);
|
||||
}
|
||||
if (existing && existing.status === "running") {
|
||||
const current =
|
||||
existing.imageVersion === config.opencodeImageVersion &&
|
||||
@@ -440,6 +446,8 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||
branch: "main",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("repository file already exists")) continue;
|
||||
log.warn({ err, path: file.path }, "failed to init repo file (non-fatal)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ export type GrowEventCategory =
|
||||
| "system";
|
||||
|
||||
export type GrowEventSubject = {
|
||||
kind: string;
|
||||
id: string;
|
||||
kind?: string;
|
||||
id?: string;
|
||||
serviceId?: string;
|
||||
externalId?: string;
|
||||
};
|
||||
|
||||
export type GrowEventMissionRef = {
|
||||
|
||||
@@ -12,6 +12,9 @@ function normalizeSubject(value: unknown) {
|
||||
const record = asRecord(value);
|
||||
const kind = getString(record.kind);
|
||||
const id = getString(record.id);
|
||||
const serviceId = getString(record.serviceId ?? record.service_id);
|
||||
const externalId = getString(record.externalId ?? record.external_id);
|
||||
if (serviceId || externalId) return { serviceId, externalId };
|
||||
return kind && id ? { kind, id } : undefined;
|
||||
}
|
||||
|
||||
@@ -48,6 +51,9 @@ export function normalizeGrowEvent(input: unknown, overrides: { userId?: string;
|
||||
request_id: raw.request_id ?? payload.request_id,
|
||||
});
|
||||
const subject = normalizeSubject(raw.subject) ?? (() => {
|
||||
const serviceId = getString(raw.subject_service_id ?? payload.subject_service_id);
|
||||
const externalId = getString(raw.subject_external_id ?? payload.subject_external_id);
|
||||
if (serviceId || externalId) return { serviceId, externalId };
|
||||
const kind = getString(raw.subject_kind ?? payload.subject_kind);
|
||||
const id = getString(raw.subject_id ?? payload.subject_id);
|
||||
return kind && id ? { kind, id } : undefined;
|
||||
|
||||
@@ -113,11 +113,58 @@ 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") ||
|
||||
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 })];
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ function statusFor(event: GrowEventRow): string {
|
||||
const payload = event.payload ?? {};
|
||||
const explicit = getString(payload.status);
|
||||
if (explicit) return explicit;
|
||||
if (event.type.includes("review_completed") || event.type.includes("completed")) return "completed";
|
||||
if (event.type.includes("review_completed") || event.type.includes("feedback.generated") || event.type.includes("completed")) return "completed";
|
||||
if (event.type.includes("failed")) return "failed";
|
||||
if (event.type.includes("configured") || event.type.includes("created")) return "active";
|
||||
return "active";
|
||||
|
||||
@@ -101,26 +101,26 @@ function actionToEventType(serviceId: ServiceRedisSpec["serviceId"], action: str
|
||||
const effective = msgAction || action || "event";
|
||||
|
||||
if (serviceId === "interview") {
|
||||
if (effective === "interview_configured" || action === "configure_interview") return "interview.configured";
|
||||
if (effective === "interview_configured" || action === "configure_interview") return "interview.session.configured";
|
||||
if (effective === "review_loaded") {
|
||||
const data = asRecord(message.data);
|
||||
return data.status === "completed" ? "interview.review_completed" : "interview.review_processing";
|
||||
return data.status === "completed" ? "interview.feedback.generated" : "interview.feedback.processing";
|
||||
}
|
||||
if (effective === "interview_page_loaded") return "interview.page_state_loaded";
|
||||
return `interview.${effective.replaceAll("_", ".")}`;
|
||||
}
|
||||
|
||||
if (serviceId === "roleplay") {
|
||||
if (effective === "roleplay_configured" || action === "configure_roleplay") return "roleplay.configured";
|
||||
if (effective === "roleplay_configured" || action === "configure_roleplay") return "roleplay.scenario.configured";
|
||||
if (effective === "roleplay_review_loaded" || effective === "review_loaded") {
|
||||
const data = asRecord(message.data);
|
||||
return data.status === "completed" ? "roleplay.review_completed" : "roleplay.review_processing";
|
||||
return data.status === "completed" ? "roleplay.feedback.generated" : "roleplay.feedback.processing";
|
||||
}
|
||||
if (effective === "roleplay_page_loaded") return "roleplay.page_state_loaded";
|
||||
return `roleplay.${effective.replaceAll("_", ".")}`;
|
||||
}
|
||||
|
||||
if (effective === "ai_analysis_complete" || action === "ai_analyze") return "resume.analysis_completed";
|
||||
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";
|
||||
return `resume.${effective.replaceAll("_", ".")}`;
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { config } from "../config.js";
|
||||
import { getService, listServices, type ServiceId } from "../services/service-registry.js";
|
||||
|
||||
export type GrowServiceId = "resume-service" | "interview-service" | "roleplay-service" | "qscore-service" | "social-branding-service" | "matchmaking-service";
|
||||
export type GrowFeatureId = "resume-building" | "mock-interview" | "mock-roleplay" | "q-score" | "social-branding" | "matchmaking";
|
||||
export type GrowServiceId = ServiceId;
|
||||
export type GrowFeatureId =
|
||||
| "resume-building"
|
||||
| "cover-letter"
|
||||
| "mock-interview"
|
||||
| "mock-roleplay"
|
||||
| "q-score"
|
||||
| "social-branding"
|
||||
| "matchmaking"
|
||||
| "pathways"
|
||||
| "courses"
|
||||
| "assessment";
|
||||
|
||||
export type GrowFeatureDefinition = {
|
||||
id: GrowFeatureId;
|
||||
@@ -16,77 +26,18 @@ export type GrowFeatureDefinition = {
|
||||
operations: string[];
|
||||
};
|
||||
|
||||
export const featureDefinitions: GrowFeatureDefinition[] = [
|
||||
{
|
||||
id: "resume-building",
|
||||
serviceId: "resume-service",
|
||||
title: "Resume Building",
|
||||
label: "Resume",
|
||||
description: "Build, tailor, analyze, and improve resumes for role fit and ATS readiness.",
|
||||
promptModulePath: "agents/resume.md",
|
||||
enabled: Boolean(config.resumeServiceUrl),
|
||||
internalUrl: config.resumeServiceUrl,
|
||||
publicUrl: config.resumePublicUrl,
|
||||
operations: ["resume.state", "resume.templates", "resume.a2aTask", "resume.create", "resume.update", "resume.analyze", "resume.suggestions", "resume.copilot", "resume.optimizeSummary", "resume.optimizeExperience", "resume.suggestSkills", "resume.generateSummary", "resume.versions", "resume.preview"],
|
||||
},
|
||||
{
|
||||
id: "mock-interview",
|
||||
serviceId: "interview-service",
|
||||
title: "Mock Interview",
|
||||
label: "Interview",
|
||||
description: "Configure, practice, review, and score interview sessions.",
|
||||
promptModulePath: "agents/interview.md",
|
||||
enabled: Boolean(config.interviewServiceUrl),
|
||||
internalUrl: config.interviewServiceUrl,
|
||||
publicUrl: config.interviewPublicUrl,
|
||||
operations: ["interview.configure", "interview.preview", "interview.questions", "interview.approve", "interview.assignments", "interview.unassign", "interview.resultsBulk", "interview.review", "interview.leaderboard", "interview.artifacts", "interview.videoUpload", "interview.practice"],
|
||||
},
|
||||
{
|
||||
id: "mock-roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
title: "Mock Roleplay",
|
||||
label: "Roleplay",
|
||||
description: "Practice negotiations, recruiter calls, manager conversations, and stakeholder roleplays.",
|
||||
promptModulePath: "agents/roleplay.md",
|
||||
enabled: Boolean(config.roleplayServiceUrl),
|
||||
internalUrl: config.roleplayServiceUrl,
|
||||
publicUrl: config.roleplayPublicUrl,
|
||||
operations: ["roleplay.configure", "roleplay.preview", "roleplay.questions", "roleplay.approve", "roleplay.assignments", "roleplay.unassign", "roleplay.resultsBulk", "roleplay.review", "roleplay.leaderboard", "roleplay.artifacts", "roleplay.videoUpload", "roleplay.practice"],
|
||||
},
|
||||
{
|
||||
id: "q-score",
|
||||
serviceId: "qscore-service",
|
||||
title: "Q Score",
|
||||
label: "Q Score",
|
||||
description: "Analyze overall job-market readiness and convert signals into improvement priorities.",
|
||||
promptModulePath: "agents/qscore.md",
|
||||
enabled: Boolean(config.qscoreServiceUrl),
|
||||
internalUrl: config.qscoreServiceUrl,
|
||||
operations: ["qscore.ingest", "qscore.compute"],
|
||||
},
|
||||
{
|
||||
id: "social-branding",
|
||||
serviceId: "social-branding-service",
|
||||
title: "Social Branding",
|
||||
label: "Branding",
|
||||
description: "Build and optimize your professional profile, LinkedIn presence, and personal brand.",
|
||||
promptModulePath: "agents/social-branding.md",
|
||||
enabled: Boolean(config.socialBrandingServiceUrl),
|
||||
internalUrl: config.socialBrandingServiceUrl,
|
||||
operations: ["branding.profile", "branding.linkedin", "branding.content", "branding.analyze"],
|
||||
},
|
||||
{
|
||||
id: "matchmaking",
|
||||
serviceId: "matchmaking-service",
|
||||
title: "Matchmaking",
|
||||
label: "Matchmaking",
|
||||
description: "Connect with relevant professionals, mentors, and opportunities through curated matching.",
|
||||
promptModulePath: "agents/matchmaking.md",
|
||||
enabled: Boolean(config.matchmakingServiceUrl),
|
||||
internalUrl: config.matchmakingServiceUrl,
|
||||
operations: ["matchmaking.find", "matchmaking.connect", "matchmaking.schedule", "matchmaking.review"],
|
||||
},
|
||||
];
|
||||
export const featureDefinitions: GrowFeatureDefinition[] = listServices().map((service) => ({
|
||||
id: service.featureId as GrowFeatureId,
|
||||
serviceId: service.id,
|
||||
title: service.label,
|
||||
label: service.label,
|
||||
description: service.description,
|
||||
promptModulePath: service.promptModulePath,
|
||||
enabled: service.enabled,
|
||||
internalUrl: service.backend.baseUrl,
|
||||
publicUrl: service.backend.publicUrl,
|
||||
operations: Object.keys(service.backend.endpoints),
|
||||
}));
|
||||
|
||||
export const internalWorkflowModules = [
|
||||
{
|
||||
@@ -103,7 +54,8 @@ export function listFeatureDefinitions() {
|
||||
}
|
||||
|
||||
export function getFeatureByServiceId(serviceId: string) {
|
||||
return featureDefinitions.find((feature) => feature.serviceId === serviceId);
|
||||
const service = getService(serviceId);
|
||||
return service ? featureDefinitions.find((feature) => feature.serviceId === service.id) : undefined;
|
||||
}
|
||||
|
||||
export function displayLabelForService(serviceId: string | undefined) {
|
||||
|
||||
@@ -16,14 +16,33 @@ const notificationSchema = z.object({
|
||||
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 HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 20000);
|
||||
const rawFeedSchema = z.object({
|
||||
notifications: z.array(rawNotificationSchema).min(1).max(24),
|
||||
});
|
||||
|
||||
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 90000);
|
||||
const HOME_FEED_AGENT_ATTEMPTS = Math.max(1, Number(process.env.HOME_FEED_AGENT_ATTEMPTS ?? 2));
|
||||
|
||||
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.
|
||||
@@ -33,27 +52,121 @@ Every notification must point to one of these real dashboard routes:
|
||||
- /agents/roleplay for recruiter/manager/salary/stakeholder roleplay
|
||||
- /agents/qscore for Q Score/readiness explanations
|
||||
- /missions for mission progress, approvals, artifacts, next stages
|
||||
- /social for LinkedIn/social branding
|
||||
- /pathways for locked/coming-soon pathways
|
||||
- /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, qscore, 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("/social")) return "/social";
|
||||
if (href.startsWith("/pathways")) return "/pathways";
|
||||
if (href.startsWith("/agents/social-branding")) return "/agents/social-branding";
|
||||
if (href.startsWith("/agents/matchmaking")) return "/agents/matchmaking";
|
||||
if (href.startsWith("/rewards")) return "/rewards";
|
||||
if (href.startsWith("/productivity")) return "/productivity";
|
||||
return moduleId === "productivity" ? "/productivity" : `/${moduleId}`;
|
||||
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("/agents/qscore")) return "qscore";
|
||||
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 === "qscore") return "Q Score";
|
||||
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 === "qscore") return "/agents/qscore";
|
||||
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 moduleId = raw.moduleId ?? seed?.moduleId ?? (inferredSource ? moduleFromSource(inferredSource) : "suggestions");
|
||||
const rawHref = raw.href ?? seed?.href ?? (inferredSource ? defaultHrefForSource(inferredSource, moduleId) : `/${moduleId}`);
|
||||
const href = sanitizeHref(rawHref, moduleId);
|
||||
const source = raw.source ?? seed?.source ?? 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 candidate = normalizeAgentNotification(seed, 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 {
|
||||
@@ -71,36 +184,47 @@ export async function refineHomeNotificationsWithAgent(input: {
|
||||
context: Record<string, unknown>;
|
||||
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>;
|
||||
}): Promise<Array<AgentHomeNotification & { id: string; createdAt: string }>> {
|
||||
if (!config.llmApiKey && config.nodeEnv === "production") {
|
||||
throw new HomeFeedAgentError("home_feed_agent_missing_llm_api_key");
|
||||
}
|
||||
if (!config.llmApiKey) return [];
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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 = feedSchema.parse(parseJsonObject(result.text));
|
||||
const now = new Date().toISOString();
|
||||
return parsed.notifications.map((n, index) => ({
|
||||
...n,
|
||||
href: sanitizeHref(n.href, n.moduleId),
|
||||
urgency: n.urgency as HomeUrgency,
|
||||
id: stableId("agent-home", index),
|
||||
createdAt: now,
|
||||
}));
|
||||
} catch (err) {
|
||||
log.warn({ err, userId: input.userId }, "home feed agent failed; using deterministic notifications");
|
||||
return [];
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { log } from "../log.js";
|
||||
import {
|
||||
growActiveMissions,
|
||||
growEvents,
|
||||
@@ -15,8 +16,9 @@ import {
|
||||
type NewGrowHomeNotification,
|
||||
} from "../db/schema.js";
|
||||
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
|
||||
import { buildServiceLink } from "../services/service-registry.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
|
||||
import { HomeFeedAgentError, refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
|
||||
import { listAvailableMissionDefinitions } from "../missions/registry.js";
|
||||
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
|
||||
import {
|
||||
@@ -35,16 +37,16 @@ const FRESH_MS = 10 * 60 * 1000;
|
||||
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const SERVICE_HREFS = {
|
||||
resume: "/agents/resume",
|
||||
interview: "/agents/interview",
|
||||
roleplay: "/agents/roleplay",
|
||||
qscore: "/agents/qscore",
|
||||
resume: buildServiceLink("resume-service", "workspace") ?? "/agents/resume",
|
||||
interview: buildServiceLink("interview-service", "discovery") ?? "/agents/interview",
|
||||
roleplay: buildServiceLink("roleplay-service", "discovery") ?? "/agents/roleplay",
|
||||
qscore: buildServiceLink("qscore-service", "dashboard") ?? "/agents/qscore",
|
||||
mission: "/missions/active",
|
||||
social: "/social",
|
||||
pathways: "/pathways",
|
||||
social: buildServiceLink("social-branding-service", "profile") ?? "/agents/social-branding",
|
||||
pathways: buildServiceLink("matchmaking-service", "jobs") ?? "/agents/matchmaking",
|
||||
rewards: "/rewards",
|
||||
suggestions: "/suggestions",
|
||||
productivity: "/productivity",
|
||||
productivity: buildServiceLink("courses-service", "catalog") ?? "/agents/courses",
|
||||
} as const;
|
||||
|
||||
type SeedNotification = Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId; priority: number };
|
||||
@@ -101,20 +103,22 @@ function profileFromPreferences(preferences: Record<string, unknown>) {
|
||||
|
||||
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
|
||||
const profile = profileFromPreferences(ctx.preferences);
|
||||
const params = new URLSearchParams({ source: "home" });
|
||||
if (mission?.instanceId) params.set("missionInstanceId", mission.instanceId);
|
||||
if (mission?.missionId) params.set("missionId", mission.missionId);
|
||||
if (mission?.stageId) params.set("stageId", mission.stageId);
|
||||
params.set("targetRole", profile.targetRole);
|
||||
if (profile.targetCompany !== "target company") params.set("targetCompany", profile.targetCompany);
|
||||
if (profile.industry) params.set("industry", profile.industry);
|
||||
if (profile.focusAreas.length) params.set("focusAreas", profile.focusAreas.slice(0, 4).join(","));
|
||||
if (profile.weakSpots.length) params.set("weakSpots", profile.weakSpots.slice(0, 3).join(","));
|
||||
if (profile.jobDescription) params.set("jobDescription", profile.jobDescription.slice(0, 900));
|
||||
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
|
||||
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
|
||||
if (service === "resume") return `/agents/resume?${params.toString()}`;
|
||||
return `/agents/qscore?${params.toString()}`;
|
||||
const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`;
|
||||
const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup";
|
||||
return buildServiceLink(serviceId, pageId, {
|
||||
source: "home",
|
||||
missionInstanceId: mission?.instanceId,
|
||||
missionId: mission?.missionId,
|
||||
stageId: mission?.stageId ?? undefined,
|
||||
targetRole: profile.targetRole,
|
||||
role: profile.targetRole,
|
||||
targetCompany: profile.targetCompany !== "target company" ? profile.targetCompany : undefined,
|
||||
industry: profile.industry,
|
||||
focusAreas: profile.focusAreas.length ? profile.focusAreas.slice(0, 4).join(",") : undefined,
|
||||
weakSpots: profile.weakSpots.length ? profile.weakSpots.slice(0, 3).join(",") : undefined,
|
||||
jobDescription: profile.jobDescription?.slice(0, 900),
|
||||
type: service === "interview" ? "behavioral" : undefined,
|
||||
}) ?? SERVICE_HREFS[service];
|
||||
}
|
||||
|
||||
function sourceFromSuggestionRole(role: string): HomeSource {
|
||||
@@ -631,23 +635,29 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean; use
|
||||
|
||||
const dayOneSeeds = buildDayOneSeeds();
|
||||
const deterministic = hasAnyRealActivity(ctx) ? buildDynamicSeeds(ctx) : dayOneSeeds;
|
||||
const 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,
|
||||
});
|
||||
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
|
||||
|
||||
@@ -25,12 +25,12 @@ const demoNotifications: DemoNotification[] = [
|
||||
{ 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: "/social", 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: "/social", 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: "/social", source: "social", priority: 96 },
|
||||
{ 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: "/pathways", 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: "/pathways", source: "pathways", priority: 68 },
|
||||
{ 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 },
|
||||
|
||||
@@ -42,8 +42,8 @@ export type HomeFeedResponse = {
|
||||
export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notifications">> = {
|
||||
suggestions: { id: "suggestions", label: "Today's Queue", href: "/suggestions", accent: "orange" },
|
||||
missions: { id: "missions", label: "Missions", href: "/missions", accent: "orange" },
|
||||
social: { id: "social", label: "Social Branding", href: "/social", accent: "blue" },
|
||||
pathways: { id: "pathways", label: "Pathways", href: "/pathways", accent: "teal" },
|
||||
social: { id: "social", label: "Social Branding", href: "/agents/social-branding", accent: "blue" },
|
||||
pathways: { id: "pathways", label: "Pathways", href: "/agents/matchmaking", accent: "teal" },
|
||||
productivity: { id: "productivity", label: "Interview · Roleplay · Resume", href: "/agents", accent: "orange" },
|
||||
rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" },
|
||||
};
|
||||
@@ -62,9 +62,9 @@ export const ALLOWED_NOTIFICATION_HREFS = new Set([
|
||||
"/missions",
|
||||
"/missions/active",
|
||||
"/missions/available",
|
||||
"/social",
|
||||
"/pathways",
|
||||
"/productivity",
|
||||
"/agents/social-branding",
|
||||
"/agents/matchmaking",
|
||||
"/agents",
|
||||
"/rewards",
|
||||
"/agents/resume",
|
||||
"/agents/interview",
|
||||
|
||||
@@ -24,12 +24,14 @@ import { logRoutes } from "./routes/logs.js";
|
||||
import { v1Routes } from "./v1/index.js";
|
||||
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
|
||||
import { db } from "./db/client.js";
|
||||
import { ensureRuntimeSchema } from "./db/ensure-runtime-schema.js";
|
||||
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
|
||||
import { initCatalog } from "./agents/catalog.js";
|
||||
|
||||
async function main() {
|
||||
// Boot-time DB sanity + reconcile + central Gitea readiness.
|
||||
await db.execute("select 1");
|
||||
await ensureRuntimeSchema();
|
||||
await hydratePortAllocator();
|
||||
|
||||
// Ensure central Gitea is reachable before accepting traffic (changes.md §2A).
|
||||
|
||||
@@ -4,7 +4,8 @@ import { missionActions, missionSuggestions } from "../db/schema.js";
|
||||
import type { GrowActiveMission } from "../actors/missions/types.js";
|
||||
import type { MissionActionPatch } from "./reducer-types.js";
|
||||
import { defaultMissionActionStatus, type MissionActionDto, type MissionActionRow, type MissionActionStatus, type NewMissionActionInput } from "./action-types.js";
|
||||
import { missionDetailHref, serviceHref } from "./reducer-helpers.js";
|
||||
import { missionDetailHref } from "./reducer-helpers.js";
|
||||
import { buildServiceLink, getService, getServiceActionLabel } from "../services/service-registry.js";
|
||||
|
||||
const OPEN_STATUSES: MissionActionStatus[] = ["queued", "running", "waiting_approval", "waiting_user_input", "failed"];
|
||||
const DONE_STATUSES: MissionActionStatus[] = ["done", "dismissed", "snoozed"];
|
||||
@@ -48,26 +49,30 @@ export function actionToDto(row: MissionActionRow): MissionActionDto {
|
||||
function ctaForAction(action: MissionActionRow | NewMissionActionInput) {
|
||||
const payload = action.payload && typeof action.payload === "object" && !Array.isArray(action.payload) ? action.payload as Record<string, unknown> : {};
|
||||
const hrefFromPayload = typeof payload.href === "string" ? payload.href : undefined;
|
||||
const serviceId = action.serviceId ?? "";
|
||||
const missionHref = missionDetailHref(action.missionInstanceId);
|
||||
const href = hrefFromPayload ??
|
||||
(serviceId.includes("interview") ? serviceHref("interview", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
|
||||
serviceId.includes("roleplay") ? serviceHref("roleplay", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
|
||||
serviceId.includes("resume") ? serviceHref("resume", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : missionHref);
|
||||
const service = getService(action.serviceId);
|
||||
const href = hrefFromPayload ?? (
|
||||
service
|
||||
? buildServiceLink(service.id, service.curator.defaultPage, {
|
||||
source: "mission",
|
||||
missionInstanceId: action.missionInstanceId,
|
||||
missionId: action.missionId,
|
||||
stageId: action.stageId ?? undefined,
|
||||
}) ?? missionHref
|
||||
: missionHref
|
||||
);
|
||||
|
||||
if (action.mode === "approval_required") return { ctaLabel: "Review", ctaHref: missionHref };
|
||||
if (action.mode === "user_input_required") return { ctaLabel: "Answer", ctaHref: missionHref };
|
||||
if (serviceId.includes("interview")) return { ctaLabel: "Start mock", ctaHref: href };
|
||||
if (serviceId.includes("roleplay")) return { ctaLabel: "Run drill", ctaHref: href };
|
||||
if (serviceId.includes("resume")) return { ctaLabel: "Open resume", ctaHref: href };
|
||||
return { ctaLabel: "Open", ctaHref: href };
|
||||
return { ctaLabel: service ? getServiceActionLabel(service.id, "start") : "Open", ctaHref: href };
|
||||
}
|
||||
|
||||
function suggestionTypeForAction(action: MissionActionRow | NewMissionActionInput) {
|
||||
if (action.mode === "user_input_required") return "blocked" as const;
|
||||
if (action.mode === "approval_required") return "review" as const;
|
||||
if ((action.serviceId ?? "").includes("interview") || (action.serviceId ?? "").includes("roleplay")) return "practice" as const;
|
||||
if ((action.serviceId ?? "").includes("resume")) return "artifact" as const;
|
||||
const category = getService(action.serviceId)?.category;
|
||||
if (category === "practice") return "practice" as const;
|
||||
if (category === "document") return "artifact" as const;
|
||||
return "action" as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { asRecord, getNumber, getString } from "../events/envelope.js";
|
||||
import { buildServiceLink } from "../services/service-registry.js";
|
||||
import type { MissionActionPatch } from "./reducer-types.js";
|
||||
|
||||
export function isResumeEvent(source: string, type: string) {
|
||||
@@ -134,12 +135,10 @@ export function actionForAgent(missionId: string, agent: "planner" | "resume" |
|
||||
}
|
||||
|
||||
export function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", missionInstanceId: string, missionId: string, stageId?: string) {
|
||||
const params = new URLSearchParams({ source: "mission", missionInstanceId, missionId });
|
||||
if (stageId) params.set("stageId", stageId);
|
||||
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
|
||||
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
|
||||
if (service === "resume") return `/agents/resume?${params.toString()}`;
|
||||
return `/agents/qscore?${params.toString()}`;
|
||||
const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`;
|
||||
const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup";
|
||||
return buildServiceLink(serviceId, pageId, { source: "mission", missionInstanceId, missionId, stageId })
|
||||
?? missionDetailHref(missionInstanceId);
|
||||
}
|
||||
|
||||
export function missionDetailHref(missionInstanceId: string) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { config } from "../config.js";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import type { Registry } from "../actors/registry.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents } from "../db/schema.js";
|
||||
import { listActiveMissionsPg } from "../grow/persistence.js";
|
||||
import { listMissionActions } from "../missions/actions.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
@@ -24,7 +29,23 @@ export function analyticsRoutes() {
|
||||
|
||||
app.get("/user/activity", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await getClient().analyticsActor.getOrCreate(["user", userId]).getUserActivity({ userId }));
|
||||
const events = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(eq(growEvents.userId, userId))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(100);
|
||||
const activeMissions = await listActiveMissionsPg(userId).catch(() => []);
|
||||
const actions = await listMissionActions(userId, { openOnly: false }).catch(() => []);
|
||||
|
||||
return c.json({
|
||||
kind: "user-activity",
|
||||
userId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
events,
|
||||
activeMissions: activeMissions.map((item) => item.mission),
|
||||
actions,
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
@@ -2,39 +2,41 @@ 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";
|
||||
|
||||
function canSeedDemo(userId: string) {
|
||||
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
|
||||
}
|
||||
|
||||
async function getUserServiceProfile(req: Request): Promise<{ userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> }> {
|
||||
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) return {};
|
||||
const userProfile = await res.json().catch(() => null) as Record<string, unknown> | null;
|
||||
const preferences = userProfile?.preferences;
|
||||
return {
|
||||
userProfile: userProfile ?? undefined,
|
||||
preferences: preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record<string, unknown> : {},
|
||||
};
|
||||
}
|
||||
|
||||
export function homeRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.get("/feed", async (c) => {
|
||||
const refresh = c.req.query("refresh") === "1" || c.req.query("refresh") === "true";
|
||||
const profile = await getUserServiceProfile(c.req.raw).catch((err) => {
|
||||
log.warn({ err, userId: c.get("userId") }, "home feed continuing without user-service profile");
|
||||
const userId = c.get("userId");
|
||||
const profile = await getRequestUserProfile(c.req.raw, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "home feed continuing without user-service profile");
|
||||
return {};
|
||||
});
|
||||
return c.json(await getHomeFeed(c.get("userId"), { refresh, ...profile }));
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/notifications/:id/dismiss", async (c) => {
|
||||
|
||||
@@ -10,7 +10,6 @@ const LOG_CONTAINERS = {
|
||||
interview: "interview-service-api-1",
|
||||
roleplay: "roleplay-service-api-1",
|
||||
social: "growqr_social_api",
|
||||
pathways: "pathways-service-api-1",
|
||||
courses: "courses_service-api-1",
|
||||
assessment: "assessment-service-api-1",
|
||||
matchmaking: "matchmaking-service-api-1",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { buildDeterministicMissionSuggestions } from "../missions/suggestions.js
|
||||
import { createMissionAction, getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { getRequestUserPreferences } from "../services/user-context.js";
|
||||
import { missionDetailHref } from "../missions/reducer-helpers.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
@@ -105,18 +106,6 @@ async function getMissionSnapshot(userId: string, active: GrowActiveMission): Pr
|
||||
return missionActorFor(userId, active.instanceId, active.actorType).getState();
|
||||
}
|
||||
|
||||
async function getUserPreferences(req: Request): Promise<Record<string, unknown>> {
|
||||
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) return {};
|
||||
const user = await res.json().catch(() => null) as Record<string, unknown> | null;
|
||||
const preferences = user?.preferences;
|
||||
return preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
export function missionRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
@@ -183,7 +172,7 @@ export function missionRoutes() {
|
||||
|
||||
const windowEnd = new Date();
|
||||
const windowStart = new Date(windowEnd.getTime() - 24 * 60 * 60 * 1000);
|
||||
const preferences = await getUserPreferences(c.req.raw);
|
||||
const preferences = await getRequestUserPreferences(c.req.raw, userId) ?? {};
|
||||
const run = await createMissionCoachRunPg({
|
||||
userId,
|
||||
missionInstanceId: active.mission.instanceId,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Hono } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { createHash } from "node:crypto";
|
||||
import { 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, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.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 { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
const LANDING_AGENTS = [
|
||||
@@ -63,7 +66,10 @@ function missionFromRequest(req: Request, body?: JsonObject): Record<string, unk
|
||||
}
|
||||
|
||||
function curatorTaskIdFromRequest(req: Request, body?: JsonObject) {
|
||||
const fromBody = body ? getString((body as Record<string, unknown>).curatorTaskId) : undefined;
|
||||
const params = body && isRecord(body.params) ? body.params : undefined;
|
||||
const fromBody = body
|
||||
? getString((body as Record<string, unknown>).curatorTaskId ?? (body as Record<string, unknown>).taskId ?? params?.curatorTaskId ?? params?.taskId)
|
||||
: undefined;
|
||||
if (fromBody) return fromBody;
|
||||
const url = new URL(req.url);
|
||||
return getString(url.searchParams.get("curatorTaskId"));
|
||||
@@ -75,6 +81,90 @@ function stripMissionFromBody(body: JsonObject): JsonObject {
|
||||
return rest;
|
||||
}
|
||||
|
||||
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("qscore")) return "qscore";
|
||||
if (source.includes("matchmaking")) return "matchmaking";
|
||||
if (source.includes("analytics")) return "analytics";
|
||||
return source;
|
||||
}
|
||||
|
||||
function externalIdFromPayload(payload: Record<string, unknown>, correlation?: Record<string, unknown>) {
|
||||
const result = isRecord(payload.result) ? payload.result : {};
|
||||
return getString(
|
||||
correlation?.externalId ??
|
||||
correlation?.sessionId ??
|
||||
correlation?.resumeId ??
|
||||
result.task_id ??
|
||||
result.taskId ??
|
||||
result.session_id ??
|
||||
result.sessionId ??
|
||||
result.scenario_id ??
|
||||
result.scenarioId ??
|
||||
result.id,
|
||||
);
|
||||
}
|
||||
|
||||
function dedupeSegment(value: unknown) {
|
||||
return getString(value)?.replace(/[^A-Za-z0-9_.:-]+/g, "-");
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
function stableDedupeFingerprint(input: {
|
||||
userId: string;
|
||||
source: string;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
correlation?: Record<string, unknown>;
|
||||
subject?: Record<string, unknown>;
|
||||
}) {
|
||||
return createHash("sha256")
|
||||
.update(stableStringify({
|
||||
userId: input.userId,
|
||||
source: input.source,
|
||||
type: input.type,
|
||||
subject: input.subject,
|
||||
correlation: input.correlation,
|
||||
payload: input.payload,
|
||||
}))
|
||||
.digest("hex")
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
function gatewayDedupeKey(input: {
|
||||
userId: string;
|
||||
source: string;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
correlation?: Record<string, unknown>;
|
||||
subject?: Record<string, unknown>;
|
||||
}) {
|
||||
const result = isRecord(input.payload.result) ? input.payload.result : {};
|
||||
const request = isRecord(input.payload.request) ? input.payload.request : {};
|
||||
const subject = input.subject ?? {};
|
||||
const externalId = externalIdFromPayload(input.payload, input.correlation);
|
||||
const anchor =
|
||||
dedupeSegment(request.request_id ?? request.requestId) ??
|
||||
dedupeSegment(input.correlation?.requestId) ??
|
||||
dedupeSegment(input.correlation?.taskId) ??
|
||||
dedupeSegment(input.correlation?.curatorTaskId) ??
|
||||
dedupeSegment(input.correlation?.sessionId) ??
|
||||
dedupeSegment(input.correlation?.resumeId) ??
|
||||
dedupeSegment(input.correlation?.externalId) ??
|
||||
dedupeSegment(subject.externalId) ??
|
||||
dedupeSegment(externalId) ??
|
||||
dedupeSegment(result.task_id ?? result.taskId ?? result.session_id ?? result.sessionId ?? result.id);
|
||||
return `${input.source}:${input.type}:${input.userId}:${anchor ?? stableDedupeFingerprint(input)}`;
|
||||
}
|
||||
|
||||
async function recordGatewayEvent(input: {
|
||||
userId: string;
|
||||
source: string;
|
||||
@@ -82,6 +172,8 @@ async function recordGatewayEvent(input: {
|
||||
payload: Record<string, unknown>;
|
||||
correlation?: Record<string, unknown>;
|
||||
mission?: Record<string, unknown>;
|
||||
subject?: Record<string, unknown>;
|
||||
dedupeKey?: string;
|
||||
}) {
|
||||
await db.insert(events).values({
|
||||
userId: input.userId,
|
||||
@@ -96,7 +188,12 @@ async function recordGatewayEvent(input: {
|
||||
category: "service",
|
||||
userId: input.userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
dedupeKey: input.dedupeKey ?? gatewayDedupeKey(input),
|
||||
mission: input.mission,
|
||||
subject: input.subject ?? {
|
||||
serviceId: canonicalSubjectServiceId(input.source),
|
||||
externalId: externalIdFromPayload(input.payload, input.correlation) ?? `${canonicalSubjectServiceId(input.source)}:${input.type}`,
|
||||
},
|
||||
correlation: input.correlation,
|
||||
payload: input.payload,
|
||||
});
|
||||
@@ -106,21 +203,107 @@ async function recordGatewayEvent(input: {
|
||||
|
||||
function eventTypeForReview(prefix: "interview" | "roleplay", result: Record<string, unknown>) {
|
||||
const status = getString(result.status);
|
||||
if (status === "completed") return `${prefix}.review_completed`;
|
||||
if (status === "failed") return `${prefix}.review_failed`;
|
||||
return `${prefix}.review_processing`;
|
||||
if (status === "completed") return prefix === "interview" ? "interview.feedback.generated" : "roleplay.feedback.generated";
|
||||
if (status === "failed") return prefix === "interview" ? "interview.feedback.failed" : "roleplay.feedback.failed";
|
||||
return prefix === "interview" ? "interview.feedback.processing" : "roleplay.feedback.processing";
|
||||
}
|
||||
|
||||
function resumeEventTypeForRest(method: string, rest: string, ok: boolean) {
|
||||
if (!ok) return "resume.request_failed";
|
||||
if (method === "POST" && /^resumes\/upload/.test(rest)) return "resume.uploaded";
|
||||
if (method === "POST" && /^parse\/resume\/[^/]+\/parse/.test(rest)) return "resume.parsed";
|
||||
if (method === "POST" && /^ai\/analyze\//.test(rest)) return "resume.analysis_completed";
|
||||
if (method === "POST" && /^ai\/analyze\//.test(rest)) return "resume.analysis.completed";
|
||||
if ((method === "POST" || method === "PUT" || method === "PATCH") && /versions?/.test(rest)) return "resume.version_created";
|
||||
if (method !== "GET") return "resume.updated";
|
||||
return "resume.loaded";
|
||||
}
|
||||
|
||||
function agentDataAction(result: Record<string, unknown>) {
|
||||
const messages = Array.isArray(result.messages) ? result.messages : [];
|
||||
for (const item of messages) {
|
||||
const message = isRecord(item) ? item : {};
|
||||
const action = getString(message.action);
|
||||
if (message.type === "agent_data" && action) return action;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resultHasAgentError(result: Record<string, unknown>) {
|
||||
if (getString(result.status) === "error") return true;
|
||||
const messages = Array.isArray(result.messages) ? result.messages : [];
|
||||
return messages.some((item) => isRecord(item) && item.type === "agent_error");
|
||||
}
|
||||
|
||||
function resumeEventTypeForA2a(action: string | undefined, result: Record<string, unknown>) {
|
||||
if (resultHasAgentError(result)) return "resume.request_failed";
|
||||
const effective = agentDataAction(result) ?? action ?? "";
|
||||
if (["ai_analyze", "analyze_resume", "bg_parse_analyze", "ai_analysis_complete"].includes(effective)) return "resume.analysis.completed";
|
||||
if (["parse_resume", "resume_parsed"].includes(effective)) return "resume.parsed";
|
||||
if (["export_pdf", "export_analysis_pdf", "resume_exported"].includes(effective)) return "resume.exported";
|
||||
if (["create_resume", "resume_created", "save_version", "update_resume_meta"].includes(effective)) return "resume.updated";
|
||||
return "resume.updated";
|
||||
}
|
||||
|
||||
function resumeIdFromA2a(body: JsonObject, result: Record<string, unknown>) {
|
||||
const params = isRecord(body.params) ? body.params : {};
|
||||
const messages = Array.isArray(result.messages) ? result.messages : [];
|
||||
for (const item of messages) {
|
||||
const message = isRecord(item) ? item : {};
|
||||
const data = isRecord(message.data) ? message.data : {};
|
||||
const resume = isRecord(data.resume) ? data.resume : {};
|
||||
const id = getString(data.resume_id ?? data.resumeId ?? resume.id);
|
||||
if (id) return id;
|
||||
}
|
||||
return getString(params.resume_id ?? params.resumeId ?? result.task_id ?? result.taskId);
|
||||
}
|
||||
|
||||
function serviceErrorResponse(err: unknown): never {
|
||||
if (err instanceof ProductServiceError) {
|
||||
let detail: unknown = err.body;
|
||||
try {
|
||||
detail = err.body ? JSON.parse(err.body) : {};
|
||||
} catch {
|
||||
detail = { detail: err.body };
|
||||
}
|
||||
throw new HTTPException(err.status as never, { message: JSON.stringify(detail) });
|
||||
}
|
||||
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" : "");
|
||||
const payload = {
|
||||
...body,
|
||||
action: action === "get_feed" ? "get_scout_feed" : action,
|
||||
user_id: getString(body.user_id) ?? userId,
|
||||
};
|
||||
const res = await fetch(target, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await res.text();
|
||||
const result = text ? JSON.parse(text) as Record<string, unknown> : {};
|
||||
if (!res.ok) throw new HTTPException(res.status as never, { message: text || "matchmaking request failed" });
|
||||
return { action: String(payload.action || action), result };
|
||||
}
|
||||
|
||||
function parseJsonBody(body: ArrayBuffer | undefined, headers: Headers): JsonObject {
|
||||
if (!body || !headers.get("content-type")?.includes("application/json")) return {};
|
||||
try {
|
||||
@@ -208,25 +391,6 @@ function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function getUserServiceProfile(req: Request): Promise<{ userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> }> {
|
||||
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) return {};
|
||||
const userProfile = await res.json().catch(() => null) as Record<string, unknown> | null;
|
||||
const preferences = userProfile?.preferences;
|
||||
return {
|
||||
userProfile: userProfile ?? undefined,
|
||||
preferences: isRecord(preferences) ? preferences : {},
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserServicePreferences(req: Request): Promise<Record<string, unknown> | undefined> {
|
||||
return (await getUserServiceProfile(req)).preferences;
|
||||
}
|
||||
|
||||
async function getServiceState(baseUrl: string, path: string): Promise<Record<string, unknown> | undefined> {
|
||||
const target = new URL(path, baseUrl.replace(/\/$/, ""));
|
||||
const res = await fetch(target, {
|
||||
@@ -253,7 +417,7 @@ function mergeUniqueSkills(existing: unknown, incoming: unknown): string[] {
|
||||
}
|
||||
|
||||
async function resolveGrowUserContext(req: Request, userId: string): Promise<Record<string, unknown>> {
|
||||
const { userProfile } = await getUserServiceProfile(req);
|
||||
const { userProfile } = await getRequestUserProfile(req, userId);
|
||||
const userContext: Record<string, unknown> = { ...(userProfile ?? {}) };
|
||||
userContext.clerk_id = String(userContext.clerk_id ?? userId);
|
||||
|
||||
@@ -442,7 +606,7 @@ export function serviceRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.get("/catalog", (c) => c.json({ services: listServiceCapabilities() }));
|
||||
app.get("/catalog", (c) => c.json({ services: listServiceCapabilities({ public: true }) }));
|
||||
|
||||
app.get("/agents", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
@@ -491,7 +655,7 @@ export function serviceRoutes() {
|
||||
app.get("/qscore/current", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
try {
|
||||
await ensureOnboardingBaselineQscore(userId, await getUserServicePreferences(c.req.raw));
|
||||
await ensureOnboardingBaselineQscore(userId, await getRequestUserPreferences(c.req.raw, userId));
|
||||
} catch (err) {
|
||||
log.warn({ err, userId }, "failed to seed onboarding Q Score baseline before current Q Score read");
|
||||
}
|
||||
@@ -535,7 +699,7 @@ export function serviceRoutes() {
|
||||
? Math.round(signals.reduce((sum, signal) => sum + signal.score, 0) / signals.length)
|
||||
: null;
|
||||
|
||||
return c.json({
|
||||
const response = {
|
||||
qscore: score === null ? null : {
|
||||
score,
|
||||
signalCount: projection?.signalCount ?? signals.length,
|
||||
@@ -551,7 +715,17 @@ export function serviceRoutes() {
|
||||
occurredAt: signal.occurredAt.toISOString(),
|
||||
updatedAt: signal.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "qscore-service",
|
||||
type: "qscore.review.opened",
|
||||
payload: { score, signalCount: signals.length, source: "services.qscore.current" },
|
||||
correlation: { taskId: curatorTaskIdFromRequest(c.req.raw) },
|
||||
}).catch((err) => log.warn({ err, userId }, "failed to record qscore review event"));
|
||||
|
||||
return c.json(response);
|
||||
});
|
||||
|
||||
app.get("/interview/page-state", async (c) => {
|
||||
@@ -574,19 +748,23 @@ export function serviceRoutes() {
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await interviewService.configure(payload);
|
||||
const result = await interviewService.configure(payload).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "interview-service",
|
||||
type: "interview.configured",
|
||||
type: "interview.session.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record interview configured event"));
|
||||
return c.json(result);
|
||||
});
|
||||
app.post("/interview/preview", async (c) => c.json(await interviewService.preview(await c.req.json<JsonObject>())));
|
||||
app.post("/interview/preview", async (c) => {
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, c.get("userId"));
|
||||
return c.json(await interviewService.preview(payload));
|
||||
});
|
||||
app.post("/interview/questions", async (c) => c.json(await interviewService.editQuestions(await c.req.json())));
|
||||
app.post("/interview/approve", async (c) => {
|
||||
const body = await c.req.json<{ session_id: string }>();
|
||||
@@ -599,7 +777,7 @@ export function serviceRoutes() {
|
||||
app.get("/interview/review/:sessionId", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const result = await interviewService.review(sessionId);
|
||||
const result = await interviewService.review(sessionId, userId).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
@@ -611,9 +789,9 @@ export function serviceRoutes() {
|
||||
return c.json(result);
|
||||
});
|
||||
app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard()));
|
||||
app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType"))));
|
||||
app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"))));
|
||||
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"))));
|
||||
app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
|
||||
app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
|
||||
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
|
||||
|
||||
app.get("/roleplay/page-state", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
@@ -635,19 +813,23 @@ export function serviceRoutes() {
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await roleplayService.configure(payload);
|
||||
const result = await roleplayService.configure(payload).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.configured",
|
||||
type: "roleplay.scenario.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id), taskId: curatorTaskIdFromRequest(c.req.raw, body) },
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record roleplay configured event"));
|
||||
return c.json(result);
|
||||
});
|
||||
app.post("/roleplay/preview", async (c) => c.json(await roleplayService.preview(await c.req.json<JsonObject>())));
|
||||
app.post("/roleplay/preview", async (c) => {
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, c.get("userId"));
|
||||
return c.json(await roleplayService.preview(payload));
|
||||
});
|
||||
app.post("/roleplay/questions", async (c) => c.json(await roleplayService.editQuestions(await c.req.json())));
|
||||
app.post("/roleplay/approve", async (c) => {
|
||||
const body = await c.req.json<{ session_id: string }>();
|
||||
@@ -660,7 +842,7 @@ export function serviceRoutes() {
|
||||
app.get("/roleplay/review/:sessionId", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const result = await roleplayService.review(sessionId);
|
||||
const result = await roleplayService.review(sessionId, userId).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
@@ -672,14 +854,29 @@ export function serviceRoutes() {
|
||||
return c.json(result);
|
||||
});
|
||||
app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard()));
|
||||
app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType"))));
|
||||
app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"))));
|
||||
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"))));
|
||||
app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
|
||||
app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
|
||||
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
|
||||
|
||||
app.get("/resume/state/:clerkId", async (c) => c.json(await resumeService.state(c.req.param("clerkId"))));
|
||||
app.post("/resume/tasks", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>();
|
||||
return c.json(await resumeService.task({ ...body, user_id: String(body.user_id ?? c.get("userId")) }));
|
||||
const result = await resumeService.task({ ...body, user_id: String(body.user_id ?? userId) });
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const resumeId = resumeIdFromA2a(body, resultObj);
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "resume-builder",
|
||||
type: resumeEventTypeForA2a(getString(body.action), resultObj),
|
||||
payload: { request: body, result: resultObj },
|
||||
correlation: {
|
||||
taskId: curatorTaskIdFromRequest(c.req.raw, body),
|
||||
resumeId,
|
||||
externalId: resumeId,
|
||||
},
|
||||
}).catch((err) => log.warn({ err, userId, action: body.action }, "failed to record resume A2A workflow event"));
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// Frontend Resume Builder routes should preserve the user's Clerk bearer token
|
||||
@@ -699,5 +896,22 @@ export function serviceRoutes() {
|
||||
return proxySocialRequest(c.req.raw, rest, c.get("userId"));
|
||||
});
|
||||
|
||||
app.post("/matchmaking/a2a", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>().catch(() => ({}));
|
||||
const { action, result } = await callMatchmakingA2a(body, userId);
|
||||
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),
|
||||
},
|
||||
}).catch((err) => log.warn({ err, userId, action }, "failed to record matchmaking workflow event"));
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 { eq } from "drizzle-orm";
|
||||
import { provisionUserStack } from "../docker/manager.js";
|
||||
import { growEvents, users, userStacks, type UserStack } from "../db/schema.js";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { log } from "../log.js";
|
||||
import { config } from "../config.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
@@ -12,6 +11,13 @@ import {
|
||||
runCuratorOnboardingLoopSafely,
|
||||
} from "../v1/curator/curator-onboarding-loop.js";
|
||||
|
||||
const ONBOARDING_LEDGER_EVENT_TYPES = [
|
||||
"onboarding.snapshot.saved",
|
||||
"onboarding.completed",
|
||||
"user.onboarding.completed",
|
||||
"profile.onboarding.completed",
|
||||
] as const;
|
||||
|
||||
function publicStack(stack: UserStack | null | undefined) {
|
||||
if (!stack) return stack;
|
||||
const { opencodePassword: _opencodePassword, ...safe } = stack;
|
||||
@@ -56,6 +62,27 @@ async function ensureUserServiceUser(req: Request) {
|
||||
return res.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
async function getOnboardingEventStatus(userId: string) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: growEvents.id,
|
||||
type: growEvents.type,
|
||||
occurredAt: growEvents.occurredAt,
|
||||
processingStatus: growEvents.processingStatus,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type, [...ONBOARDING_LEDGER_EVENT_TYPES]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function userRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
@@ -83,12 +110,6 @@ export function userRoutes() {
|
||||
where: eq(userStacks.userId, userId),
|
||||
});
|
||||
|
||||
if (!stack || stack.status !== "running") {
|
||||
void provisionUserStack(userId).catch((err) =>
|
||||
log.error({ err, userId }, "background provision failed"),
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
user: userServiceUser,
|
||||
backendUser: userRow,
|
||||
@@ -97,6 +118,25 @@ export function userRoutes() {
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/onboarding-status", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const event = await getOnboardingEventStatus(userId);
|
||||
|
||||
return c.json({
|
||||
userId,
|
||||
hasOnboardingEvent: Boolean(event),
|
||||
onboardingEvent: event
|
||||
? {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
processingStatus: event.processingStatus,
|
||||
}
|
||||
: null,
|
||||
needsOnboarding: !event,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/me", async (c) => proxyUserService(c.req.raw, "/me"));
|
||||
app.patch("/me", async (c) => {
|
||||
const res = await fetchUserService(c.req.raw, "/me");
|
||||
|
||||
@@ -249,7 +249,7 @@ async function runModulesUntilGate(input: {
|
||||
}
|
||||
|
||||
function extractQScore(output: Record<string, unknown>): number | undefined {
|
||||
const direct = output.q_score ?? output.estimated_q_score;
|
||||
const direct = output.q_score;
|
||||
if (typeof direct === "number") return Math.round(direct);
|
||||
const compute = output.compute as Record<string, unknown> | undefined;
|
||||
if (typeof compute?.q_score === "number") return Math.round(compute.q_score);
|
||||
|
||||
@@ -9,9 +9,31 @@ export type ServiceCallOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export class ProductServiceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly body: string,
|
||||
readonly path: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProductServiceError";
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500);
|
||||
const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 120000);
|
||||
|
||||
function userHeader(userId?: string): Record<string, string> | undefined {
|
||||
return userId ? { "x-growqr-user": userId } : undefined;
|
||||
}
|
||||
|
||||
function resolveUserPayload(userIdOrPayload?: string | JsonObject, payload?: JsonObject) {
|
||||
return typeof userIdOrPayload === "string"
|
||||
? { userId: userIdOrPayload, payload }
|
||||
: { userId: undefined, payload: userIdOrPayload };
|
||||
}
|
||||
|
||||
async function serviceJson<T = JsonObject>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
@@ -28,13 +50,16 @@ async function serviceJson<T = JsonObject>(
|
||||
signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_SERVICE_TIMEOUT_MS),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}: ${text}`);
|
||||
if (!res.ok) throw new ProductServiceError(`${path} returned HTTP ${res.status}: ${text}`, res.status, text, path);
|
||||
return (text ? JSON.parse(text) : {}) as T;
|
||||
}
|
||||
|
||||
export const interviewService = {
|
||||
health: () => serviceJson(config.interviewServiceUrl, "/health"),
|
||||
pageState: (userId: string) => serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`),
|
||||
pageState: (userId: string) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
configure: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
preview: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }) =>
|
||||
@@ -49,25 +74,39 @@ export const interviewService = {
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/interviews/assignments/unassign", { body: payload }),
|
||||
resultsBulk: (payload: JsonObject) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/interviews/results:bulk", { body: payload }),
|
||||
review: (sessionId: string) => serviceJson(config.interviewServiceUrl, `/api/v1/review/${encodeURIComponent(sessionId)}`),
|
||||
review: (sessionId: string, userId?: string) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/review/${encodeURIComponent(sessionId)}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
leaderboard: () => serviceJson(config.interviewServiceUrl, "/api/v1/leaderboard"),
|
||||
artifact: (sessionId: string, artifactType: string) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`),
|
||||
createVideoUploadUrl: (sessionId: string, payload?: JsonObject) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, {
|
||||
method: "POST",
|
||||
...(payload === undefined ? {} : { body: payload }),
|
||||
artifact: (sessionId: string, artifactType: string, userId?: string) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
markVideoUploaded: (sessionId: string, payload?: JsonObject) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, {
|
||||
createVideoUploadUrl: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => {
|
||||
const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput);
|
||||
return serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, {
|
||||
method: "POST",
|
||||
headers: userHeader(userId),
|
||||
...(payload === undefined ? {} : { body: payload }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
markVideoUploaded: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => {
|
||||
const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput);
|
||||
return serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, {
|
||||
method: "POST",
|
||||
headers: userHeader(userId),
|
||||
...(payload === undefined ? {} : { body: payload }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const roleplayService = {
|
||||
health: () => serviceJson(config.roleplayServiceUrl, "/health"),
|
||||
pageState: (userId: string) => serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`),
|
||||
pageState: (userId: string) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
configure: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
preview: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }) =>
|
||||
@@ -82,20 +121,31 @@ export const roleplayService = {
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/assignments/unassign", { body: payload }),
|
||||
resultsBulk: (payload: JsonObject) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/results:bulk", { body: payload }),
|
||||
review: (sessionId: string) => serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/review/${encodeURIComponent(sessionId)}`),
|
||||
review: (sessionId: string, userId?: string) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/review/${encodeURIComponent(sessionId)}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
leaderboard: () => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/leaderboard"),
|
||||
artifact: (sessionId: string, artifactType: string) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`),
|
||||
createVideoUploadUrl: (sessionId: string, payload?: JsonObject) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, {
|
||||
method: "POST",
|
||||
...(payload === undefined ? {} : { body: payload }),
|
||||
artifact: (sessionId: string, artifactType: string, userId?: string) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
markVideoUploaded: (sessionId: string, payload?: JsonObject) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, {
|
||||
createVideoUploadUrl: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => {
|
||||
const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput);
|
||||
return serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, {
|
||||
method: "POST",
|
||||
headers: userHeader(userId),
|
||||
...(payload === undefined ? {} : { body: payload }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
markVideoUploaded: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => {
|
||||
const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput);
|
||||
return serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, {
|
||||
method: "POST",
|
||||
headers: userHeader(userId),
|
||||
...(payload === undefined ? {} : { body: payload }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const resumeService = {
|
||||
|
||||
@@ -236,18 +236,12 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// Graceful fallback: formula store unavailable → use static estimate
|
||||
const avgSignalScore = Math.round(
|
||||
signals.reduce((sum, s) => sum + s.score, 0) / signals.length,
|
||||
);
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Q Score estimated Q Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`,
|
||||
status: "unavailable",
|
||||
summary: `Q Score compute failed; no score was generated: ${err instanceof Error ? err.message : String(err)}`,
|
||||
detail: {
|
||||
ingest,
|
||||
estimated_q_score: avgSignalScore,
|
||||
signal_scores: signals.map(s => ({ id: s.signal_id, score: s.score })),
|
||||
compute_fallback: true,
|
||||
compute_error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,69 @@
|
||||
import { config } from "../config.js";
|
||||
import type { CuratorServiceId, CuratorTask } from "../v1/curator/curator-types.js";
|
||||
|
||||
type QueryValue = string | number | undefined | null;
|
||||
export type QueryValue = string | number | boolean | undefined | null;
|
||||
export type QueryState = Record<string, QueryValue>;
|
||||
|
||||
export type ServiceId =
|
||||
| "interview-service"
|
||||
| "roleplay-service"
|
||||
| "courses-service"
|
||||
| "assessment-service"
|
||||
| "matchmaking-service"
|
||||
| "resume-service"
|
||||
| "cover-letter-service"
|
||||
| "qscore-service"
|
||||
| "social-branding-service";
|
||||
|
||||
export type ServiceCategory = "practice" | "learning" | "opportunity" | "document" | "measurement" | "profile";
|
||||
|
||||
export type ServiceEndpoint = {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
path: string;
|
||||
contract: string;
|
||||
usage: string;
|
||||
};
|
||||
|
||||
export type ServiceFrontendPage = {
|
||||
path: string;
|
||||
aliases?: string[];
|
||||
queryParams: string[];
|
||||
usage: string;
|
||||
};
|
||||
|
||||
export type ServiceRecord = {
|
||||
id: ServiceId;
|
||||
label: string;
|
||||
description: string;
|
||||
category: ServiceCategory;
|
||||
enabled: boolean;
|
||||
featureId: string;
|
||||
promptModulePath: string;
|
||||
aliases?: string[];
|
||||
backend: {
|
||||
baseUrl?: string;
|
||||
publicUrl?: string;
|
||||
healthPath: string;
|
||||
endpoints: Record<string, ServiceEndpoint>;
|
||||
usage: string;
|
||||
};
|
||||
frontend: {
|
||||
baseUrl: string;
|
||||
pages: Record<string, ServiceFrontendPage>;
|
||||
usage: string;
|
||||
};
|
||||
curator: {
|
||||
defaultPage: string;
|
||||
defaultActionLabel: string;
|
||||
defaultQueryState?: QueryState;
|
||||
actionLabels?: Record<string, string>;
|
||||
toolName: string;
|
||||
completionEvents: string[];
|
||||
qscoreSignals?: string[];
|
||||
usage: string;
|
||||
};
|
||||
usageDocs: string[];
|
||||
};
|
||||
|
||||
type MissionServiceId = Extract<CuratorServiceId, "interview-service" | "roleplay-service" | "resume-service">;
|
||||
|
||||
@@ -26,10 +89,16 @@ type CuratorRouteInput = {
|
||||
roleplayBrief?: string;
|
||||
};
|
||||
|
||||
function appendQuery(
|
||||
pathname: string,
|
||||
params: Record<string, QueryValue>,
|
||||
) {
|
||||
function endpoint(
|
||||
method: ServiceEndpoint["method"],
|
||||
path: string,
|
||||
contract: string,
|
||||
usage: string,
|
||||
): ServiceEndpoint {
|
||||
return { method, path, contract, usage };
|
||||
}
|
||||
|
||||
function appendQuery(pathname: string, params: QueryState = {}) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
@@ -47,6 +116,645 @@ function getSessionId(detail?: Record<string, unknown>) {
|
||||
return getString(detail?.session_id ?? detail?.sessionId ?? detail?.id);
|
||||
}
|
||||
|
||||
const frontendBaseUrl = config.workflowsDashboardUrl.replace(/\/$/, "");
|
||||
|
||||
const serviceRegistry: ServiceRecord[] = [
|
||||
{
|
||||
id: "interview-service",
|
||||
label: "Interview",
|
||||
description: "Configure, practice, review, and score mock interview sessions.",
|
||||
category: "practice",
|
||||
enabled: Boolean(config.interviewServiceUrl),
|
||||
featureId: "mock-interview",
|
||||
promptModulePath: "agents/interview.md",
|
||||
backend: {
|
||||
baseUrl: config.interviewServiceUrl,
|
||||
publicUrl: config.interviewPublicUrl,
|
||||
healthPath: "/health",
|
||||
endpoints: {
|
||||
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
|
||||
pageState: endpoint("GET", "/api/v1/interviews/page-state?user_id=:userId", "Returns usage and personalization state.", "Hydrate interview landing/setup pages."),
|
||||
configure: endpoint("POST", "/api/v1/configure", "Creates an interview plan from user, org, persona, type, duration, and context.", "Use for committed setup requests."),
|
||||
preview: endpoint("POST", "/api/v1/configure/preview", "Creates a preview plan without starting live practice.", "Use for curator/dashboard previews."),
|
||||
questions: endpoint("POST", "/api/v1/configure/questions", "Edits generated questions for a session.", "Use after preview edits."),
|
||||
approve: endpoint("POST", "/api/v1/configure/approve", "Approves a generated session by session_id.", "Use when a user accepts a preview."),
|
||||
assignments: endpoint("GET", "/api/v1/interviews/assignments", "Lists interview assignments by email/status/limit.", "Render assigned practice work."),
|
||||
createAssignments: endpoint("POST", "/api/v1/interviews/assignments", "Creates interview assignments for assignee emails.", "Admin or organization handoffs."),
|
||||
unassign: endpoint("POST", "/api/v1/interviews/assignments/unassign", "Removes interview assignments.", "Admin cleanup."),
|
||||
resultsBulk: endpoint("POST", "/api/v1/interviews/results:bulk", "Fetches result summaries for multiple sessions.", "Dashboard history and summaries."),
|
||||
review: endpoint("GET", "/api/v1/review/:sessionId", "Returns review/status for a session.", "Poll or open feedback."),
|
||||
leaderboard: endpoint("GET", "/api/v1/leaderboard", "Returns interview leaderboard.", "Leaderboard widgets."),
|
||||
artifact: endpoint("GET", "/api/v1/artifacts/:sessionId/:artifactType", "Returns session artifacts.", "Fetch transcript, report, or media artifacts."),
|
||||
videoUploadUrl: endpoint("POST", "/api/v1/sessions/:sessionId/video/upload-url", "Returns signed upload instructions.", "Browser upload setup."),
|
||||
markVideoUploaded: endpoint("POST", "/api/v1/sessions/:sessionId/video/uploaded", "Marks uploaded video as available.", "Complete upload flow."),
|
||||
},
|
||||
usage: "Backend callers should use the gateway /services/interview/* routes when user auth, mission correlation, and event recording are required.",
|
||||
},
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
pages: {
|
||||
discovery: {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
usage: "Prefer preview for curator links and setup for mission CTAs that still need user choices.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "preview",
|
||||
defaultActionLabel: "Open interview preview",
|
||||
actionLabels: {
|
||||
start: "Start mock",
|
||||
review: "Review interview",
|
||||
},
|
||||
defaultQueryState: {
|
||||
type: "behavioral",
|
||||
persona: "payal",
|
||||
duration: 5,
|
||||
difficulty: "medium",
|
||||
media: "video",
|
||||
},
|
||||
toolName: "prepare_interview_preview",
|
||||
completionEvents: ["interview.session.configured", "interview.feedback.generated", "interview.completed"],
|
||||
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 getServiceEndpoint('interview-service', 'configure') for backend contract metadata.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "roleplay-service",
|
||||
label: "Roleplay",
|
||||
description: "Practice negotiations, recruiter calls, stakeholder conversations, and workplace scenarios.",
|
||||
category: "practice",
|
||||
enabled: Boolean(config.roleplayServiceUrl),
|
||||
featureId: "mock-roleplay",
|
||||
promptModulePath: "agents/roleplay.md",
|
||||
backend: {
|
||||
baseUrl: config.roleplayServiceUrl,
|
||||
publicUrl: config.roleplayPublicUrl,
|
||||
healthPath: "/health",
|
||||
endpoints: {
|
||||
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
|
||||
pageState: endpoint("GET", "/api/v1/roleplays/page-state?user_id=:userId", "Returns usage and personalization state.", "Hydrate roleplay landing/setup pages."),
|
||||
configure: endpoint("POST", "/api/v1/roleplays/configure", "Creates a roleplay scenario from user, org, persona, duration, brief, metadata, qscore, and user_context.", "Use for committed scenario generation."),
|
||||
preview: endpoint("POST", "/api/v1/roleplays/configure/preview", "Creates a roleplay preview.", "Use for curator/dashboard previews."),
|
||||
questions: endpoint("POST", "/api/v1/roleplays/configure/questions", "Edits generated roleplay questions or beats.", "Use after preview edits."),
|
||||
approve: endpoint("POST", "/api/v1/roleplays/configure/approve", "Approves a generated roleplay by session_id.", "Use when a user accepts a preview."),
|
||||
assignments: endpoint("GET", "/api/v1/roleplays/assignments", "Lists roleplay assignments by email/status/limit.", "Render assigned drills."),
|
||||
createAssignments: endpoint("POST", "/api/v1/roleplays/assignments", "Creates roleplay assignments.", "Admin or organization handoffs."),
|
||||
unassign: endpoint("POST", "/api/v1/roleplays/assignments/unassign", "Removes roleplay assignments.", "Admin cleanup."),
|
||||
resultsBulk: endpoint("POST", "/api/v1/roleplays/results:bulk", "Fetches result summaries for multiple sessions.", "Dashboard history and summaries."),
|
||||
review: endpoint("GET", "/api/v1/roleplays/review/:sessionId", "Returns review/status for a roleplay session.", "Poll or open feedback."),
|
||||
leaderboard: endpoint("GET", "/api/v1/roleplays/leaderboard", "Returns roleplay leaderboard.", "Leaderboard widgets."),
|
||||
artifact: endpoint("GET", "/api/v1/artifacts/:sessionId/:artifactType", "Returns session artifacts.", "Fetch transcript, report, or media artifacts."),
|
||||
videoUploadUrl: endpoint("POST", "/api/v1/sessions/:sessionId/video/upload-url", "Returns signed upload instructions.", "Browser upload setup."),
|
||||
markVideoUploaded: endpoint("POST", "/api/v1/sessions/:sessionId/video/uploaded", "Marks uploaded video as available.", "Complete upload flow."),
|
||||
},
|
||||
usage: "Backend callers should use the gateway /services/roleplay/* routes when user auth, mission correlation, and event recording are required.",
|
||||
},
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
pages: {
|
||||
discovery: {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
usage: "Prefer builder for curator links and setup for mission CTAs that still need user choices.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "builder",
|
||||
defaultActionLabel: "Open roleplay preview",
|
||||
actionLabels: {
|
||||
start: "Run drill",
|
||||
review: "Review roleplay",
|
||||
},
|
||||
defaultQueryState: {
|
||||
role: "Professional",
|
||||
persona: "emma",
|
||||
duration: 5,
|
||||
mode: "video",
|
||||
},
|
||||
toolName: "prepare_roleplay_preview",
|
||||
completionEvents: ["roleplay.scenario.configured", "roleplay.feedback.generated", "roleplay.completed"],
|
||||
qscoreSignals: ["communication.roleplay", "networking.conversation", "readiness.practice"],
|
||||
usage: "Include role, brief, mission state, and curatorTaskId when building stateful handoffs.",
|
||||
},
|
||||
usageDocs: [
|
||||
"Call buildServiceLink('roleplay-service', 'builder', state) for curator handoffs.",
|
||||
"Call getServiceEndpoint('roleplay-service', 'configure') for backend contract metadata.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resume-service",
|
||||
label: "Resume",
|
||||
description: "Build, tailor, analyze, version, and preview resumes.",
|
||||
category: "document",
|
||||
enabled: Boolean(config.resumeServiceUrl),
|
||||
featureId: "resume-building",
|
||||
promptModulePath: "agents/resume.md",
|
||||
backend: {
|
||||
baseUrl: config.resumeServiceUrl,
|
||||
publicUrl: config.resumePublicUrl,
|
||||
healthPath: "/health",
|
||||
endpoints: {
|
||||
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
|
||||
state: endpoint("GET", "/api/state/:clerkId", "Returns user resume-builder state.", "Hydrate profile and personalization context."),
|
||||
templates: endpoint("GET", "/api/v1/templates", "Lists resume templates.", "Render template gallery."),
|
||||
a2aTask: endpoint("POST", "/a2a/tasks", "Runs resume-builder agent actions for a user_id.", "Agent/curator orchestrated work."),
|
||||
listResumes: endpoint("GET", "/api/v1/resumes?clerk_id=:clerkId", "Lists resumes for a Clerk user.", "Resume hub."),
|
||||
createResume: endpoint("POST", "/api/v1/resumes", "Creates a resume.", "Resume creation."),
|
||||
getResume: endpoint("GET", "/api/v1/resumes/:resumeId", "Reads a resume.", "Resume editor."),
|
||||
updateResume: endpoint("PUT", "/api/v1/resumes/:resumeId", "Updates a resume.", "Resume editor saves."),
|
||||
analyzeResume: endpoint("POST", "/api/v1/ai/analyze/:resumeId", "Runs AI analysis for a resume.", "Resume score and improvement plan."),
|
||||
suggestions: endpoint("GET", "/api/v1/ai/suggestions/:resumeId", "Returns AI suggestions.", "Editor improvement rail."),
|
||||
copilot: endpoint("POST", "/api/v1/ai/copilot", "Runs resume copilot.", "Inline editing assistant."),
|
||||
versions: endpoint("GET", "/api/v1/resumes/:resumeId/versions", "Lists resume versions.", "Version history."),
|
||||
preview: endpoint("GET", "/api/v1/export/resumes/:resumeId/preview", "Returns resume preview.", "PDF/preview surface."),
|
||||
},
|
||||
usage: "Use gateway /services/resume/* for browser-authenticated requests so Clerk bearer tokens are preserved.",
|
||||
},
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
pages: {
|
||||
workspace: {
|
||||
path: "/agents/resume",
|
||||
queryParams: ["tab", "section", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Resume hub. Use tab=resumes by default and section to deep-link editor panels.",
|
||||
},
|
||||
editor: {
|
||||
path: "/agents/resume/:resumeId",
|
||||
queryParams: ["section"],
|
||||
usage: "Resume editor for a known resume.",
|
||||
},
|
||||
templates: {
|
||||
path: "/agents/resume/templates",
|
||||
queryParams: [],
|
||||
usage: "Template gallery.",
|
||||
},
|
||||
session: {
|
||||
path: "/agents/resume",
|
||||
queryParams: ["goal", "role"],
|
||||
usage: "Legacy service-session launcher used by service agent results.",
|
||||
},
|
||||
},
|
||||
usage: "Curator links should open the workspace unless a concrete resumeId is known.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "workspace",
|
||||
defaultActionLabel: "Open resume workspace",
|
||||
actionLabels: {
|
||||
start: "Open resume",
|
||||
review: "Review resume",
|
||||
},
|
||||
defaultQueryState: {
|
||||
tab: "resumes",
|
||||
},
|
||||
toolName: "prepare_resume_upload",
|
||||
completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated"],
|
||||
qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"],
|
||||
usage: "Include mission state and optional section when linking into resume work.",
|
||||
},
|
||||
usageDocs: [
|
||||
"Call buildServiceLink('resume-service', 'workspace', { tab: 'resumes' }) for curator handoffs.",
|
||||
"Use the resume gateway proxy for browser calls that need Clerk auth.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cover-letter-service",
|
||||
label: "Cover Letter",
|
||||
description: "Generate, tailor, analyze, version, and preview cover letters.",
|
||||
category: "document",
|
||||
enabled: Boolean(config.resumeServiceUrl),
|
||||
featureId: "cover-letter",
|
||||
promptModulePath: "agents/cover-letter.md",
|
||||
aliases: ["coverletter-service"],
|
||||
backend: {
|
||||
baseUrl: config.resumeServiceUrl,
|
||||
publicUrl: config.resumePublicUrl,
|
||||
healthPath: "/health",
|
||||
endpoints: {
|
||||
health: endpoint("GET", "/health", "Readiness probe inherited from resume-builder.", "Check resume-builder availability."),
|
||||
listCoverLetters: endpoint("GET", "/api/v1/cover-letters", "Lists cover letters.", "Cover-letter hub."),
|
||||
createCoverLetter: endpoint("POST", "/api/v1/cover-letters", "Creates a cover letter.", "Manual creation."),
|
||||
getCoverLetter: endpoint("GET", "/api/v1/cover-letters/:coverLetterId", "Reads a cover letter.", "Cover-letter editor."),
|
||||
updateCoverLetter: endpoint("PUT", "/api/v1/cover-letters/:coverLetterId", "Updates a cover letter.", "Editor saves."),
|
||||
generate: endpoint("POST", "/api/v1/cover-letters/generate", "Generates a tailored cover letter.", "Job application handoff."),
|
||||
tailor: endpoint("POST", "/api/v1/cover-letters/:coverLetterId/tailor", "Tailors an existing cover letter.", "Application-specific rewrite."),
|
||||
analyze: endpoint("POST", "/api/v1/cover-letters/:coverLetterId/analyze", "Analyzes a cover letter.", "Strength and fit scoring."),
|
||||
copilot: endpoint("POST", "/api/v1/cover-letters/copilot", "Runs cover-letter copilot.", "Inline editing assistant."),
|
||||
preview: endpoint("GET", "/api/v1/export/cover-letters/:coverLetterId/preview", "Returns cover-letter preview.", "Preview/PDF surface."),
|
||||
},
|
||||
usage: "Cover letters currently live behind resume-builder and the /services/resume/* proxy.",
|
||||
},
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
pages: {
|
||||
workspace: {
|
||||
path: "/agents/resume",
|
||||
queryParams: ["tab", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Open with tab=cover-letters to land in the cover-letter list.",
|
||||
},
|
||||
generator: {
|
||||
path: "/agents/resume",
|
||||
queryParams: ["tab", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Cover-letter generation currently lands in the shared resume workspace with tab=cover-letters.",
|
||||
},
|
||||
editor: {
|
||||
path: "/agents/resume/cover-letters/:coverLetterId",
|
||||
queryParams: [],
|
||||
usage: "Cover-letter editor for a known coverLetterId.",
|
||||
},
|
||||
},
|
||||
usage: "Use workspace with tab=cover-letters for general handoffs.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "workspace",
|
||||
defaultActionLabel: "Open cover letters",
|
||||
actionLabels: {
|
||||
start: "Write cover letter",
|
||||
review: "Review cover letter",
|
||||
},
|
||||
defaultQueryState: {
|
||||
tab: "cover-letters",
|
||||
},
|
||||
toolName: "prepare_cover_letter_handoff",
|
||||
completionEvents: ["cover_letter.generated", "cover_letter.updated", "cover_letter.analysis_completed"],
|
||||
qscoreSignals: ["proof.cover_letter", "readiness.application"],
|
||||
usage: "Use for application-specific artifact tasks; share mission/job context in query or payload.",
|
||||
},
|
||||
usageDocs: [
|
||||
"Call buildServiceLink('cover-letter-service', 'workspace', { tab: 'cover-letters' }) for curator handoffs.",
|
||||
"Backend endpoint metadata maps to resume-builder cover-letter APIs.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "courses-service",
|
||||
label: "Courses",
|
||||
description: "Create, list, and open learning courses.",
|
||||
category: "learning",
|
||||
enabled: Boolean(config.coursesServiceUrl),
|
||||
featureId: "courses",
|
||||
promptModulePath: "agents/courses.md",
|
||||
aliases: ["course-service"],
|
||||
backend: {
|
||||
baseUrl: config.coursesServiceUrl,
|
||||
publicUrl: config.coursesPublicUrl,
|
||||
healthPath: "/api/v1/health",
|
||||
endpoints: {
|
||||
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
|
||||
createCourse: endpoint("POST", "/api/v1/courses", "Creates a course.", "Admin or generated course creation."),
|
||||
listCourses: endpoint("GET", "/api/v1/courses", "Lists courses with pagination/query filters.", "Course catalog."),
|
||||
getCourse: endpoint("GET", "/api/v1/courses/:courseId", "Reads course details.", "Course detail page."),
|
||||
},
|
||||
usage: "Use for learning plan/course catalog handoffs; course generation stays in the service.",
|
||||
},
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
pages: {
|
||||
catalog: {
|
||||
path: "/agents/courses",
|
||||
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Course catalog and learning entry point.",
|
||||
},
|
||||
assigned: {
|
||||
path: "/agents/courses/assigned",
|
||||
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Assigned course rail.",
|
||||
},
|
||||
lesson: {
|
||||
path: "/agents/courses/assigned/:assignmentCode/lesson/:lessonId",
|
||||
queryParams: [],
|
||||
usage: "Assigned course lesson player.",
|
||||
},
|
||||
},
|
||||
usage: "Open catalog for general learning handoffs.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "catalog",
|
||||
defaultActionLabel: "Open courses",
|
||||
actionLabels: {
|
||||
start: "Start course",
|
||||
},
|
||||
toolName: "prepare_course_handoff",
|
||||
completionEvents: ["course.started", "course.completed"],
|
||||
qscoreSignals: ["skills.learning", "readiness.course_progress"],
|
||||
usage: "Use for skill-gap tasks that should become learning work.",
|
||||
},
|
||||
usageDocs: ["Call buildServiceLink('courses-service', 'catalog', state) for course handoffs."],
|
||||
},
|
||||
{
|
||||
id: "assessment-service",
|
||||
label: "Assessment",
|
||||
description: "Create, list, read, and submit assessments.",
|
||||
category: "measurement",
|
||||
enabled: Boolean(config.assessmentServiceUrl),
|
||||
featureId: "assessment",
|
||||
promptModulePath: "agents/assessment.md",
|
||||
backend: {
|
||||
baseUrl: config.assessmentServiceUrl,
|
||||
publicUrl: config.assessmentPublicUrl,
|
||||
healthPath: "/api/v1/health",
|
||||
endpoints: {
|
||||
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
|
||||
createAssessment: endpoint("POST", "/api/v1/assessments", "Creates an assessment.", "Admin or generated assessment creation."),
|
||||
listAssessments: endpoint("GET", "/api/v1/assessments", "Lists assessments with pagination/query filters.", "Assessment catalog."),
|
||||
getAssessment: endpoint("GET", "/api/v1/assessments/:assessmentId", "Reads assessment details.", "Assessment page."),
|
||||
submitAssessment: endpoint("POST", "/api/v1/assessments/:assessmentId/submit", "Submits answers and returns assessment state.", "Completion flow."),
|
||||
},
|
||||
usage: "Use for measurement tasks and proof of skill checks.",
|
||||
},
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
pages: {
|
||||
assessment: {
|
||||
path: "/agents/assessments",
|
||||
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Assessment landing and active assessment surface.",
|
||||
},
|
||||
},
|
||||
usage: "Open assessment for skill/readiness measurement handoffs.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "assessment",
|
||||
defaultActionLabel: "Open assessment",
|
||||
actionLabels: {
|
||||
start: "Start assessment",
|
||||
review: "Review assessment",
|
||||
},
|
||||
toolName: "prepare_assessment_handoff",
|
||||
completionEvents: ["assessment.started", "assessment.submitted", "assessment.completed"],
|
||||
qscoreSignals: ["skills.assessment", "readiness.measurement"],
|
||||
usage: "Use when a task needs a scoreable assessment rather than practice.",
|
||||
},
|
||||
usageDocs: ["Call buildServiceLink('assessment-service', 'assessment', state) for assessment handoffs."],
|
||||
},
|
||||
{
|
||||
id: "matchmaking-service",
|
||||
label: "Matchmaking",
|
||||
description: "Match users to opportunities, employers, mentors, and networking targets.",
|
||||
category: "opportunity",
|
||||
enabled: Boolean(config.matchmakingServiceUrl),
|
||||
featureId: "matchmaking",
|
||||
promptModulePath: "agents/matchmaking.md",
|
||||
aliases: ["jobs-service"],
|
||||
backend: {
|
||||
baseUrl: config.matchmakingServiceUrl,
|
||||
publicUrl: config.matchmakingPublicUrl,
|
||||
healthPath: "/api/v1/health",
|
||||
endpoints: {
|
||||
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
|
||||
preferences: endpoint("GET", "/api/v1/preferences/:userId", "Reads matching preferences.", "Hydrate feed filters and personalization."),
|
||||
writePreferences: endpoint("POST", "/api/v1/preferences", "Writes matching preferences.", "Preference onboarding."),
|
||||
feed: endpoint("GET", "/api/v1/feed", "Returns matched opportunity feed.", "Job/opportunity feed."),
|
||||
feedAction: endpoint("POST", "/api/v1/feed/actions", "Records feed actions.", "Save/apply/dismiss tracking."),
|
||||
opportunity: endpoint("GET", "/api/v1/opportunities/:opportunityId", "Reads opportunity details.", "Opportunity detail panel."),
|
||||
a2aTask: endpoint("POST", "/a2a/tasks", "Runs matching agent tasks.", "Agent/curator orchestrated work."),
|
||||
},
|
||||
usage: "Use for opportunity matching and feed intelligence; keep user-specific actions through authenticated gateway routes when added.",
|
||||
},
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
pages: {
|
||||
jobs: {
|
||||
path: "/agents/matchmaking",
|
||||
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "query", "role", "location"],
|
||||
usage: "Job/opportunity matching dashboard.",
|
||||
},
|
||||
},
|
||||
usage: "Use matchmaking for immediate opportunity matching and Scout-style recommendations.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "jobs",
|
||||
defaultActionLabel: "Open matches",
|
||||
actionLabels: {
|
||||
start: "View matches",
|
||||
review: "Review matches",
|
||||
},
|
||||
toolName: "prepare_matchmaking_handoff",
|
||||
completionEvents: ["matchmaking.feed_viewed", "matchmaking.match_saved", "matchmaking.preference_updated"],
|
||||
qscoreSignals: ["market.matches", "networking.opportunities"],
|
||||
usage: "Use for immediate opportunity matching or mentor/network suggestions.",
|
||||
},
|
||||
usageDocs: [
|
||||
"Call buildServiceLink('matchmaking-service', 'jobs', state) for job matching.",
|
||||
"Use the canonical /agents/matchmaking page for Scout-style opportunity review.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "qscore-service",
|
||||
label: "QScore",
|
||||
description: "Analyze readiness signals and expose score projections.",
|
||||
category: "measurement",
|
||||
enabled: Boolean(config.qscoreServiceUrl),
|
||||
featureId: "q-score",
|
||||
promptModulePath: "agents/qscore.md",
|
||||
aliases: ["q-score-service"],
|
||||
backend: {
|
||||
baseUrl: config.qscoreServiceUrl,
|
||||
publicUrl: config.qscorePublicUrl,
|
||||
healthPath: "/health",
|
||||
endpoints: {
|
||||
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability."),
|
||||
currentGateway: endpoint("GET", "/services/qscore/current", "Backend-projected current score and latest signals.", "Dashboard QScore panel."),
|
||||
ingest: endpoint("POST", "/api/v1/signals", "Ingests score signals when available.", "Service-to-service signal updates."),
|
||||
compute: endpoint("POST", "/api/v1/score/compute", "Computes or refreshes score when available.", "Score recalculation."),
|
||||
},
|
||||
usage: "Use backend gateway /services/qscore/current for dashboard-safe reads; direct service APIs vary by QScore deployment.",
|
||||
},
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
pages: {
|
||||
dashboard: {
|
||||
path: "/agents/qscore",
|
||||
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Dedicated Q Score service page with live readiness signals.",
|
||||
},
|
||||
},
|
||||
usage: "Open the dedicated Q Score service page for score review.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "dashboard",
|
||||
defaultActionLabel: "Review QScore",
|
||||
actionLabels: {
|
||||
review: "Review QScore",
|
||||
},
|
||||
toolName: "prepare_qscore_review",
|
||||
completionEvents: ["qscore.updated", "qscore.signal.projected"],
|
||||
qscoreSignals: ["qscore.updated", "qscore.signal.projected"],
|
||||
usage: "Use for measurement and projected readiness review tasks.",
|
||||
},
|
||||
usageDocs: ["Call buildServiceLink('qscore-service', 'dashboard', state) for QScore handoffs."],
|
||||
},
|
||||
{
|
||||
id: "social-branding-service",
|
||||
label: "Social Branding",
|
||||
description: "Build and optimize professional profile, LinkedIn, content, and brand signals.",
|
||||
category: "profile",
|
||||
enabled: Boolean(config.socialBrandingServiceUrl),
|
||||
featureId: "social-branding",
|
||||
promptModulePath: "agents/social-branding.md",
|
||||
aliases: ["social-service"],
|
||||
backend: {
|
||||
baseUrl: config.socialBrandingServiceUrl,
|
||||
publicUrl: config.socialBrandingPublicUrl,
|
||||
healthPath: "/health",
|
||||
endpoints: {
|
||||
health: endpoint("GET", "/health", "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."),
|
||||
analyze: endpoint("POST", "/api/v1/analyze", "Analyzes profile/social brand when available.", "Brand improvement tasks."),
|
||||
},
|
||||
usage: "Use /services/social/* gateway proxy for browser-authenticated profile calls.",
|
||||
},
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
pages: {
|
||||
profile: {
|
||||
path: "/agents/social-branding",
|
||||
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Social/profile improvement page.",
|
||||
},
|
||||
},
|
||||
usage: "Open profile for branding, LinkedIn, and social proof handoffs.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "profile",
|
||||
defaultActionLabel: "Open social profile flow",
|
||||
actionLabels: {
|
||||
start: "Improve profile",
|
||||
review: "Review profile",
|
||||
},
|
||||
toolName: "prepare_social_branding_handoff",
|
||||
completionEvents: ["social.profile_updated", "social.linkedin_connected", "social.branding_analyzed"],
|
||||
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
|
||||
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
|
||||
},
|
||||
usageDocs: ["Call buildServiceLink('social-branding-service', 'profile', state) for social branding handoffs."],
|
||||
},
|
||||
];
|
||||
|
||||
const serviceAliases = new Map<string, ServiceId>();
|
||||
for (const service of serviceRegistry) {
|
||||
serviceAliases.set(service.id, service.id);
|
||||
for (const alias of service.aliases ?? []) serviceAliases.set(alias, service.id);
|
||||
}
|
||||
|
||||
export function normalizeServiceId(serviceId?: string | null): ServiceId | undefined {
|
||||
if (!serviceId) return undefined;
|
||||
return serviceAliases.get(serviceId);
|
||||
}
|
||||
|
||||
export function listServices() {
|
||||
return serviceRegistry;
|
||||
}
|
||||
|
||||
export function getService(serviceId?: string | null) {
|
||||
const normalized = normalizeServiceId(serviceId);
|
||||
return normalized ? serviceRegistry.find((service) => service.id === normalized) : undefined;
|
||||
}
|
||||
|
||||
export function getServiceBackend(serviceId?: string | null) {
|
||||
return getService(serviceId)?.backend;
|
||||
}
|
||||
|
||||
export function getServiceFrontend(serviceId?: string | null) {
|
||||
return getService(serviceId)?.frontend;
|
||||
}
|
||||
|
||||
export function getServiceEndpoint(serviceId: string | undefined, endpointId: string) {
|
||||
return getService(serviceId)?.backend.endpoints[endpointId];
|
||||
}
|
||||
|
||||
export function getServiceUsageDocs(serviceId?: string | null) {
|
||||
return getService(serviceId)?.usageDocs ?? [];
|
||||
}
|
||||
|
||||
function resolvePage(service: ServiceRecord, pageId?: string) {
|
||||
const selectedPageId = pageId || service.curator.defaultPage;
|
||||
const direct = service.frontend.pages[selectedPageId];
|
||||
if (direct) return direct;
|
||||
return Object.values(service.frontend.pages).find((page) => page.aliases?.includes(selectedPageId));
|
||||
}
|
||||
|
||||
export function buildServiceLink(serviceId: string | undefined, pageId?: string, state: QueryState = {}) {
|
||||
const service = getService(serviceId);
|
||||
if (!service) return undefined;
|
||||
const page = resolvePage(service, pageId);
|
||||
if (!page) return undefined;
|
||||
const includeDefaultState = !pageId || pageId === service.curator.defaultPage;
|
||||
return appendQuery(page.path, {
|
||||
...(includeDefaultState ? service.curator.defaultQueryState : {}),
|
||||
...state,
|
||||
});
|
||||
}
|
||||
|
||||
export function listServicesForCatalog() {
|
||||
return serviceRegistry.map((service) => ({
|
||||
id: service.id,
|
||||
label: service.label,
|
||||
description: service.description,
|
||||
category: service.category,
|
||||
enabled: service.enabled,
|
||||
featureId: service.featureId,
|
||||
backend: {
|
||||
publicUrl: service.backend.publicUrl,
|
||||
healthPath: service.backend.healthPath,
|
||||
endpoints: service.backend.endpoints,
|
||||
usage: service.backend.usage,
|
||||
},
|
||||
frontend: service.frontend,
|
||||
curator: service.curator,
|
||||
usageDocs: service.usageDocs,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildServiceSessionPath(
|
||||
serviceId: MissionServiceId,
|
||||
detail?: Record<string, unknown>,
|
||||
@@ -56,7 +764,7 @@ export function buildServiceSessionPath(
|
||||
|
||||
if (serviceId === "interview-service") {
|
||||
if (!sessionId) return undefined;
|
||||
return appendQuery("/v2/service-sessions/interview", {
|
||||
return buildServiceLink(serviceId, "session", {
|
||||
session_id: sessionId,
|
||||
goal,
|
||||
role: getString(detail?.target_role) ?? goal ?? "Interview practice",
|
||||
@@ -66,7 +774,7 @@ export function buildServiceSessionPath(
|
||||
|
||||
if (serviceId === "roleplay-service") {
|
||||
if (!sessionId) return undefined;
|
||||
return appendQuery("/v2/service-sessions/roleplay", {
|
||||
return buildServiceLink(serviceId, "session", {
|
||||
session_id: sessionId,
|
||||
goal,
|
||||
role: getString(detail?.target_role) ?? goal ?? "Roleplay practice",
|
||||
@@ -74,30 +782,23 @@ export function buildServiceSessionPath(
|
||||
});
|
||||
}
|
||||
|
||||
return appendQuery("/v2/service-sessions/resume", {
|
||||
return buildServiceLink(serviceId, "session", {
|
||||
goal,
|
||||
role: goal,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMissionServiceRoute(input: MissionRouteInput) {
|
||||
const baseParams = {
|
||||
const pageId = input.serviceId === "resume-service" ? "workspace" : "setup";
|
||||
return buildServiceLink(input.serviceId, pageId, {
|
||||
source: "mission",
|
||||
missionInstanceId: input.missionInstanceId,
|
||||
missionId: input.missionId,
|
||||
stageId: input.stageId,
|
||||
goal: input.goal,
|
||||
};
|
||||
|
||||
if (input.serviceId === "interview-service") {
|
||||
return appendQuery("/agents/interview/setup", baseParams);
|
||||
}
|
||||
|
||||
if (input.serviceId === "roleplay-service") {
|
||||
return appendQuery("/agents/roleplay/setup", baseParams);
|
||||
}
|
||||
|
||||
return appendQuery("/agents/resume", baseParams);
|
||||
role: input.goal,
|
||||
type: input.serviceId === "interview-service" ? "behavioral" : undefined,
|
||||
}) ?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId });
|
||||
}
|
||||
|
||||
function curatorBaseParams(input: CuratorRouteInput) {
|
||||
@@ -111,85 +812,64 @@ function curatorBaseParams(input: CuratorRouteInput) {
|
||||
}
|
||||
|
||||
export function buildCuratorServiceRoute(input: CuratorRouteInput) {
|
||||
if (input.serviceId === "interview-service") {
|
||||
return appendQuery("/agents/interview/preview", {
|
||||
...curatorBaseParams(input),
|
||||
role: input.targetRole?.trim() || "Product Manager",
|
||||
type: "behavioral",
|
||||
persona: input.personaId ?? "payal",
|
||||
duration: input.durationMinutes ?? 5,
|
||||
difficulty: input.difficulty ?? "medium",
|
||||
media: input.requestedMode ?? "video",
|
||||
});
|
||||
const service = getService(input.serviceId);
|
||||
if (!service) {
|
||||
return input.missionInstanceId
|
||||
? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId })
|
||||
: "/missions/active";
|
||||
}
|
||||
|
||||
if (input.serviceId === "roleplay-service") {
|
||||
return appendQuery("/agents/roleplay/builder", {
|
||||
...curatorBaseParams(input),
|
||||
role: input.targetRole?.trim() || "Professional",
|
||||
persona: input.personaId ?? "emma",
|
||||
duration: input.durationMinutes ?? 5,
|
||||
mode: input.requestedMode ?? "video",
|
||||
brief: input.roleplayBrief,
|
||||
});
|
||||
const state: QueryState = {
|
||||
...curatorBaseParams(input),
|
||||
};
|
||||
|
||||
if (service.id === "interview-service") {
|
||||
state.role = input.targetRole?.trim() || "Product Manager";
|
||||
state.type = "behavioral";
|
||||
state.persona = input.personaId ?? "payal";
|
||||
state.duration = input.durationMinutes ?? 5;
|
||||
state.difficulty = input.difficulty ?? "medium";
|
||||
state.media = input.requestedMode ?? "video";
|
||||
}
|
||||
|
||||
if (input.serviceId === "resume-service") {
|
||||
return appendQuery("/agents/resume", curatorBaseParams(input));
|
||||
}
|
||||
if (input.serviceId === "qscore-service") {
|
||||
return appendQuery("/analytics", curatorBaseParams(input));
|
||||
}
|
||||
if (input.serviceId === "social-branding-service") {
|
||||
return appendQuery("/social", curatorBaseParams(input));
|
||||
}
|
||||
if (input.serviceId === "matchmaking-service") {
|
||||
return appendQuery("/pathways", curatorBaseParams(input));
|
||||
if (service.id === "roleplay-service") {
|
||||
state.role = input.targetRole?.trim() || "Professional";
|
||||
state.persona = input.personaId ?? "emma";
|
||||
state.duration = input.durationMinutes ?? 5;
|
||||
state.mode = input.requestedMode ?? "video";
|
||||
state.brief = input.roleplayBrief;
|
||||
}
|
||||
|
||||
return input.missionInstanceId
|
||||
? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId })
|
||||
: "/missions/active";
|
||||
return buildServiceLink(service.id, service.curator.defaultPage, state)
|
||||
?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId });
|
||||
}
|
||||
|
||||
export function getServiceDisplayName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
|
||||
if (serviceId === "interview-service") return "Interview service";
|
||||
if (serviceId === "roleplay-service") return "Roleplay service";
|
||||
if (serviceId === "resume-service") return "Resume service";
|
||||
if (serviceId === "qscore-service") return "Q Score service";
|
||||
if (serviceId === "social-branding-service") return "Social branding service";
|
||||
if (serviceId === "matchmaking-service") return "Pathways service";
|
||||
return fallback;
|
||||
export function getServiceDisplayName(serviceId?: string, fallback = "Mission planner") {
|
||||
return getService(serviceId)?.label ?? fallback;
|
||||
}
|
||||
|
||||
export function getServiceToolName(serviceId?: CuratorServiceId) {
|
||||
if (serviceId === "interview-service") return "prepare_interview_preview";
|
||||
if (serviceId === "roleplay-service") return "prepare_roleplay_preview";
|
||||
if (serviceId === "resume-service") return "prepare_resume_upload";
|
||||
if (serviceId === "qscore-service") return "prepare_qscore_review";
|
||||
return "prepare_mission_step";
|
||||
export function getServiceToolName(serviceId?: string) {
|
||||
return getService(serviceId)?.curator.toolName ?? "prepare_mission_step";
|
||||
}
|
||||
|
||||
export function getServiceCompletionEvents(serviceId?: CuratorServiceId) {
|
||||
if (serviceId === "interview-service") {
|
||||
return ["interview.configured", "interview.review_completed", "interview.completed"];
|
||||
}
|
||||
if (serviceId === "roleplay-service") {
|
||||
return ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"];
|
||||
}
|
||||
if (serviceId === "resume-service") {
|
||||
return ["resume.analysis_completed", "resume.parsed", "resume.updated"];
|
||||
}
|
||||
if (serviceId === "qscore-service") {
|
||||
return ["qscore.updated", "qscore.signal_projected"];
|
||||
}
|
||||
return ["curator.task.completed"];
|
||||
export function getCompletionEvents(serviceId?: string) {
|
||||
return getService(serviceId)?.curator.completionEvents ?? ["curator.task.completed"];
|
||||
}
|
||||
|
||||
export function getServiceActionLabel(task: CuratorTask) {
|
||||
if (task.serviceId === "interview-service") return "Open interview preview";
|
||||
if (task.serviceId === "roleplay-service") return "Open roleplay preview";
|
||||
if (task.serviceId === "resume-service") return "Open resume workspace";
|
||||
if (task.serviceId === "qscore-service") return "Review Q Score";
|
||||
return task.cta || "Open";
|
||||
export const getServiceCompletionEvents = getCompletionEvents;
|
||||
|
||||
export function getServiceActionLabel(serviceId?: string, actionId?: string): string;
|
||||
export function getServiceActionLabel(task: Pick<CuratorTask, "serviceId" | "cta">): string;
|
||||
export function getServiceActionLabel(
|
||||
input?: string | Pick<CuratorTask, "serviceId" | "cta">,
|
||||
actionId?: string,
|
||||
) {
|
||||
if (typeof input === "object") {
|
||||
const service = getService(input.serviceId);
|
||||
if (actionId && service?.curator.actionLabels?.[actionId]) return service.curator.actionLabels[actionId];
|
||||
return service?.curator.defaultActionLabel ?? input.cta ?? "Open";
|
||||
}
|
||||
const service = getService(input);
|
||||
if (actionId && service?.curator.actionLabels?.[actionId]) return service.curator.actionLabels[actionId];
|
||||
return service?.curator.defaultActionLabel ?? "Open";
|
||||
}
|
||||
|
||||
102
src/services/user-context.ts
Normal file
102
src/services/user-context.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { config } from "../config.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
|
||||
export type UserProfileContext = {
|
||||
userProfile?: Record<string, unknown>;
|
||||
preferences?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function bearerToken(req: Request): string {
|
||||
return (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "").trim();
|
||||
}
|
||||
|
||||
function isTrustedServiceToken(token: string): boolean {
|
||||
return Boolean(token && (token === config.serviceToken || token === config.a2aAllowedKey));
|
||||
}
|
||||
|
||||
function splitDisplayName(displayName: string | null | undefined) {
|
||||
const parts = (displayName ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
return {
|
||||
firstName: parts[0] || undefined,
|
||||
lastName: parts.length > 1 ? parts.slice(1).join(" ") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProfile(
|
||||
base: UserProfileContext,
|
||||
incoming: Record<string, unknown> | null | undefined,
|
||||
userId: string,
|
||||
): UserProfileContext {
|
||||
const userProfile: Record<string, unknown> = { ...(base.userProfile ?? {}) };
|
||||
if (incoming) {
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
if (value !== null && value !== undefined) userProfile[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
userProfile.clerk_id = String(userProfile.clerk_id ?? userId);
|
||||
const preferences = isRecord(incoming?.preferences) ? incoming.preferences : base.preferences ?? {};
|
||||
return { userProfile, preferences };
|
||||
}
|
||||
|
||||
async function backendMirrorProfile(userId: string): Promise<UserProfileContext> {
|
||||
const row = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
||||
const displayName = row?.displayName ?? userId;
|
||||
const { firstName, lastName } = splitDisplayName(displayName);
|
||||
return {
|
||||
userProfile: {
|
||||
clerk_id: row?.id ?? userId,
|
||||
email: row?.email ?? `${userId}@service.local`,
|
||||
display_name: displayName,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
preferences: {},
|
||||
metadata: { source: "backend_user_mirror" },
|
||||
},
|
||||
preferences: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchUserServiceJson(path: string, headers: Headers): Promise<Record<string, unknown> | null> {
|
||||
const target = new URL(path, config.userServiceUrl.replace(/\/$/, ""));
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json().catch(() => null);
|
||||
return isRecord(json) ? json : null;
|
||||
}
|
||||
|
||||
async function a2aUserState(userId: string): Promise<Record<string, unknown> | null> {
|
||||
const headers = new Headers();
|
||||
headers.set("authorization", `Bearer ${config.a2aAllowedKey}`);
|
||||
return fetchUserServiceJson(`/api/state/${encodeURIComponent(userId)}`, headers);
|
||||
}
|
||||
|
||||
async function clerkUserProfile(req: Request): Promise<Record<string, unknown> | null> {
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
return fetchUserServiceJson("/api/v1/users/me", headers);
|
||||
}
|
||||
|
||||
export async function getRequestUserProfile(req: Request, userId: string): Promise<UserProfileContext> {
|
||||
const base = await backendMirrorProfile(userId);
|
||||
const token = bearerToken(req);
|
||||
|
||||
if (token && !isTrustedServiceToken(token)) {
|
||||
const profile = await clerkUserProfile(req);
|
||||
if (profile) return mergeProfile(base, profile, userId);
|
||||
}
|
||||
|
||||
const state = await a2aUserState(userId);
|
||||
return mergeProfile(base, state, userId);
|
||||
}
|
||||
|
||||
export async function getRequestUserPreferences(req: Request, userId: string): Promise<Record<string, unknown> | undefined> {
|
||||
return (await getRequestUserProfile(req, userId)).preferences;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { Registry } from "../../actors/registry.js";
|
||||
import { getConversationModel } from "../../actors/conversation/agent.js";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growConversationMessages, growEvents } from "../../db/schema.js";
|
||||
import { curatorActor } from "../curator/curator-actor.js";
|
||||
import { curatorService } from "../curator/curator-actor.js";
|
||||
import { curatorImprovementSignalSchema } from "../curator/curator-types.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
@@ -57,7 +57,7 @@ export const analyticsTools = {
|
||||
apply_improvement_to_curator: tool({
|
||||
description: "Apply generated improvement signals to the curator.",
|
||||
inputSchema: z.object({ userId: z.string(), date: z.string(), signals: z.array(curatorImprovementSignalSchema) }),
|
||||
execute: async ({ userId, date, signals }) => curatorActor.applyImprovementSignals({ userId, date, signals }),
|
||||
execute: async ({ userId, date, signals }) => curatorService.applyImprovementSignals({ userId, date, signals }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -103,7 +103,7 @@ export const v1AnalyticsActor = {
|
||||
},
|
||||
|
||||
async applyImprovementSignals(input: { userId: string; date: string; signals: z.infer<typeof curatorImprovementSignalSchema>[] }) {
|
||||
return curatorActor.applyImprovementSignals(input);
|
||||
return curatorService.applyImprovementSignals(input);
|
||||
},
|
||||
|
||||
async runNightly(input: { date: string; userId?: string }) {
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
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 { recordGrowEvent } from "../../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../../events/route-to-user-actor.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 +38,120 @@ 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];
|
||||
|
||||
const response = {
|
||||
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",
|
||||
};
|
||||
|
||||
const event = await recordGrowEvent({
|
||||
source: "growqr-backend:analytics",
|
||||
type: "analytics.insight_snapshot.opened",
|
||||
category: "usage",
|
||||
userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
dedupeKey: `analytics:insight-snapshot:${userId}:${new Date().toISOString().slice(0, 10)}`,
|
||||
subject: {
|
||||
serviceId: "analytics",
|
||||
externalId: "insight-snapshot",
|
||||
},
|
||||
payload: {
|
||||
score,
|
||||
signalCount: projection?.signalCount ?? latestSignals.length,
|
||||
totalEvents14d: counts?.total ?? 0,
|
||||
source: "v1.analytics.insight-snapshot",
|
||||
},
|
||||
});
|
||||
await routeGrowEventToUserActor(event).catch(() => undefined);
|
||||
|
||||
return c.json(response);
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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 { emitCuratorEvent } from "./curator-events.js";
|
||||
@@ -6,7 +7,22 @@ import { prepareHandoffForTask } from "./curator-tools.js";
|
||||
import type { CuratorIcpId } from "./curator-icp-playbooks.js";
|
||||
import { runCuratorOnboardingLoop } from "./curator-onboarding-loop.js";
|
||||
|
||||
export const curatorActor = {
|
||||
type CuratorActorState = {
|
||||
userId: string;
|
||||
planGenerations: number;
|
||||
sprintReads: number;
|
||||
taskCompletions: number;
|
||||
lastActionAt?: string;
|
||||
lastEventId?: string;
|
||||
};
|
||||
|
||||
function touch(c: { state: CuratorActorState }, input: { userId: string }) {
|
||||
if (c.state.userId && c.state.userId !== input.userId) throw new Error("curatorActor initialized for a different user");
|
||||
c.state.userId = input.userId;
|
||||
c.state.lastActionAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -129,3 +145,77 @@ export const curatorActor = {
|
||||
return { tasks: sprint.todayTasks, streak: sprint.streak, sprint };
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
getToday: async (c, input: Parameters<typeof curatorService.getToday>[0]) => {
|
||||
touch(c, input);
|
||||
c.state.sprintReads += 1;
|
||||
return curatorService.getToday(input);
|
||||
},
|
||||
getSprint: async (c, input: Parameters<typeof curatorService.getSprint>[0]) => {
|
||||
touch(c, input);
|
||||
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);
|
||||
c.state.taskCompletions += 1;
|
||||
c.state.lastEventId = result.eventId;
|
||||
return result;
|
||||
},
|
||||
recordServiceImpact: async (c, input: Parameters<typeof curatorService.recordServiceImpact>[0]) => {
|
||||
touch(c, input);
|
||||
c.state.lastEventId = input.eventId;
|
||||
return curatorService.recordServiceImpact(input);
|
||||
},
|
||||
applyImprovementSignals: async (c, input: Parameters<typeof curatorService.applyImprovementSignals>[0]) => {
|
||||
touch(c, input);
|
||||
return curatorService.applyImprovementSignals(input);
|
||||
},
|
||||
getState: async (c, input: Parameters<typeof curatorService.getState>[0]) => {
|
||||
touch(c, input);
|
||||
const state = await curatorService.getState(input);
|
||||
return { ...state, actorState: c.state };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, growHomeNotifications, type GrowEventRow } from "../../db/schema.js";
|
||||
import { asRecord, getString } from "../../events/envelope.js";
|
||||
@@ -44,6 +44,17 @@ function parseCompletedAt(value: unknown): string | undefined {
|
||||
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
||||
}
|
||||
|
||||
function onboardingContextFromPayload(payload: Record<string, unknown>) {
|
||||
const preferences = asRecord(payload.preferences);
|
||||
const onboarding = asRecord(payload.onboarding ?? preferences.onboarding);
|
||||
if (!Object.keys(onboarding).length && !Object.keys(preferences).length) return undefined;
|
||||
return {
|
||||
onboarding,
|
||||
preferences: Object.keys(preferences).length ? preferences : { onboarding },
|
||||
source: "grow_events",
|
||||
};
|
||||
}
|
||||
|
||||
export function onboardingCompletedAtFromPreferences(preferences: Record<string, unknown> | undefined) {
|
||||
const onboarding = asRecord(preferences?.onboarding);
|
||||
return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt);
|
||||
@@ -123,6 +134,22 @@ async function recordOnboardingContextSnapshot(input: {
|
||||
}, { userId: input.userId, source: input.source ?? "onboarding" });
|
||||
}
|
||||
|
||||
async function findLatestOnboardingContext(userId: string) {
|
||||
const [event] = await db
|
||||
.select({ id: growEvents.id, payload: growEvents.payload, occurredAt: growEvents.occurredAt, source: growEvents.source, type: growEvents.type })
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type, ["onboarding.snapshot.saved", "onboarding.completed", "user.onboarding.completed", "profile.onboarding.completed"]),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(1);
|
||||
|
||||
if (!event?.payload) return undefined;
|
||||
const context = onboardingContextFromPayload(event.payload);
|
||||
return context ? { ...context, sourceEventId: event.id, sourceEventType: event.type, sourceEventSource: event.source } : undefined;
|
||||
}
|
||||
|
||||
async function fetchUserServiceContext(userId: string): Promise<Record<string, unknown> | undefined> {
|
||||
const token = config.serviceToken || (config.nodeEnv !== "production" ? config.a2aAllowedKey : "");
|
||||
if (!token) return undefined;
|
||||
@@ -228,7 +255,7 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
|
||||
}
|
||||
|
||||
const startDate = isoDateFrom(input.completedAt);
|
||||
const context = input.context ?? await fetchUserServiceContext(userId);
|
||||
const context = input.context ?? await findLatestOnboardingContext(userId) ?? await fetchUserServiceContext(userId);
|
||||
await recordOnboardingContextSnapshot({
|
||||
userId,
|
||||
startDate,
|
||||
@@ -272,6 +299,7 @@ export async function runCuratorOnboardingLoopForEvent(event: GrowEventRow): Pro
|
||||
completedAt: onboardingCompletedAtFromEvent(event),
|
||||
sourceEventId: event.id,
|
||||
source: event.source,
|
||||
context: onboardingContextFromPayload(event.payload ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { requireUser, type AuthContext } from "../../auth/clerk.js";
|
||||
import { curatorActor } from "./curator-actor.js";
|
||||
import { config } from "../../config.js";
|
||||
import type { Registry } from "../../actors/registry.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
|
||||
}
|
||||
|
||||
function getCuratorActor(userId: string) {
|
||||
return getClient().curatorActor.getOrCreate(["user", userId]);
|
||||
}
|
||||
|
||||
const chatSchema = z.object({
|
||||
conversationId: z.string().optional(),
|
||||
@@ -31,12 +42,12 @@ export function v1CuratorRoutes() {
|
||||
goals: z.array(z.string()).optional(),
|
||||
forceRegenerate: z.boolean().optional(),
|
||||
}).parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await curatorActor.generatePlanRange({ userId, ...body }));
|
||||
return c.json(await getCuratorActor(userId).generatePlanRange({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.get("/plan", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await curatorActor.getPlan({
|
||||
return c.json(await getCuratorActor(userId).getPlan({
|
||||
userId,
|
||||
startDate: c.req.query("startDate"),
|
||||
endDate: c.req.query("endDate"),
|
||||
@@ -45,18 +56,18 @@ export function v1CuratorRoutes() {
|
||||
|
||||
app.get("/today", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await curatorActor.getToday({ userId, date: c.req.query("date") }));
|
||||
return c.json(await getCuratorActor(userId).getToday({ userId, date: c.req.query("date") }));
|
||||
});
|
||||
|
||||
app.get("/sprint", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await curatorActor.getSprint({ userId, date: c.req.query("date") }));
|
||||
return c.json(await getCuratorActor(userId).getSprint({ userId, date: c.req.query("date") }));
|
||||
});
|
||||
|
||||
app.post("/curation/preview", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = curationPreviewSchema.parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await curatorActor.previewCuration({ userId, ...body }));
|
||||
return c.json(await getCuratorActor(userId).previewCuration({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.post("/onboarding/run", async (c) => {
|
||||
@@ -64,40 +75,40 @@ export function v1CuratorRoutes() {
|
||||
const body = z.object({
|
||||
completedAt: z.string().optional(),
|
||||
}).parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await curatorActor.runOnboardingLoop({ userId, ...body }));
|
||||
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 curatorActor.chat({ userId, ...body }));
|
||||
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 curatorActor.startTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
|
||||
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 curatorActor.prepareTaskHandoff({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
|
||||
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(() => ({})));
|
||||
return c.json(await curatorActor.completeTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date"), reason: body.reason }));
|
||||
return c.json(await getCuratorActor(userId).completeTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date"), reason: body.reason }));
|
||||
});
|
||||
|
||||
app.post("/events/service-impact", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({ eventId: z.string() }).parse(await c.req.json());
|
||||
return c.json(await curatorActor.recordServiceImpact({ userId, eventId: body.eventId }));
|
||||
return c.json(await getCuratorActor(userId).recordServiceImpact({ userId, eventId: body.eventId }));
|
||||
});
|
||||
|
||||
app.get("/state", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await curatorActor.getState({ userId }));
|
||||
return c.json(await getCuratorActor(userId).getState({ userId }));
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
@@ -21,17 +21,18 @@ import type {
|
||||
import { buildCuratorTaskDeepLink, completionEventsForService, serviceName, serviceToolName } from "./curator-service-links.js";
|
||||
|
||||
const VALID_COMPLETION_TYPES = [
|
||||
"resume.analysis_completed",
|
||||
"service.completed",
|
||||
"resume.analysis.completed",
|
||||
"resume.parsed",
|
||||
"resume.updated",
|
||||
"interview.configured",
|
||||
"interview.review_completed",
|
||||
"interview.session.configured",
|
||||
"interview.feedback.generated",
|
||||
"interview.completed",
|
||||
"roleplay.configured",
|
||||
"roleplay.review_completed",
|
||||
"roleplay.scenario.configured",
|
||||
"roleplay.feedback.generated",
|
||||
"roleplay.completed",
|
||||
"qscore.updated",
|
||||
"qscore.signal_projected",
|
||||
"qscore.signal.projected",
|
||||
"curator.task.completed",
|
||||
] as const;
|
||||
|
||||
@@ -82,7 +83,7 @@ type PlanDaySeed = {
|
||||
weekTheme: string;
|
||||
weekSummary: string;
|
||||
focus: string;
|
||||
plannedTasks: [PlannedTask, PlannedTask, PlannedTask];
|
||||
plannedTasks: PlannedTask[];
|
||||
generationStatus: "seeded" | "generated" | "adapted";
|
||||
adaptationReason?: string;
|
||||
};
|
||||
@@ -101,6 +102,60 @@ type ServiceCurationPreviewInput = {
|
||||
userContext?: Partial<Awaited<ReturnType<typeof buildCuratorUserContext>>>;
|
||||
};
|
||||
|
||||
const QSCORE_TASK_SERVICE_REPLACEMENTS: Record<CuratorTaskType, CuratorServiceId> = {
|
||||
measurement: "assessment-service",
|
||||
proof: "resume-service",
|
||||
practice: "interview-service",
|
||||
recovery: "roleplay-service",
|
||||
};
|
||||
|
||||
function curatorAssignableServiceId(serviceId: CuratorServiceId, taskType: CuratorTaskType): CuratorServiceId {
|
||||
return serviceId === "qscore-service" ? QSCORE_TASK_SERVICE_REPLACEMENTS[taskType] : serviceId;
|
||||
}
|
||||
|
||||
function qscoreFreeTaskCopy(input: {
|
||||
serviceId: CuratorServiceId;
|
||||
taskType: CuratorTaskType;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cta: string;
|
||||
signals: string[];
|
||||
}) {
|
||||
if (input.serviceId !== "qscore-service") {
|
||||
return {
|
||||
title: input.title,
|
||||
subtitle: input.subtitle,
|
||||
cta: input.cta,
|
||||
signals: input.signals,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.taskType === "proof") {
|
||||
return {
|
||||
title: input.title.replace(/Q Score|QScore/gi, "resume proof"),
|
||||
subtitle: input.subtitle.replace(/Q Score|QScore/gi, "resume proof"),
|
||||
cta: "Open resume workspace",
|
||||
signals: input.signals.map((signal) => signal.replace(/q\s?score/gi, "resume proof")),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.taskType === "practice") {
|
||||
return {
|
||||
title: input.title.replace(/Q Score|QScore/gi, "interview practice"),
|
||||
subtitle: input.subtitle.replace(/Q Score|QScore/gi, "interview practice"),
|
||||
cta: "Open interview preview",
|
||||
signals: input.signals.map((signal) => signal.replace(/q\s?score/gi, "interview practice")),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: input.title.replace(/Q Score|QScore/gi, "readiness assessment"),
|
||||
subtitle: input.subtitle.replace(/Q Score|QScore/gi, "the assessment service"),
|
||||
cta: "Open assessment",
|
||||
signals: input.signals.map((signal) => signal.replace(/q\s?score/gi, "assessment")),
|
||||
};
|
||||
}
|
||||
|
||||
const FRESHER_WEEK_TEMPLATES: WeekTemplate[] = [
|
||||
{
|
||||
theme: "Baseline + First Proof",
|
||||
@@ -810,7 +865,9 @@ function seed(
|
||||
cta: string,
|
||||
signals: string[],
|
||||
): TaskSeed {
|
||||
return { taskType, serviceId, title, subtitle, effort, qxImpact, cta, signals };
|
||||
const assignedServiceId = curatorAssignableServiceId(serviceId, taskType);
|
||||
const copy = qscoreFreeTaskCopy({ serviceId, taskType, title, subtitle, cta, signals });
|
||||
return { taskType, serviceId: assignedServiceId, title: copy.title, subtitle: copy.subtitle, effort, qxImpact, cta: copy.cta, signals: copy.signals };
|
||||
}
|
||||
|
||||
function todayIso(date = new Date()) {
|
||||
@@ -873,6 +930,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 +1116,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 +1145,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 +1195,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 +1275,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 +1313,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 +1336,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 +1430,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 +1455,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 +1513,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 +1528,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 +1553,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 +1574,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 +1584,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 +1598,8 @@ function buildTasksForPlanDay(
|
||||
planDay.weekSummary,
|
||||
task,
|
||||
completionRows,
|
||||
recentRows,
|
||||
focusDate,
|
||||
targetRole,
|
||||
)
|
||||
));
|
||||
@@ -1446,7 +1623,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 +1682,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 +1699,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 +1801,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),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1669,7 +1848,7 @@ export async function listCuratorRegistryCapabilities() {
|
||||
title: mission.title,
|
||||
modules: mission.modules.map((module) => ({ id: module.id, title: module.title, service: module.service, role: module.role })),
|
||||
})),
|
||||
services: listServiceCapabilities(),
|
||||
services: listServiceCapabilities().filter((service) => service.id !== "qscore-service"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -71,9 +72,9 @@ async function latestInterviewResumeEvidence(userId: string) {
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type as any, [
|
||||
"interview.review_completed",
|
||||
"interview.feedback.generated",
|
||||
"interview.completed",
|
||||
"roleplay.review_completed",
|
||||
"roleplay.feedback.generated",
|
||||
"roleplay.completed",
|
||||
]),
|
||||
))
|
||||
@@ -176,9 +177,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 {
|
||||
@@ -269,9 +277,9 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat
|
||||
execute: async ({ taskId }) => {
|
||||
const task = taskId ? await findTask(ctx.userId, taskId, ctx.date) : null;
|
||||
return {
|
||||
routes: ["interview-service", "resume-service", "roleplay-service", "qscore-service"].map((serviceId) => ({
|
||||
serviceId,
|
||||
route: serviceRoute({ serviceId: serviceId as any, missionId: task?.missionId, missionInstanceId: task?.missionInstanceId, stageId: task?.stageId, taskId: task?.id }),
|
||||
routes: listServices().map((service) => ({
|
||||
serviceId: service.id,
|
||||
route: serviceRoute({ serviceId: service.id, missionId: task?.missionId, missionInstanceId: task?.missionInstanceId, stageId: task?.stageId, taskId: task?.id }),
|
||||
})),
|
||||
};
|
||||
},
|
||||
@@ -489,13 +497,9 @@ export function buildCuratorTools(ctx: { userId: string; date: string; conversat
|
||||
}),
|
||||
|
||||
prepare_qscore_review: tool({
|
||||
description: "Prepare a Q-score review handoff.",
|
||||
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 ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "qscore-service");
|
||||
},
|
||||
execute: async () => ({ error: "qscore_curator_handoff_disabled", replacementServices: ["assessment-service", "interview-service", "roleplay-service", "resume-service", "matchmaking-service", "courses-service", "social-branding-service"] }),
|
||||
}),
|
||||
|
||||
emit_curator_event: tool({
|
||||
|
||||
@@ -3,7 +3,10 @@ import { z } from "zod";
|
||||
export const curatorServiceIdSchema = z.enum([
|
||||
"interview-service",
|
||||
"resume-service",
|
||||
"cover-letter-service",
|
||||
"roleplay-service",
|
||||
"courses-service",
|
||||
"assessment-service",
|
||||
"qscore-service",
|
||||
"social-branding-service",
|
||||
"matchmaking-service",
|
||||
@@ -11,7 +14,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([
|
||||
@@ -20,6 +23,9 @@ export const curatorTaskStatusSchema = z.enum([
|
||||
"handoff_prepared",
|
||||
"completed",
|
||||
"blocked",
|
||||
"partial",
|
||||
"skipped",
|
||||
"abandoned",
|
||||
]);
|
||||
|
||||
export const curatorWeekLifecycleSchema = z.enum(["done", "active", "upcoming"]);
|
||||
@@ -69,7 +75,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),
|
||||
@@ -128,7 +134,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;
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export async function updateRunProgress(runId: string) {
|
||||
}
|
||||
|
||||
function extractQScore(output: Record<string, unknown>): number | undefined {
|
||||
const direct = output.q_score ?? output.estimated_q_score;
|
||||
const direct = output.q_score;
|
||||
if (typeof direct === "number") return Math.round(direct);
|
||||
const compute = output.compute as Record<string, unknown> | undefined;
|
||||
if (typeof compute?.q_score === "number") return Math.round(compute.q_score);
|
||||
|
||||
@@ -1,28 +1,55 @@
|
||||
import { listFeatureDefinitions, internalWorkflowModules } from "../features/registry.js";
|
||||
import { internalWorkflowModules } from "../features/registry.js";
|
||||
import { listServices } from "../services/service-registry.js";
|
||||
|
||||
export type ServiceCapability = {
|
||||
id: string;
|
||||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
enabled: boolean;
|
||||
internalUrl?: string;
|
||||
publicUrl?: string;
|
||||
operations: string[];
|
||||
featureId?: string;
|
||||
promptModulePath?: string;
|
||||
healthPath?: string;
|
||||
backend?: unknown;
|
||||
frontend?: unknown;
|
||||
curator?: unknown;
|
||||
usageDocs?: string[];
|
||||
};
|
||||
|
||||
export function listServiceCapabilities(): ServiceCapability[] {
|
||||
export function listServiceCapabilities(opts: { public?: boolean } = {}): ServiceCapability[] {
|
||||
const services = listServices().map((service) => ({
|
||||
id: service.id,
|
||||
name: service.label,
|
||||
label: service.label,
|
||||
description: service.description,
|
||||
category: service.category,
|
||||
enabled: service.enabled,
|
||||
...(opts.public ? {} : { internalUrl: service.backend.baseUrl }),
|
||||
publicUrl: service.backend.publicUrl,
|
||||
operations: Object.keys(service.backend.endpoints),
|
||||
featureId: service.featureId,
|
||||
promptModulePath: service.promptModulePath,
|
||||
healthPath: service.backend.healthPath,
|
||||
backend: {
|
||||
...(opts.public ? {} : { baseUrl: service.backend.baseUrl }),
|
||||
publicUrl: service.backend.publicUrl,
|
||||
healthPath: service.backend.healthPath,
|
||||
endpoints: service.backend.endpoints,
|
||||
usage: service.backend.usage,
|
||||
},
|
||||
frontend: service.frontend,
|
||||
curator: service.curator,
|
||||
usageDocs: service.usageDocs,
|
||||
}));
|
||||
|
||||
if (opts.public) return services;
|
||||
|
||||
return [
|
||||
...listFeatureDefinitions().map((feature) => ({
|
||||
id: feature.serviceId,
|
||||
name: feature.title,
|
||||
enabled: feature.enabled,
|
||||
internalUrl: feature.internalUrl,
|
||||
publicUrl: feature.publicUrl,
|
||||
operations: feature.operations,
|
||||
featureId: feature.id,
|
||||
promptModulePath: feature.promptModulePath,
|
||||
})),
|
||||
...services,
|
||||
...internalWorkflowModules.map((module) => ({
|
||||
id: module.id,
|
||||
name: module.title,
|
||||
|
||||
Reference in New Issue
Block a user