Compare commits
89 Commits
chore/rele
...
backend-ca
| Author | SHA1 | Date | |
|---|---|---|---|
| a6b0cf3a00 | |||
|
|
dfc45fbea2 | ||
| 210b577462 | |||
|
|
60332956a0 | ||
|
|
1b90e5db39 | ||
|
|
52589fc76d | ||
| 685f2dcd24 | |||
|
|
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 | ||
| 17a888bd67 | |||
|
|
1be3ab1961 | ||
|
|
bd582fc6c4 | ||
|
|
2c5cf1bcf8 | ||
|
|
292e375a37 | ||
|
|
9a6518a5d8 | ||
|
|
c66360cb7e | ||
|
|
abeefc221b | ||
|
|
20c18583db | ||
|
|
27c9f58b80 | ||
|
|
c73b1a1788 | ||
|
|
447b5ca726 | ||
|
|
e8b4634dd1 | ||
|
|
a41e8be1e1 | ||
|
|
38e68d8273 | ||
|
|
1d887bc153 | ||
|
|
c46b9b11f6 | ||
|
|
fe449fdc50 | ||
| 9b6f887c3f | |||
|
|
89e1be4b12 | ||
|
|
2ccc0ea48d | ||
|
|
3fecfdc403 | ||
|
|
37fa8f13f4 | ||
|
|
9bb2c0de3f | ||
|
|
368410e9d8 | ||
|
|
4b23dd0905 | ||
|
|
60b1df6892 | ||
|
|
ed7233d6e2 | ||
|
|
4a20816ba0 | ||
|
|
036aff1d1d | ||
| 72b3f03dad | |||
|
|
41b0c69326 | ||
| 92ab414048 | |||
|
|
9fd478c095 | ||
|
|
f0ef57f054 | ||
|
|
dd48321904 | ||
|
|
bef6d08b6b | ||
|
|
170d3583c6 | ||
|
|
aa8f2853b2 | ||
|
|
c47e6de526 | ||
|
|
5f667038d8 | ||
|
|
ef5d7bb378 | ||
|
|
d4f9b0edcb | ||
|
|
01e9cc92d4 | ||
|
|
213987a9e0 | ||
|
|
8e4fdc6adf | ||
|
|
d10ef2a882 |
@@ -2,17 +2,20 @@ 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
|
||||
ENV NODE_ENV=production
|
||||
ENV RIVET_RUNNER_VERSION=$RIVET_RUNNER_VERSION
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY package.json ./
|
||||
|
||||
9
docker-compose.override.yml
Normal file
9
docker-compose.override.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
# VPS override: make host.docker.internal resolve to the host so the
|
||||
# backend container can reach product services + spawned per-user
|
||||
# containers published on host ports (Linux has no built-in mapping).
|
||||
services:
|
||||
backend:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:8015
|
||||
@@ -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,11 +118,17 @@ 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:
|
||||
# Docker-out-of-Docker: backend uses host Docker to spawn per-user OpenCode containers.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./prompts:/app/prompts
|
||||
# Shared host dir that per-user containers will also bind-mount their
|
||||
# workspace from (so backend and spawned containers see the same files).
|
||||
- ./.data/users:/data/users
|
||||
|
||||
84
docs/backend-dead-code.md
Normal file
84
docs/backend-dead-code.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Backend Dead Code Inventory
|
||||
|
||||
PRM-46 inventory pass for `growqr-backend`.
|
||||
|
||||
No source code was deleted in this pass. Static search and manual inspection were used. Typecheck was run successfully with `pnpm typecheck`.
|
||||
|
||||
## Summary
|
||||
|
||||
The codebase is mostly wired, but it contains several compatibility, demo, and partially superseded paths. The main cleanup risk is accidentally removing code still used by the frontend's older workflow screens or by demo environments.
|
||||
|
||||
## Candidates
|
||||
|
||||
| Priority | Candidate | Recommendation | Evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| High | `src/actors/product-service-actors.ts` | Keep for now; consider deleting only after confirming no Rivet clients call these actors. | Actors are registered in `src/actors/registry.ts`, but local code routes service calls through `src/routes/services.ts` and `src/services/product-service-clients.ts` directly. No local `getOrCreate` references for `interviewServiceActor`, `roleplayServiceActor`, or `resumeServiceActor` were found. |
|
||||
| High | Legacy `/workflows/job-application*` route aliases in `src/routes/workflows.ts` and large portions of `src/actors/user-actor.ts` workflow state | Keep until frontend migration is verified; likely cleanup after DB-backed workflow runs fully replace it. | `job-application` aliases call `userActor`; newer `/workflow-runs` path uses `workflowRuns`, `workflowRunModules`, and `workflowRunActor`/`executeWorkflowModule`. Two workflow systems coexist. |
|
||||
| High | `src/workflows/module-runner.ts` synchronous execution from routes | Keep, but consolidate behind `workflowRunActor` before cleanup. | Used both by `workflowRunActor` and directly by route handlers. Direct route use undercuts actor durability, but the module runner itself is active. |
|
||||
| Medium | `src/workflows/smoke-test.ts` | Keep as script if used manually; otherwise convert to documented test or remove. | Only referenced by `package.json` script `workflows:smoke`; not part of app runtime. |
|
||||
| Medium | `scripts/rivet-actors.ts` | Keep if used by ops; document or remove if not. | Standalone admin script; not imported by source. It relies on `RIVET_ENDPOINT`, `RIVET_NAMESPACE`, and admin token defaults. |
|
||||
| Medium | Demo home seeder `src/home/seed-demo-home.ts` and `/home/seed-demo` | Keep in staging/demo only; move behind explicit environment gate. | `src/routes/home.ts` exposes a seed endpoint. Schema has `generatedBy: "demo"` for notifications. This is live source behavior rather than isolated fixture code. |
|
||||
| Medium | Static fallback mission registry vs persisted registry (`src/missions/registry.ts` and `src/missions/postgres-registry.ts`) | Keep both until migration/backfill is confirmed; then decide whether DB registry or static registry is source of truth. | `routes/missions.ts` reads persisted definitions, while actor factory and conversations read static definitions. `postgres-registry` falls back to static definitions. |
|
||||
| Medium | Duplicate mission actor wrappers (`career-transition-actor.ts`, `salary-negotiation-war-room-actor.ts`, `promotion-readiness-actor.ts`, `personal-brand-opportunity-engine-actor.ts`) | Keep; low-cost wrappers are active. | Thin wrappers are mapped in routes, registry, event actor, and actor registry. |
|
||||
| Medium | `src/events/projectors/projection-agent.ts` LLM insight path | Keep, but verify product use. | Referenced by `userEventActor` and `reducer-types`, so not dead. It can silently fall back when no LLM API key exists. |
|
||||
| Medium | Legacy Redis observers in `src/events/redis-consumer.ts` | Keep until services emit canonical Grow Events. | Comments state these observe existing service A2A traffic. They are enabled by `INTERVIEW_REDIS_URL`, `ROLEPLAY_REDIS_URL`, and `RESUME_REDIS_URL`. |
|
||||
| Medium | `events` audit table in `src/db/schema.ts` | Keep until old frontend timelines and route writes are audited. | Older user/service paths still import/use `events` table, while newer Grow Event tables also exist. |
|
||||
| Low | `src/workflows/registry.ts` and `src/missions/registry.ts` duplicate product concepts | Keep; consolidate later. | Workflows are commercial product definitions; missions are actor-backed variants. The overlap is intentional but duplicative. |
|
||||
| Low | `docker/opencode/workspace-template/*/README.md` placeholders | Keep as template docs or remove if generated workspaces no longer need empty folders. | Template-only files are not runtime code, but useful for preserving folder structure. |
|
||||
| Low | `docs/architecture.html` | Keep unless replaced by Markdown architecture docs. | Existing doc artifact, not source. |
|
||||
|
||||
## Unused or Underused Env Vars / Config Values
|
||||
|
||||
| Env/config | Recommendation | Evidence |
|
||||
| --- | --- | --- |
|
||||
| `config.required` | Keep or remove after scanning call sites; currently exported but not used in local source. | `required` is attached to config, but no local `config.required(` references were found. |
|
||||
| `clerkPublishableKey` | Keep if clients read backend config elsewhere; otherwise remove from backend config. | Defined in `config.ts` and `.env.example`, but backend auth uses secret key. |
|
||||
| `opencodeApiKey` | Keep only if future direct OpenCode auth requires it; currently `llmApiKey` consumes `OPENCODE_API_KEY`. | Defined separately in config; most OpenCode runtime calls use per-container password, not this field. |
|
||||
| `userServiceUrl` | Keep; used by missions profile lookup. | `routes/missions.ts` fetches `/api/v1/users/me`. |
|
||||
| `legacyServiceTaskObserverGroup` | Keep while legacy Redis observers exist. | Used in `redis-consumer.ts`. |
|
||||
| `migrationVersion`, `promptVersion`, `opencodeImageVersion` | Keep; active Docker rollout labels. | Used by `docker/manager.ts` and Docker build metadata. |
|
||||
|
||||
## Stale or Demo-Oriented Behavior
|
||||
|
||||
- Demo generated home notifications and `/home/seed-demo` should move to a staging/demo module or be guarded by `config.environment`.
|
||||
- `service-agents.ts` includes demo-like defaults, such as `formula_version: "workflow-demo"` and synthetic Q Score fallback summaries.
|
||||
- `config.ts` defaults many production-sensitive values to local/dev values, including Gitea admin credentials, service token fallback, A2A key, and localhost URLs.
|
||||
- Docker/OpenCode scripts are active but dev-biased, using image tags like `growqr/opencode:dev`.
|
||||
|
||||
## Prompt Workflow Inventory
|
||||
|
||||
All prompt workflow files under `prompts/workflows/*` are referenced by `src/workflows/registry.ts` through `promptPath` values:
|
||||
|
||||
- `career-transition/orchestrator.md`
|
||||
- `interview-to-offer/interview-plan.md`
|
||||
- `salary-negotiation-war-room/orchestrator.md`
|
||||
- `promotion-readiness/orchestrator.md`
|
||||
- `personal-brand-opportunity-engine/orchestrator.md`
|
||||
|
||||
Additional interview-to-offer prompt files (`resume-analysis.md`, `story-bank.md`, `final-readiness-report.md`) are not referenced by `workflowDefinitions` directly in this pass. Recommendation: keep until OpenCode/agent prompt loading is audited, then either wire them into module definitions or archive them.
|
||||
|
||||
## Delete/Keep Decisions Before Cleanup
|
||||
|
||||
Do not delete yet:
|
||||
|
||||
- `userActor` workflow code
|
||||
- `product-service-actors`
|
||||
- static mission/workflow registries
|
||||
- Redis legacy observers
|
||||
- demo home seeder
|
||||
- standalone scripts
|
||||
|
||||
Good first cleanup after approval:
|
||||
|
||||
1. Move demo seeding to `src/staging` and guard it with a staging/demo environment.
|
||||
2. Remove or document unused config fields (`config.required`, `clerkPublishableKey`, `opencodeApiKey`) after a second pass across frontend/deployment references.
|
||||
3. Convert `workflows:smoke` into a real test or delete the script.
|
||||
4. Consolidate mission actor type mapping into one helper and remove duplicate mapping functions.
|
||||
|
||||
## Verification
|
||||
|
||||
`pnpm typecheck` passed:
|
||||
|
||||
```txt
|
||||
tsc -p tsconfig.json --noEmit
|
||||
```
|
||||
179
docs/backend-organization-audit.md
Normal file
179
docs/backend-organization-audit.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Backend Organization Audit
|
||||
|
||||
PRM-41 audit pass for `growqr-backend`.
|
||||
|
||||
Scope reviewed: `src/routes`, `src/actors`, `src/events`, `src/missions`, `src/workflows`, and `src/services`.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The backend currently has three overlapping orchestration layers:
|
||||
|
||||
1. HTTP routes that directly perform database writes, service calls, and some synchronous workflow execution.
|
||||
2. Rivet actors that own durable user, workflow, mission, conversation, memory, and event processing state.
|
||||
3. Event/projector code that normalizes service events into Grow Events, updates mission state, records service sessions, and projects Q Score signals.
|
||||
|
||||
That split is workable for a demo-stage backend, but it blurs ownership. Several routes contain business logic that should live in services or actors, while actors and event consumers need stronger idempotency, retry, and replay boundaries before production traffic.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
FE[Frontend / service clients] --> Hono[Hono routes]
|
||||
Hono --> DB[(Postgres / Drizzle)]
|
||||
Hono --> Rivet[Rivet actors]
|
||||
Hono --> Svc[Product services]
|
||||
Hono --> Docker[Docker + Gitea + OpenCode]
|
||||
|
||||
Svc --> Redis[Redis streams / pubsub]
|
||||
Redis --> Consumer[events/redis-consumer]
|
||||
Consumer --> GrowEvents[(grow_events)]
|
||||
Consumer --> EventActor[userEventActor]
|
||||
EventActor --> MissionActors[mission actors]
|
||||
EventActor --> Projectors[QScore/session/projectors]
|
||||
MissionActors --> DB
|
||||
|
||||
Rivet --> DB
|
||||
Rivet --> Svc
|
||||
Rivet --> Docker
|
||||
```
|
||||
|
||||
## Route to Actor/Service/Event/Data Flow Map
|
||||
|
||||
| Route module | Mounted path | Primary flow | Actor/service/data dependencies | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `src/routes/actors.ts` | `/actors` | Auth-gated user stack control | `docker/manager`, `actors` table | Provisions/stops OpenCode stack directly from route. |
|
||||
| `src/routes/agents.ts` | `/agents` | Catalog read | `agents/catalog` | Thin route. |
|
||||
| `src/routes/chat.ts` | `/api/chat` | Chat request, Rivet first, direct LLM fallback | `userActor`, `lib/llm`, `services/service-agents` | Contains fallback tool orchestration and timeout logic in route. |
|
||||
| `src/routes/conversations.ts` | `/conversations` | Conversation CRUD/chat/mission bridging | `conversationActor`, mission actors, `grow_conversations`, messages | Heavy route; mixes persistence, actor bootstrapping, mission resolution, and response shaping. |
|
||||
| `src/routes/events.ts` | `/events` | User/service event ingestion and listing | `recordGrowEvent`, `routeGrowEventToUserActor`, `grow_events` | Good ingestion boundary, but service auth is environment-sensitive. |
|
||||
| `src/routes/git.ts` | `/git` | Repo/file operations | `docker/manager`, `GiteaClient` | Route owns path safety and repo operation decisions. |
|
||||
| `src/routes/grow.ts` | `/grow` | Grow bootstrap and active state | `growActor` | Thin actor gateway. |
|
||||
| `src/routes/home.ts` | `/home` | Home feed, notifications, demo seed | `home-feed`, `seed-demo-home` | Includes demo seeding endpoint. |
|
||||
| `src/routes/missions.ts` | `/missions` | Mission catalog, start/pause/resume/stage/artifacts/coach | `growActor`, mission actors, user service, mission registry | Heavy route; owns mission selection, profile fallback, actor type mapping, and artifact commands. |
|
||||
| `src/routes/opencode.ts` | `/opencode` | OpenCode stack/session/message proxy | `docker/manager`, `OpencodeClient` | Directly provisions stack and opens sessions. |
|
||||
| `src/routes/services.ts` | `/services` | Product service proxy and event recording | `product-service-clients`, `recordGrowEvent`, Q Score onboarding | Very heavy route; contains service-specific payload shaping and event side effects. |
|
||||
| `src/routes/users.ts` | `/users` | User profile/bootstrap | `auth/clerk`, `users` table, onboarding Q Score | Includes Clerk profile mirroring and onboarding side effects. |
|
||||
| `src/routes/workflows.ts` | `/workflows`, `/workflow-runs` | Workflow definitions/runs/modules/approvals | `userActor`, `workflowRunActor`, `workflow/module-runner`, DB | Two paths: legacy userActor job-application flow and DB-backed workflow runs. |
|
||||
|
||||
## Actor Inventory
|
||||
|
||||
| Actor | Current role | Main inputs | Outputs/effects | Robustness observations |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `userActor` | Legacy unified user orchestration: chat, memory tools, workflow status, service handoffs, OpenCode/Gitea interactions | `/api/chat`, `/workflows/job-application`, workflow route aliases | Actor state, DB events, service calls, Gitea reads/writes | Very broad responsibilities; failures in service calls often become summaries rather than durable retryable jobs. |
|
||||
| `workflowRunActor` | Queued workflow module runner | `/workflow-runs/:runId/pause|resume` and direct client use | `workflowRunModules`, `workflowEvents`, `qscoreSnapshots` via module runner | Has Rivet loop retry settings for module execution, but route-level `/run` bypasses actor queue and executes synchronously. |
|
||||
| `conversationActor` | Durable streaming conversation state | `/conversations` | Actor state and generated messages | Queue usage exists for messages; needs documented idempotency per turn/message id. |
|
||||
| `memoryActor` | Durable memory file state | Internal client use | Actor state/file-like memory | Queue writes exist; external call idempotency unclear. |
|
||||
| `growActor` | Active mission list/state control | `/grow`, `/missions` | `grow_active_missions`, mission state | Mission lifecycle split across growActor, mission actors, and routes. |
|
||||
| `userEventActor` | Routes normalized Grow Events to missions/projectors | Redis consumer, `/events` ingestion | Mission stage patches, projector DB updates, event status | Central point for event idempotency, but retries/replay/DLQ are not yet formalized. |
|
||||
| Mission actors | Per-mission state machines | `/missions`, `/conversations`, event actor | `grow_active_missions`, artifacts, suggestions | Four mission actors are thin factory wrappers; interview-to-offer has custom implementation. |
|
||||
| Product service actors | Actor wrappers for interview/roleplay/resume clients | Registry only; possible client use | Service calls | Registered, but routes call clients directly. These may be underused compared to direct service proxy routes. |
|
||||
|
||||
## Event and Projector Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Service as Product service
|
||||
participant Redis as Redis stream/pubsub
|
||||
participant Route as /events or service routes
|
||||
participant Store as grow_events
|
||||
participant UserEvent as userEventActor
|
||||
participant Mission as mission actor
|
||||
participant Projection as projectors
|
||||
|
||||
Service->>Redis: canonical GrowEvent or legacy task response
|
||||
Redis->>Route: redis-consumer normalizes message
|
||||
Route->>Store: recordGrowEvent with dedupeKey
|
||||
Route->>UserEvent: routeGrowEventToUserActor
|
||||
UserEvent->>Mission: apply reducer-derived stage patches
|
||||
UserEvent->>Projection: service session and Q Score projections
|
||||
Projection->>Store: update projection tables
|
||||
```
|
||||
|
||||
Current event strengths:
|
||||
|
||||
- `normalizeGrowEvent` accepts multiple service field conventions.
|
||||
- `recordGrowEvent` uses `dedupeKey` and a unique index on `grow_events.dedupe_key`.
|
||||
- Legacy Redis observer bridges `tasks:*` and `responses:*` without service changes.
|
||||
- Projector surfaces exist for session tracking, Q Score, and LLM-derived insights.
|
||||
|
||||
Current event gaps:
|
||||
|
||||
- Redis canonical consumer always `xAck`s in `finally`, even when `recordAndRoute` fails, so failed messages do not remain pending for retry.
|
||||
- No DLQ stream/table for failed canonical or legacy event processing.
|
||||
- No replay script for `grow_events.processing_status in ('failed', 'unresolved')`.
|
||||
- Legacy task context is in-memory only, so response events can lose user/action context after a backend restart.
|
||||
|
||||
## Business Logic in Routes
|
||||
|
||||
Highest concentration:
|
||||
|
||||
- `src/routes/services.ts`: service-specific request construction, event emission, Q Score baseline/onboarding side effects, mission association, and UI response shaping.
|
||||
- `src/routes/workflows.ts`: run creation, module row initialization, baseline Q Score, approval gate progression, artifact content lookup, and synchronous module execution.
|
||||
- `src/routes/missions.ts`: mission profile lookup from user service, actor type mapping, start/resume/pause/stage/artifact commands, and coach run orchestration.
|
||||
- `src/routes/conversations.ts`: active conversation persistence, mission-aware chat routing, actor fallback behavior, and response normalization.
|
||||
- `src/routes/chat.ts`: Rivet fallback, direct LLM tool loop, service agent selection, and timeout handling.
|
||||
|
||||
Low-risk thin routes:
|
||||
|
||||
- `src/routes/agents.ts`
|
||||
- `src/routes/grow.ts`
|
||||
- parts of `src/routes/events.ts`
|
||||
|
||||
Recommended ownership target:
|
||||
|
||||
- Routes validate/authenticate and translate HTTP to commands.
|
||||
- Actors own durable user/mission/workflow progression.
|
||||
- Services own outbound HTTP details.
|
||||
- Projectors own derived read models.
|
||||
- Routes should not decide retry, idempotency, or service fallback behavior beyond returning HTTP errors.
|
||||
|
||||
## Idempotency Gaps
|
||||
|
||||
| Area | Existing behavior | Gap |
|
||||
| --- | --- | --- |
|
||||
| Grow Event ingestion | `dedupeKey` unique index; normalizer uses explicit key or source id | Service routes do not consistently set stable dedupe keys for all service-created side effects. |
|
||||
| Workflow runs | `/workflow-runs/:runId/modules/:moduleId/run` reads `idempotency-key` header | `executeWorkflowModule` does not use the key to suppress duplicate service calls; `/run` generates timestamp keys. |
|
||||
| Workflow module rows | Has `idempotencyKey`, `retryCount`, `maxRetries` columns | Counters are mostly passive; no central retry state machine. |
|
||||
| Actor queues | Rivet queues and `loop` step names provide some dedupe for `workflowRunActor` | Several routes bypass actor queue and execute directly. |
|
||||
| Service session creation | `stableUuid` exists in service-agent helper | Not consistently used as a request id/idempotency key across service calls. |
|
||||
| OpenCode artifacts | `onConflictDoNothing` for workflow artifacts | OpenCode prompt/message send can duplicate work before artifact row conflict applies. |
|
||||
|
||||
## Retry Gaps
|
||||
|
||||
| Area | Existing behavior | Gap |
|
||||
| --- | --- | --- |
|
||||
| `workflowRunActor` | Rivet `loop` has `retryBackoffBase` and `retryBackoffMax` | Only applies when execution goes through actor loop. |
|
||||
| HTTP service clients | Throw on non-2xx after `fetch` | No timeout, retry classification, request id, or backoff. |
|
||||
| Gitea client | Some wait/poll helpers exist | Most API calls are single-shot. |
|
||||
| OpenCode client | Health polling exists | Session/message calls are single-shot. |
|
||||
| Redis consumer | Infinite loop catches top-level errors | Per-message failures are acked; no retry budget or DLQ. |
|
||||
| Projectors | Called by event actor | Projector failures need durable retry/replay semantics and status transitions. |
|
||||
|
||||
## Actor Robustness Gaps
|
||||
|
||||
- `userActor` is too broad to reason about failure domains. It owns chat, service tools, memory, workflow, Gitea, OpenCode, and DB event writes.
|
||||
- Product service actors are registered but not the primary path for service proxy routes, so actor-level durability is uneven.
|
||||
- Mission actor mapping is manually duplicated in routes, registry, and event actor.
|
||||
- Route-level synchronous workflow execution can hold HTTP requests open across slow service/OpenCode calls.
|
||||
- Actor initialization is repeated in routes; a central actor gateway could enforce init/idempotency/logging.
|
||||
|
||||
## Priority-Ranked Recommendations
|
||||
|
||||
1. Create a backend command layer for route-to-actor/service translation. Move mission start, workflow run, approval, service configure, and chat tool dispatch logic out of routes.
|
||||
2. Make `workflowRunActor` the only executor for workflow modules. Routes should enqueue commands and return command ids.
|
||||
3. Add a shared outbound `withRetry`/timeout/idempotency wrapper for service clients, Gitea, OpenCode, and LLM calls.
|
||||
4. Add DLQ and replay support for Redis/event processing. Do not ack canonical Redis messages until durable record/projector status is successful or DLQ-ed.
|
||||
5. Normalize mission actor mapping into a single registry source used by routes, event actor, and mission registry.
|
||||
6. Split `userActor` responsibilities: chat/memory/workflow/OpenCode paths should be smaller actors or delegated services with explicit contracts.
|
||||
7. Convert route-created side effects to stable idempotency keys. Use request id, user id, mission instance id, service id, and operation name.
|
||||
8. Add structured logging fields across routes/actors/events: `requestId`, `userId`, `missionInstanceId`, `runId`, `moduleId`, `eventId`, `idempotencyKey`, `retryAttempt`.
|
||||
9. Add focused tests around duplicate workflow module run, duplicate service event ingest, Redis failure handling, and mission projector replay.
|
||||
|
||||
## Suggested Next Slice
|
||||
|
||||
Use PRM-43 to introduce shared retry/idempotency primitives first. Then return to this audit and migrate the highest-risk route logic in this order:
|
||||
|
||||
1. `/workflow-runs/*/run`
|
||||
2. `/services/interview|roleplay configure/review`
|
||||
3. `/missions/:missionId/start`
|
||||
4. `/api/chat` direct LLM fallback
|
||||
148
docs/environment-matrix.md
Normal file
148
docs/environment-matrix.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Environment Matrix
|
||||
|
||||
PRM-42 staging vs production separation inventory for `growqr-backend`.
|
||||
|
||||
No refactor was performed in this pass.
|
||||
|
||||
## Current Environment Model
|
||||
|
||||
The backend currently uses `config.nodeEnv` plus many individual env vars. There is no explicit first-class `environment` such as `development | staging | production | demo`.
|
||||
|
||||
Important consequence: local/dev defaults can leak into staging or production unless deployment env vars override every sensitive value.
|
||||
|
||||
## Current Config Inventory
|
||||
|
||||
| Area | Config/env | Current default | Production concern |
|
||||
| --- | --- | --- | --- |
|
||||
| Runtime | `PORT`, `LOG_LEVEL`, `NODE_ENV` | `4000`, `info`, `development` | `NODE_ENV` is too broad for staging/demo behavior. |
|
||||
| Database | `DATABASE_URL` | hardcoded fallback DSN in `config.ts` | Production should fail fast instead of falling back. |
|
||||
| Auth | `CLERK_SECRET_KEY`, `CLERK_PUBLISHABLE_KEY` | empty | Secret key absence changes auth behavior; publishable key appears underused. |
|
||||
| Service auth | `SERVICE_TOKEN`, `A2A_ALLOWED_KEY` | empty / `dev-a2a-key` | Dev token fallback must not be accepted in production. |
|
||||
| Redis events | `GROW_EVENTS_REDIS_URL`, `REDIS_URL`, stream/group/consumer names | disabled unless set | Staging/prod need explicit stream, group, and replay policy. |
|
||||
| Legacy Redis | `INTERVIEW_REDIS_URL`, `ROLEPLAY_REDIS_URL`, `RESUME_REDIS_URL` | fallback to event Redis | Legacy observation should be explicitly enabled per environment. |
|
||||
| LLM | `LLM_PROVIDER`, `LLM_API_KEY`, `OPENCODE_API_KEY`, `LLM_BASE_URL`, `GROW_AGENT_MODEL`, `LLM_MODEL` | `opencode`, `https://opencode.ai/zen/v1`, `kimi-k2.6` | Staging/prod should pin provider/model and require API key where features are enabled. |
|
||||
| Rivet | `RIVET_ENDPOINT`, `RIVET_CLIENT_ENDPOINT` | localhost/127.0.0.1 | Docker compose overrides endpoint; production needs internal and public separation. |
|
||||
| Product services | `INTERVIEW_SERVICE_URL`, `ROLEPLAY_SERVICE_URL`, `QSCORE_SERVICE_URL`, `RESUME_SERVICE_URL`, `USER_SERVICE_URL`, `MATCHMAKING_SERVICE_URL`, `SOCIAL_BRANDING_SERVICE_URL` | localhost ports | Production should require service URLs or feature-disable explicitly. |
|
||||
| Public URLs | `INTERVIEW_PUBLIC_URL`, `ROLEPLAY_PUBLIC_URL`, `RESUME_PUBLIC_URL`, `WORKFLOWS_DASHBOARD_URL`, `FRONTEND_ORIGIN` | localhost/frontend fallback | Public and internal service URLs need separate semantics. |
|
||||
| Gitea | `GITEA_PUBLIC_URL`, `GITEA_INTERNAL_URL`, `GITEA_ADMIN_USER`, `GITEA_ADMIN_PASSWORD`, `GITEA_ADMIN_TOKEN`, `GITEA_ORG_NAME` | localhost, `growqr-admin`, `growqr-admin-dev`, empty token | Admin password fallback is dev-only. Production should require token/secret. |
|
||||
| OpenCode | `OPENCODE_IMAGE`, `OPENCODE_IMAGE_VERSION`, `MIGRATION_VERSION`, `PROMPT_VERSION`, `USER_CONTAINER_HOST`, `USER_DATA_ROOT`, `USER_PORT_RANGE_*` | dev image/version, local paths/ports | Needs staging/prod image tags and storage policy. |
|
||||
| CORS/admin | `FRONTEND_ORIGIN`, `ADMIN_USER_IDS` | localhost / empty | Empty admin list currently allows `/workflows/admin/ops` to all authenticated users. |
|
||||
| Agent limits | `MAX_AGENT_TOKENS`, `PROJECTION_AGENT_MODEL`, `CONVERSATION_ACTOR_MODEL` | 4096 / agent model | Model overrides should be pinned by environment. |
|
||||
|
||||
## Environment-Dependent Code Paths
|
||||
|
||||
| File | Behavior |
|
||||
| --- | --- |
|
||||
| `src/config.ts` | Central env parsing with dev defaults for database, tokens, local service URLs, Gitea, OpenCode, Rivet, frontend, and ports. |
|
||||
| `src/auth/clerk.ts` | In non-production, `A2A_ALLOWED_KEY` is accepted as an auth fallback. Clerk client is only created when `CLERK_SECRET_KEY` exists. |
|
||||
| `src/index.ts` | Proxies `/api/rivet` only when `process.env.RIVET_ENDPOINT` is set. Starts Redis consumer opportunistically. CORS uses `FRONTEND_ORIGIN`. |
|
||||
| `src/events/redis-consumer.ts` | Canonical consumer disabled if no Redis URL. Legacy observers enabled by legacy Redis URLs. |
|
||||
| `src/events/projectors/projection-agent.ts` | Falls back if no LLM API key; model can be overridden by `PROJECTION_AGENT_MODEL`. |
|
||||
| `src/actors/conversation/agent.ts` | Requires LLM key for streaming; model can be overridden by `CONVERSATION_ACTOR_MODEL`. |
|
||||
| `src/routes/events.ts` | Service ingest auth allows no service token in non-production. |
|
||||
| `src/routes/home.ts` | Exposes demo seeding route. |
|
||||
| `src/home/seed-demo-home.ts` | Demo notifications and executable direct script behavior. |
|
||||
| `src/services/service-agents.ts` | Synthetic/demo fallbacks for some unavailable services and Q Score estimate behavior. |
|
||||
| `src/docker/manager.ts` | Uses Gitea/OpenCode image/version/host/path/port config and mutates Docker runtime. |
|
||||
| `scripts/rivet-actors.ts` | Uses dev Rivet namespace/token defaults. |
|
||||
| `docker-compose.yml` | Dev compose defaults for Postgres, Gitea, Rivet, backend, services, frontend origins, and OpenCode image. |
|
||||
| `docker/opencode/*` | Dev-oriented OpenCode image/template behavior. |
|
||||
|
||||
## Hardcoded URL and Default Hotspots
|
||||
|
||||
- `http://localhost:*` defaults in `src/config.ts`, `.env.example`, `README.md`, and `docker-compose.yml`.
|
||||
- `http://127.0.0.1:*` defaults for Rivet client, Gitea, and user container host.
|
||||
- `http://host.docker.internal:*` compose service defaults.
|
||||
- OpenCode base image `ghcr.io/anomalyco/opencode:latest` in `docker/opencode/Dockerfile`.
|
||||
- Dev image tag `growqr/opencode:dev`.
|
||||
- Gitea admin defaults `growqr-admin` / `growqr-admin-dev`.
|
||||
- A2A fallback `dev-a2a-key`.
|
||||
|
||||
## Clerk / JWKS Assumptions
|
||||
|
||||
The code uses Clerk SDK with `CLERK_SECRET_KEY`; there is no explicit JWKS URL configuration in the reviewed backend source. Service-to-service auth is token based, with dev fallback behavior. Target production should document whether auth is:
|
||||
|
||||
- Clerk session token verification for user requests.
|
||||
- `SERVICE_TOKEN` for service-to-backend event ingestion.
|
||||
- Separate internal A2A key for legacy product service calls.
|
||||
- Optional JWKS validation if services send JWTs instead of opaque service tokens.
|
||||
|
||||
## Target Config Model
|
||||
|
||||
Introduce:
|
||||
|
||||
```ts
|
||||
type RuntimeEnvironment = "development" | "test" | "staging" | "demo" | "production";
|
||||
```
|
||||
|
||||
Recommended top-level config shape:
|
||||
|
||||
```ts
|
||||
config.environment
|
||||
config.isProduction
|
||||
config.isStaging
|
||||
config.isDemo
|
||||
config.features.demoDataEnabled
|
||||
config.features.legacyRedisObserversEnabled
|
||||
config.features.opencodeProvisioningEnabled
|
||||
config.features.serviceProxyEnabled
|
||||
config.urls.internal.*
|
||||
config.urls.public.*
|
||||
config.auth.*
|
||||
config.retry.*
|
||||
config.events.*
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Production must fail fast for missing `DATABASE_URL`, `CLERK_SECRET_KEY`, `SERVICE_TOKEN`, `FRONTEND_ORIGIN`, Gitea credentials/token, and any enabled service URL.
|
||||
- Staging may use staging service URLs and demo data only when `DEMO_DATA_ENABLED=true`.
|
||||
- Development may keep local defaults.
|
||||
- Demo behavior should be impossible in production unless an explicit, audited flag is set and the route remains auth/admin-gated.
|
||||
|
||||
## What Should Move to `src/staging`
|
||||
|
||||
Proposed `src/staging` candidates:
|
||||
|
||||
- `home/seed-demo-home.ts`
|
||||
- `/home/seed-demo` route handler
|
||||
- demo notification factories
|
||||
- demo Q Score formulas/fallback constants in service-agent behavior, if not product-approved
|
||||
- local-only service session scaffolding helpers
|
||||
- any future seeders/backfills used only for demos
|
||||
|
||||
Suggested layout:
|
||||
|
||||
```txt
|
||||
src/staging/
|
||||
demo-home.ts
|
||||
demo-qscore.ts
|
||||
seed-routes.ts
|
||||
guards.ts
|
||||
```
|
||||
|
||||
`src/staging/guards.ts` should expose `requireStagingOrDemo(config)` and fail closed in production.
|
||||
|
||||
## Target Environment Matrix
|
||||
|
||||
| Behavior | Development | Staging | Demo | Production |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Localhost defaults | Allowed | Not allowed | Not allowed unless local demo | Not allowed |
|
||||
| Demo seed endpoints | Allowed | Explicit flag + admin | Enabled by flag + admin | Disabled |
|
||||
| Service token fallback | Allowed | Not allowed | Not allowed | Not allowed |
|
||||
| Legacy Redis observers | Optional | Explicit flag | Explicit flag | Disable unless migration requires |
|
||||
| Redis canonical events | Optional | Required for event demos | Required | Required |
|
||||
| OpenCode image | `:dev` ok | pinned staging tag | pinned demo tag | pinned release tag |
|
||||
| Admin ops route | Authenticated maybe ok | `ADMIN_USER_IDS` required | `ADMIN_USER_IDS` required | `ADMIN_USER_IDS` required |
|
||||
| Missing Clerk secret | Allowed only for local mock if implemented | Fail | Fail | Fail |
|
||||
| Gitea admin password default | Allowed | Fail | Fail | Fail |
|
||||
|
||||
## Priority Recommendations
|
||||
|
||||
1. Add `APP_ENV` or `GROWQR_ENV` and derive `config.environment`; stop relying on `NODE_ENV` for product behavior.
|
||||
2. Fail fast in staging/production for missing secrets and localhost/default service URLs.
|
||||
3. Move demo seed code into `src/staging` and guard routes with `DEMO_DATA_ENABLED` plus admin check.
|
||||
4. Require `ADMIN_USER_IDS` before enabling `/workflows/admin/ops` outside development.
|
||||
5. Split public URLs and internal URLs in config names consistently across frontend, services, Gitea, Rivet, and OpenCode.
|
||||
6. Add a deployment checklist that records every required env var per environment.
|
||||
7. Make legacy Redis observers an explicit feature flag and set a removal date.
|
||||
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.
|
||||
284
docs/retry-idempotency-dlq-plan.md
Normal file
284
docs/retry-idempotency-dlq-plan.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Retry, Idempotency, and DLQ Plan
|
||||
|
||||
PRM-43 design pass for `growqr-backend`.
|
||||
|
||||
No implementation was performed in this pass.
|
||||
|
||||
## Goals
|
||||
|
||||
- Bound every outbound call with timeouts.
|
||||
- Retry only safe operations with classified errors.
|
||||
- Make repeated commands safe through idempotency keys.
|
||||
- Preserve failed event/workflow work in a DLQ with replay tooling.
|
||||
- Add logs that let support trace one user action across route, actor, service, Redis, projector, and database writes.
|
||||
|
||||
## Outbound Call Site Inventory
|
||||
|
||||
| Area | Files | Current behavior | Needed behavior |
|
||||
| --- | --- | --- | --- |
|
||||
| Product service clients | `src/services/product-service-clients.ts` | Direct `fetch`, no timeout/retry/idempotency header | Shared service client with timeout, retry, idempotency key, and request id. |
|
||||
| Service agent probes | `src/services/service-agents.ts` | Direct `fetch`, some fallback summaries | Same shared client; distinguish "unavailable" from retriable failure. |
|
||||
| Gitea | `src/lib/gitea.ts`, `src/docker/manager.ts`, `src/actors/user-actor.ts` | Direct `fetch`, some wait-for-ready helpers | Retry transient Gitea API errors; idempotent repo/user/file operations. |
|
||||
| OpenCode | `src/lib/opencode.ts`, `src/workflows/executors/opencode-executor.ts` | Direct `fetch`, health polling, no command dedupe | Timeout and retry health/session/message calls; stable command id for prompts. |
|
||||
| LLM | `src/lib/llm.ts`, `src/actors/conversation/agent.ts`, `src/events/projectors/projection-agent.ts` | Direct SDK/fetch calls | Timeout, retry on provider transient errors, no retry on content/schema errors. |
|
||||
| Actor sends | routes, `src/events/route-to-user-actor.ts`, actors | `getOrCreate(...).method(...)`, queue sends | Standard command envelope with idempotency key and correlation ids. |
|
||||
| Redis consumer | `src/events/redis-consumer.ts` | Loops forever; canonical messages ack in `finally`; no DLQ | Retry budget, pending handling, DLQ stream/table, replay. |
|
||||
| Projectors | `src/events/projectors/*`, `src/actors/events/user-event-actor.ts` | Called within event actor processing | Per-projector idempotency and failure status; replay from stored Grow Events. |
|
||||
| Workflow module runner | `src/workflows/module-runner.ts`, `src/actors/workflow-run-actor.ts` | Actor loop retries in one path; direct route execution in another | Actor-only execution, durable command id, retry state in DB. |
|
||||
|
||||
## Shared `withRetry` API
|
||||
|
||||
Add `src/lib/retry.ts`:
|
||||
|
||||
```ts
|
||||
export type RetryPolicy = {
|
||||
maxAttempts: number;
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
timeoutMs: number;
|
||||
jitter: boolean;
|
||||
};
|
||||
|
||||
export async function withRetry<T>(
|
||||
operation: string,
|
||||
fn: (ctx: { signal: AbortSignal; attempt: number }) => Promise<T>,
|
||||
options: {
|
||||
policy?: Partial<RetryPolicy>;
|
||||
idempotencyKey?: string;
|
||||
classify?: (error: unknown) => "retry" | "fail";
|
||||
logFields?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<T>;
|
||||
```
|
||||
|
||||
Default policy:
|
||||
|
||||
- `maxAttempts: 3`
|
||||
- `baseDelayMs: 250`
|
||||
- `maxDelayMs: 5_000`
|
||||
- `timeoutMs: 10_000`
|
||||
- jitter enabled
|
||||
|
||||
Classification:
|
||||
|
||||
- Retry: network errors, abort/timeout, HTTP `408`, `425`, `429`, `500`, `502`, `503`, `504`.
|
||||
- Do not retry: HTTP `400`, `401`, `403`, `404`, validation/schema errors, duplicate/idempotency conflicts that already completed.
|
||||
- Special case: `409` may be success for idempotent create-if-absent operations.
|
||||
|
||||
## Idempotency Model
|
||||
|
||||
Add a command/event idempotency key convention:
|
||||
|
||||
```txt
|
||||
<domain>:<userId>:<entityId>:<operation>:<version>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `workflow:user_123:run_456:module:resume:v1`
|
||||
- `mission:user_123:instance_456:start:v1`
|
||||
- `service:user_123:interview:configure:session_abc`
|
||||
- `event:user_123:growEventId:project:qscore:v1`
|
||||
- `opencode:user_123:run_456:interview-plan:prompt-v4`
|
||||
|
||||
Where to store:
|
||||
|
||||
- `workflowRunModules.idempotencyKey` for module commands.
|
||||
- `workflowEvents.payload.idempotencyKey` for audit trail.
|
||||
- `growEvents.dedupeKey` for event ingestion.
|
||||
- Add a future `idempotency_keys` table only if multiple domains need durable response reuse.
|
||||
|
||||
Minimum table design if needed:
|
||||
|
||||
```txt
|
||||
idempotency_keys
|
||||
key text primary key
|
||||
domain text not null
|
||||
user_id text
|
||||
status text check (processing, completed, failed)
|
||||
request_hash text
|
||||
response jsonb
|
||||
error text
|
||||
expires_at timestamptz
|
||||
created_at timestamptz
|
||||
updated_at timestamptz
|
||||
```
|
||||
|
||||
## HTTP Service Client Plan
|
||||
|
||||
Create `src/services/http-client.ts`:
|
||||
|
||||
- Accepts `baseUrl`, `path`, `method`, `json`, `headers`, `idempotencyKey`, `operation`, `timeoutMs`.
|
||||
- Adds:
|
||||
- `authorization: Bearer <A2A_ALLOWED_KEY>` when configured.
|
||||
- `x-request-id`
|
||||
- `x-idempotency-key` or `idempotency-key`.
|
||||
- `x-growqr-user` when user-scoped.
|
||||
- Uses `withRetry`.
|
||||
- Parses text once and returns typed JSON.
|
||||
- Logs attempt, latency, status, and error class.
|
||||
|
||||
Then migrate:
|
||||
|
||||
1. `product-service-clients.ts`
|
||||
2. `service-agents.ts`
|
||||
3. mission route direct user-service fetch
|
||||
4. workflow service health checks
|
||||
|
||||
## Workflow Retry Plan
|
||||
|
||||
Target behavior:
|
||||
|
||||
- Routes enqueue commands to `workflowRunActor`; routes do not call `executeWorkflowModule` directly.
|
||||
- `workflowRunActor` writes command state before execution.
|
||||
- `executeWorkflowModule` receives `idempotencyKey` and passes it to service/OpenCode calls.
|
||||
- On failure, increment `workflowRunModules.retryCount`, store `error`, and emit `workflowEvents` with `retryAttempt`.
|
||||
- Exceeding retry budget marks module `blocked` or `failed` based on module type and writes a DLQ row/event.
|
||||
|
||||
Module status transition:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle
|
||||
idle --> queued
|
||||
queued --> running
|
||||
running --> done
|
||||
running --> retry_wait
|
||||
retry_wait --> running
|
||||
running --> blocked
|
||||
running --> dlq
|
||||
dlq --> replaying
|
||||
replaying --> running
|
||||
```
|
||||
|
||||
## Redis Consumer and DLQ Plan
|
||||
|
||||
Do not ack canonical Redis messages until one of these is true:
|
||||
|
||||
- event persisted and routed/projected successfully;
|
||||
- event persisted but routing failed and a durable retry record was created;
|
||||
- message moved to DLQ after retry budget.
|
||||
|
||||
Add DLQ options:
|
||||
|
||||
1. Redis stream DLQ: `grow.events.dlq`
|
||||
2. Postgres table: `grow_event_dlq`
|
||||
|
||||
Recommended to use both:
|
||||
|
||||
- Redis DLQ for operational stream tooling.
|
||||
- Postgres DLQ for admin UI, audit, and replay metadata.
|
||||
|
||||
DLQ row fields:
|
||||
|
||||
```txt
|
||||
id
|
||||
source_stream
|
||||
source_message_id
|
||||
payload
|
||||
error
|
||||
attempts
|
||||
last_attempt_at
|
||||
status: pending | replaying | replayed | discarded
|
||||
created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
Replay script:
|
||||
|
||||
```txt
|
||||
pnpm events:replay --status failed --limit 100
|
||||
pnpm events:replay --dlq --id <dlq-id>
|
||||
pnpm events:replay --event-id <grow-event-id> --projectors qscore,service-session
|
||||
```
|
||||
|
||||
Script responsibilities:
|
||||
|
||||
- Re-read stored payload.
|
||||
- Re-run `recordGrowEvent` if needed.
|
||||
- Re-run `routeGrowEventToUserActor`.
|
||||
- Optionally run only selected projectors.
|
||||
- Preserve original `dedupeKey`.
|
||||
|
||||
## Projector Idempotency Plan
|
||||
|
||||
Projectors should be repeatable:
|
||||
|
||||
- Q Score latest table already has `(userId, signalId)` primary key.
|
||||
- Mission service sessions have unique `(serviceId, externalId)`.
|
||||
- Artifacts should dedupe by `(missionInstanceId, serviceId, externalId, type)` or a stable artifact key.
|
||||
- Mission stage patches should be applied with deterministic status/progress and no duplicate suggestions.
|
||||
|
||||
Add projector event logs:
|
||||
|
||||
```txt
|
||||
grow_event_projector_runs
|
||||
event_id
|
||||
projector
|
||||
status
|
||||
attempt
|
||||
error
|
||||
started_at
|
||||
completed_at
|
||||
```
|
||||
|
||||
## Logging Fields
|
||||
|
||||
Every route/actor/event/retry log should include as many of these as available:
|
||||
|
||||
- `requestId`
|
||||
- `traceId`
|
||||
- `userId`
|
||||
- `orgId`
|
||||
- `actorType`
|
||||
- `actorKey`
|
||||
- `runId`
|
||||
- `moduleId`
|
||||
- `missionId`
|
||||
- `missionInstanceId`
|
||||
- `stageId`
|
||||
- `eventId`
|
||||
- `source`
|
||||
- `eventType`
|
||||
- `idempotencyKey`
|
||||
- `operation`
|
||||
- `attempt`
|
||||
- `maxAttempts`
|
||||
- `latencyMs`
|
||||
- `httpStatus`
|
||||
- `retryable`
|
||||
- `dlqId`
|
||||
|
||||
## Test Plan
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `withRetry` retries transient errors and stops on non-retryable errors.
|
||||
- Timeout aborts fetch and logs retry attempt.
|
||||
- Idempotency key helper returns stable keys.
|
||||
- HTTP client adds auth, request id, and idempotency headers.
|
||||
|
||||
Integration tests:
|
||||
|
||||
- Duplicate `/workflow-runs/:runId/modules/:moduleId/run` command does not duplicate service call.
|
||||
- Duplicate Grow Event with same `dedupeKey` is stored once and projection remains stable.
|
||||
- Redis message failure is not acked until retry/DLQ path is recorded.
|
||||
- DLQ replay reprocesses a failed event and updates projector status.
|
||||
- OpenCode module execution retry does not create duplicate artifact rows.
|
||||
|
||||
Manual staging drills:
|
||||
|
||||
1. Stop interview service, run interview module, verify retry and blocked/DLQ behavior.
|
||||
2. Emit duplicate Redis events, verify one `grow_events` row and stable projector state.
|
||||
3. Break Gitea token, provision stack, verify retry logs and no partial untracked state.
|
||||
4. Replay a DLQ event, verify mission progress and Q Score update.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add `src/lib/retry.ts` and focused unit tests.
|
||||
2. Add service HTTP client and migrate product service calls.
|
||||
3. Add workflow command idempotency and route-to-actor queueing.
|
||||
4. Add Redis DLQ and replay script.
|
||||
5. Add projector run records.
|
||||
6. Migrate Gitea/OpenCode/LLM calls to `withRetry`.
|
||||
7. Add staging failure drills to deployment checklist.
|
||||
34
drizzle/0010_mission_actions.sql
Normal file
34
drizzle/0010_mission_actions.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE IF NOT EXISTS "mission_actions" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"mission_instance_id" text NOT NULL REFERENCES "grow_active_missions"("instance_id") ON DELETE cascade,
|
||||
"mission_id" text NOT NULL,
|
||||
"stage_id" text,
|
||||
"agent_id" text NOT NULL,
|
||||
"agent_name" text NOT NULL,
|
||||
"base_agent" text,
|
||||
"service_id" text,
|
||||
"tool_name" text,
|
||||
"mode" text NOT NULL,
|
||||
"status" text DEFAULT 'queued' NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"prompt" text,
|
||||
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"result" jsonb,
|
||||
"error" text,
|
||||
"source_event_id" text REFERENCES "grow_events"("id") ON DELETE set null,
|
||||
"idempotency_key" text,
|
||||
"priority" integer DEFAULT 0 NOT NULL,
|
||||
"urgency" text DEFAULT 'calm' NOT NULL,
|
||||
"due_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"resolved_at" timestamp with time zone
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mission_actions_mission_idx" ON "mission_actions" ("user_id", "mission_instance_id", "status", "priority");
|
||||
CREATE INDEX IF NOT EXISTS "mission_actions_user_idx" ON "mission_actions" ("user_id", "status", "updated_at");
|
||||
CREATE INDEX IF NOT EXISTS "mission_actions_source_idx" ON "mission_actions" ("source_event_id");
|
||||
CREATE INDEX IF NOT EXISTS "mission_actions_due_idx" ON "mission_actions" ("due_at");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "mission_actions_idempotency_idx" ON "mission_actions" ("idempotency_key");
|
||||
1
drizzle/0011_conversation_metadata.sql
Normal file
1
drizzle/0011_conversation_metadata.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "grow_conversations" ADD COLUMN IF NOT EXISTS "metadata" jsonb;
|
||||
@@ -71,6 +71,20 @@
|
||||
"when": 1780481400000,
|
||||
"tag": "0009_mission_suggestions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1780481500000,
|
||||
"tag": "0010_mission_actions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1780481600000,
|
||||
"tag": "0011_conversation_metadata",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test:onboarding": "tsx scripts/onboarding-ledger.test.ts",
|
||||
"test:missions": "tsx scripts/mission-lifecycle.test.ts",
|
||||
"test:passive-actions": "tsx scripts/passive-actions.test.ts",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
|
||||
|
||||
24
prompts/curator-v1.md
Normal file
24
prompts/curator-v1.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Curator V1 Conversation Prompt
|
||||
|
||||
You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
|
||||
|
||||
## Responsibilities
|
||||
- Own 30 day direction, streak continuity, and service handoff decisions.
|
||||
- Carry state from the conversation history and captured task memory.
|
||||
- If the user gives a short answer like a role name, accept it and ask for the next missing slot.
|
||||
|
||||
## Guardrails
|
||||
- Do not ask the same question twice.
|
||||
- Do not output checklist items as separate baked chat messages.
|
||||
- Never say: What should I capture next.
|
||||
- Do not ask about another subtask, another mission, another service, or a later checklist item from this modal.
|
||||
- When the user has answered the focused subtask enough, summarize what was captured and stop.
|
||||
- If more detail is needed, ask exactly one follow-up question for the focused subtask only.
|
||||
- Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.
|
||||
|
||||
## Task Guidance
|
||||
- For target-role tasks, collect target role, current background, constraints, then offer a resume or interview handoff.
|
||||
- For service work, prepare preview-oriented handoffs once the focused subtask has enough context.
|
||||
- Interview preview defaults: type behavioral, difficulty medium, duration 5.
|
||||
- Roleplay preview should open the builder as the preview surface.
|
||||
- Keep the tone concise, warm, and practical.
|
||||
28
prompts/curator/streak-chat.md
Normal file
28
prompts/curator/streak-chat.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Curator Streak Chat Prompt
|
||||
|
||||
You are the GrowQR V1 Curator in a daily or weekly streak chat modal.
|
||||
|
||||
Your job is to move the user from a focused streak suggestion to a service preview CTA quickly. The dashboard renders the actual CTA card, so your chat text must never include internal URLs, route names, API paths, setup paths, JSON, or tool names.
|
||||
|
||||
## Conversation Rules
|
||||
|
||||
- Focus only on the clicked subtask.
|
||||
- Ask at most one clarifying question before a handoff is ready.
|
||||
- If the target role is known from onboarding, do not ask for it again.
|
||||
- If no target role is known and the service is interview or roleplay, ask exactly: `What role are you targeting?`
|
||||
- If the user gives a vague answer after one question, use your best guess and proceed.
|
||||
- When enough context exists, summarize the captured intent in one short sentence and stop.
|
||||
- Do not ask the next subtask question.
|
||||
- Do not mention setup screens, preview URLs, backend services, actors, tools, or route paths.
|
||||
- Use ASCII punctuation only.
|
||||
|
||||
## Service Defaults
|
||||
|
||||
- Interview defaults: `type=behavioral`, `difficulty=medium`, `duration=5`.
|
||||
- Roleplay defaults: custom scenario, `difficulty=medium`, `duration=5`.
|
||||
- Prefer onboarding-derived role when available.
|
||||
- If no role is available after the single follow-up, use `Product Manager` as the MVP fallback.
|
||||
|
||||
## Tone
|
||||
|
||||
Be concise, calm, and action-oriented. The user should feel like the preview is prepared, not like they are filling out a form.
|
||||
@@ -1,6 +1,6 @@
|
||||
You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.
|
||||
You are Grow — a unified AI career assistant for the GrowQR platform.
|
||||
|
||||
You coordinate sub-agent capabilities (loaded as tools), maintain durable state, and execute workflows through microservices.
|
||||
You coordinate specialist capabilities (loaded as tools), maintain durable state, and execute workflows through microservices.
|
||||
|
||||
## CRITICAL RULES
|
||||
|
||||
@@ -43,7 +43,7 @@ You coordinate sub-agent capabilities (loaded as tools), maintain durable state,
|
||||
- After resume optimization: ask what type of interview to prepare.
|
||||
- When they choose type → call start_interview_session.
|
||||
- Then offer roleplay → call start_roleplay_session when they confirm.
|
||||
- Then offer Q-Score → call compute_qscore.
|
||||
- Then offer Q Score → call compute_qscore.
|
||||
- Use [WORKFLOW: interview-to-offer] tag throughout.
|
||||
|
||||
## IMPORTANT: Tool Calling Anti-Patterns
|
||||
@@ -66,16 +66,16 @@ Assistant: "I'll analyze your resume right away."
|
||||
User: "analyze my resume"
|
||||
Assistant calls analyze_resume → "Here's your analysis: [results]. Your strengths are..."
|
||||
|
||||
## Sub-Agent Capabilities
|
||||
## Specialist Capabilities
|
||||
|
||||
{{MODULE_DESCRIPTIONS}}
|
||||
|
||||
## Workflow Tags (put at the VERY END, on their own line)
|
||||
|
||||
- [WORKFLOW: interview-to-offer] — full interview prep pipeline
|
||||
- [WORKFLOW: interview-practice] — interview sessions with the Interview Agent
|
||||
- [WORKFLOW: interview-practice] — mock interview sessions
|
||||
- [WORKFLOW: resume-boost] — resume analysis and optimization
|
||||
- [WORKFLOW: roleplay-practice] — roleplay sessions with Roleplay Agent
|
||||
- [WORKFLOW: roleplay-practice] — mock roleplay sessions
|
||||
- [WORKFLOW: career-switch] — career change navigation
|
||||
- [WORKFLOW: job-preparation] — broad company preparation
|
||||
|
||||
|
||||
46
scripts/mission-lifecycle.test.ts
Normal file
46
scripts/mission-lifecycle.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
onboardingMissionInstanceId,
|
||||
selectOnboardingMissionIds,
|
||||
} from "../src/missions/lifecycle.js";
|
||||
|
||||
const userA = "user_abc123";
|
||||
|
||||
assert.deepEqual(
|
||||
selectOnboardingMissionIds({ onboarding: { goal: "I need internship interview prep" } }),
|
||||
["interview-to-offer", "personal-brand-opportunity-engine"],
|
||||
"default onboarding should start interview-to-offer plus personal brand",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
selectOnboardingMissionIds({ onboarding: { goal: "I want to negotiate my offer and compensation" } }),
|
||||
["salary-negotiation-war-room", "personal-brand-opportunity-engine"],
|
||||
"salary/offer context should prioritize the negotiation mission",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
selectOnboardingMissionIds({ preferences: { onboarding: { goal: "I need a career transition into product" } } }),
|
||||
["career-transition", "personal-brand-opportunity-engine"],
|
||||
"preferences onboarding context should be read when selecting missions",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
selectOnboardingMissionIds({ preferences: { onboarding: { goal: "Build LinkedIn visibility and network" } } }),
|
||||
["personal-brand-opportunity-engine", "interview-to-offer"],
|
||||
"brand/networking context should not duplicate the personal-brand mission",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
onboardingMissionInstanceId(userA, "interview-to-offer"),
|
||||
onboardingMissionInstanceId(userA, "interview-to-offer"),
|
||||
"onboarding mission instance ids must be deterministic for idempotent retries",
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
onboardingMissionInstanceId(userA, "interview-to-offer"),
|
||||
onboardingMissionInstanceId("user_other", "interview-to-offer"),
|
||||
"onboarding mission instance ids must be scoped by user",
|
||||
);
|
||||
|
||||
console.log("mission-lifecycle tests passed");
|
||||
process.exit(0);
|
||||
74
scripts/onboarding-ledger.test.ts
Normal file
74
scripts/onboarding-ledger.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
completedAtFromOnboardingPayload,
|
||||
isValidOnboardingLedgerEvent,
|
||||
normalizeOnboardingEventType,
|
||||
} from "../src/events/onboarding-ledger.js";
|
||||
|
||||
const now = "2026-06-28T00:00:00.000Z";
|
||||
|
||||
assert.equal(normalizeOnboardingEventType("user_onboarding_completed"), "user.onboarding.completed");
|
||||
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({
|
||||
type: "onboarding.completed",
|
||||
payload: {},
|
||||
}),
|
||||
true,
|
||||
"explicit completion event should satisfy onboarding status",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({
|
||||
type: "user.onboarding.completed",
|
||||
payload: {},
|
||||
}),
|
||||
true,
|
||||
"user completion event should satisfy onboarding status",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({
|
||||
type: "profile.onboarding.completed",
|
||||
payload: {},
|
||||
}),
|
||||
true,
|
||||
"profile completion event should satisfy onboarding status",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({
|
||||
type: "onboarding.snapshot.saved",
|
||||
payload: { onboarding: { current_step: 2 } },
|
||||
}),
|
||||
false,
|
||||
"intermediate snapshots must not bypass onboarding",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({
|
||||
type: "onboarding.snapshot.saved",
|
||||
payload: { onboarding: { completed_at: now } },
|
||||
}),
|
||||
true,
|
||||
"completion snapshots should satisfy onboarding status",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
completedAtFromOnboardingPayload({
|
||||
preferences: { onboarding: { completed_at: now } },
|
||||
}),
|
||||
now,
|
||||
"completion timestamp should be extracted from preferences snapshot",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
completedAtFromOnboardingPayload({
|
||||
completedAt: "not-a-date",
|
||||
})?.endsWith("Z"),
|
||||
true,
|
||||
"invalid completion timestamps should normalize to a valid ISO timestamp",
|
||||
);
|
||||
|
||||
console.log("onboarding-ledger tests passed");
|
||||
process.exit(0);
|
||||
208
scripts/passive-actions.test.ts
Normal file
208
scripts/passive-actions.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { careerTransitionReducer } from "../src/missions/career-transition/reducer.js";
|
||||
import { interviewToOfferReducer } from "../src/missions/interview-to-offer/reducer.js";
|
||||
import { personalBrandOpportunityReducer } from "../src/missions/personal-brand-opportunity-engine/reducer.js";
|
||||
import { promotionReadinessReducer } from "../src/missions/promotion-readiness/reducer.js";
|
||||
import { salaryNegotiationReducer } from "../src/missions/salary-negotiation-war-room/reducer.js";
|
||||
import type { GrowActiveMission } from "../src/actors/missions/types.js";
|
||||
import type { MissionReducer } from "../src/missions/reducer-types.js";
|
||||
import type { MissionReducerContext } from "../src/missions/reducer-types.js";
|
||||
|
||||
function missionFor(missionId: GrowActiveMission["missionId"], actorType: GrowActiveMission["actorType"]): GrowActiveMission {
|
||||
return {
|
||||
instanceId: `mission-${missionId}-test`,
|
||||
missionId,
|
||||
workflowId: missionId,
|
||||
title: missionId,
|
||||
shortTitle: missionId,
|
||||
status: "active",
|
||||
progressPercent: 0,
|
||||
currentStageId: "resume",
|
||||
actorType,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const mission = missionFor("interview-to-offer", "interviewToOfferMissionActor");
|
||||
|
||||
function ctxWithMission(activeMission: GrowActiveMission, event: Partial<MissionReducerContext["event"]> & { source: string; type: string; payload?: Record<string, unknown> }): MissionReducerContext {
|
||||
return {
|
||||
userId: "user_test",
|
||||
activeMission,
|
||||
event: {
|
||||
id: `event-${activeMission.missionId}-${event.type}`,
|
||||
userId: "user_test",
|
||||
orgId: null,
|
||||
source: event.source,
|
||||
type: event.type,
|
||||
category: "service",
|
||||
occurredAt: new Date(),
|
||||
receivedAt: new Date(),
|
||||
mission: event.mission,
|
||||
subject: null,
|
||||
correlation: null,
|
||||
payload: event.payload ?? {},
|
||||
raw: {},
|
||||
dedupeKey: null,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
},
|
||||
qscoreSignals: [],
|
||||
insight: {
|
||||
summary: "test insight",
|
||||
confidence: "low",
|
||||
recommendedActions: [],
|
||||
missionStageHints: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ctx(event: Partial<MissionReducerContext["event"]> & { source: string; type: string; payload?: Record<string, unknown> }): MissionReducerContext {
|
||||
return ctxWithMission(mission, event);
|
||||
}
|
||||
|
||||
const interviewFeedbackPayload = {
|
||||
review: {
|
||||
status: "completed",
|
||||
overall_score: 72,
|
||||
weak_areas: ["impact metrics", "ownership clarity"],
|
||||
proof_gaps: ["no scale numbers"],
|
||||
story_issues: ["STAR structure is loose"],
|
||||
summary: "Good direction, but missing measurable proof.",
|
||||
},
|
||||
};
|
||||
|
||||
const roleplayFeedbackPayload = {
|
||||
review: {
|
||||
status: "completed",
|
||||
weak_areas: ["concision", "objection handling"],
|
||||
story_gaps: ["needs clearer tradeoff story"],
|
||||
summary: "Good empathy, but answers need tighter story framing.",
|
||||
},
|
||||
};
|
||||
|
||||
const reducerCases: Array<{
|
||||
name: string;
|
||||
reducer: MissionReducer;
|
||||
mission: GrowActiveMission;
|
||||
}> = [
|
||||
{
|
||||
name: "interview to offer",
|
||||
reducer: interviewToOfferReducer,
|
||||
mission,
|
||||
},
|
||||
{
|
||||
name: "career transition",
|
||||
reducer: careerTransitionReducer,
|
||||
mission: missionFor("career-transition", "careerTransitionMissionActor"),
|
||||
},
|
||||
{
|
||||
name: "promotion readiness",
|
||||
reducer: promotionReadinessReducer,
|
||||
mission: missionFor("promotion-readiness", "promotionReadinessMissionActor"),
|
||||
},
|
||||
{
|
||||
name: "salary negotiation",
|
||||
reducer: salaryNegotiationReducer,
|
||||
mission: missionFor("salary-negotiation-war-room", "salaryNegotiationWarRoomMissionActor"),
|
||||
},
|
||||
{
|
||||
name: "personal brand",
|
||||
reducer: personalBrandOpportunityReducer,
|
||||
mission: missionFor("personal-brand-opportunity-engine", "personalBrandOpportunityMissionActor"),
|
||||
},
|
||||
];
|
||||
|
||||
const resumeResult = interviewToOfferReducer.reduce(ctx({
|
||||
source: "resume-builder",
|
||||
type: "resume.analysis.completed",
|
||||
payload: {
|
||||
analysis: {
|
||||
summary: "Strong backend platform project.",
|
||||
strengths: ["Built an event-driven backend"],
|
||||
gaps: ["Add impact metrics"],
|
||||
missing_keywords: ["Kafka", "AWS"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const proofPractice = resumeResult.actions.find((action) => action.payload?.passiveAction === "resume_analysis_to_interview_practice");
|
||||
assert.ok(proofPractice, "resume analysis should create an interview practice passive action");
|
||||
assert.equal(proofPractice?.serviceId, "interview-service");
|
||||
assert.equal(proofPractice?.toolName, "interview.configure_practice");
|
||||
assert.match(String(proofPractice?.payload?.prompt), /event-driven backend/i);
|
||||
|
||||
const interviewResult = interviewToOfferReducer.reduce(ctx({
|
||||
source: "interview-service",
|
||||
type: "interview.feedback.generated",
|
||||
payload: interviewFeedbackPayload,
|
||||
}));
|
||||
|
||||
const resumeUpgrade = interviewResult.actions.find((action) => action.payload?.passiveAction === "interview_feedback_to_resume_upgrade");
|
||||
assert.ok(resumeUpgrade, "interview feedback should create a resume upgrade passive action");
|
||||
assert.equal(resumeUpgrade?.mode, "approval_required");
|
||||
assert.equal(resumeUpgrade?.serviceId, "resume-service");
|
||||
assert.deepEqual(resumeUpgrade?.payload?.missingProof, ["no scale numbers"]);
|
||||
assert.deepEqual(resumeUpgrade?.payload?.storyIssues, ["STAR structure is loose", "add measurable impact proof"]);
|
||||
|
||||
const roleplayResult = interviewToOfferReducer.reduce(ctx({
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.feedback.generated",
|
||||
payload: roleplayFeedbackPayload,
|
||||
}));
|
||||
|
||||
const storyArtifact = roleplayResult.artifacts.find((artifact) => artifact.type === "story_bank_update");
|
||||
const communicationDrill = roleplayResult.actions.find((action) => action.payload?.passiveAction === "roleplay_feedback_to_communication_drill");
|
||||
assert.ok(storyArtifact, "roleplay feedback should create a story bank artifact");
|
||||
assert.ok(communicationDrill, "roleplay feedback should create a communication drill passive action");
|
||||
assert.equal(communicationDrill?.serviceId, "interview-service");
|
||||
assert.equal(communicationDrill?.toolName, "interview.configure_practice");
|
||||
assert.deepEqual(communicationDrill?.payload?.storyIssues, ["needs clearer tradeoff story", "tighten STAR story structure"]);
|
||||
|
||||
for (const testCase of reducerCases) {
|
||||
const reducerResumeResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, {
|
||||
source: "resume-builder",
|
||||
type: "resume.analysis.completed",
|
||||
payload: {
|
||||
analysis: {
|
||||
summary: "Strong backend platform project.",
|
||||
strengths: ["Built an event-driven backend"],
|
||||
gaps: ["Add impact metrics"],
|
||||
missing_keywords: ["Kafka", "AWS"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
reducerResumeResult.actions.some((action) => action.payload?.passiveAction === "resume_analysis_to_interview_practice"),
|
||||
`${testCase.name} resume analysis should create an interview practice passive action`,
|
||||
);
|
||||
|
||||
const reducerInterviewResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, {
|
||||
source: "interview-service",
|
||||
type: "interview.feedback.generated",
|
||||
payload: interviewFeedbackPayload,
|
||||
}));
|
||||
assert.ok(
|
||||
reducerInterviewResult.actions.some((action) => action.payload?.passiveAction === "interview_feedback_to_resume_upgrade"),
|
||||
`${testCase.name} interview feedback should create a resume upgrade passive action`,
|
||||
);
|
||||
|
||||
const reducerRoleplayResult = testCase.reducer.reduce(ctxWithMission(testCase.mission, {
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.feedback.generated",
|
||||
payload: roleplayFeedbackPayload,
|
||||
}));
|
||||
assert.ok(
|
||||
reducerRoleplayResult.actions.some((action) => action.payload?.passiveAction === "roleplay_feedback_to_communication_drill"),
|
||||
`${testCase.name} roleplay feedback should create a communication drill passive action`,
|
||||
);
|
||||
assert.ok(
|
||||
reducerRoleplayResult.artifacts.some((artifact) => artifact.type === "story_bank_update"),
|
||||
`${testCase.name} roleplay feedback should create a story bank update artifact`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("passive-actions tests passed");
|
||||
process.exit(0);
|
||||
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:8040/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 }));
|
||||
149
src/actors/analytics/analytics-actor.ts
Normal file
149
src/actors/analytics/analytics-actor.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { actor } from "rivetkit";
|
||||
import { count, desc, eq, sql } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import {
|
||||
growActiveMissions,
|
||||
growEvents,
|
||||
growQscoreLatest,
|
||||
growQscoreProjectionState,
|
||||
growQscoreSignals,
|
||||
missionActions,
|
||||
} from "../../db/schema.js";
|
||||
import { listActiveMissionsPg } from "../../grow/persistence.js";
|
||||
import { listMissionActions } from "../../missions/actions.js";
|
||||
|
||||
async function scalarCount(table: any, where?: any) {
|
||||
const query = db.select({ value: count() }).from(table);
|
||||
const rows = where ? await query.where(where) : await query;
|
||||
return rows[0]?.value ?? 0;
|
||||
}
|
||||
|
||||
async function platformAnalytics() {
|
||||
const [
|
||||
totalEvents,
|
||||
serviceEvents,
|
||||
missionEvents,
|
||||
activeMissions,
|
||||
completedMissions,
|
||||
totalActions,
|
||||
doneActions,
|
||||
qscoreSignalCount,
|
||||
] = await Promise.all([
|
||||
scalarCount(growEvents),
|
||||
scalarCount(growEvents, eq(growEvents.category, "service")),
|
||||
scalarCount(growEvents, eq(growEvents.category, "mission")),
|
||||
scalarCount(growActiveMissions),
|
||||
scalarCount(growActiveMissions, eq(growActiveMissions.status, "completed")),
|
||||
scalarCount(missionActions),
|
||||
scalarCount(missionActions, eq(missionActions.status, "done")),
|
||||
scalarCount(growQscoreSignals),
|
||||
]);
|
||||
|
||||
const serviceUsage = await db
|
||||
.select({
|
||||
source: growEvents.source,
|
||||
type: growEvents.type,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(eq(growEvents.category, "service"))
|
||||
.groupBy(growEvents.source, growEvents.type)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(20);
|
||||
|
||||
return {
|
||||
kind: "platform",
|
||||
generatedAt: new Date().toISOString(),
|
||||
totals: {
|
||||
events: totalEvents,
|
||||
serviceEvents,
|
||||
missionEvents,
|
||||
activeMissions,
|
||||
completedMissions,
|
||||
missionActions: totalActions,
|
||||
completedActions: doneActions,
|
||||
qscoreSignals: qscoreSignalCount,
|
||||
},
|
||||
serviceUsage,
|
||||
};
|
||||
}
|
||||
|
||||
async function userQscoreAnalytics(userId: string) {
|
||||
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(50);
|
||||
|
||||
const signalTimeline = await db
|
||||
.select()
|
||||
.from(growQscoreSignals)
|
||||
.where(eq(growQscoreSignals.userId, userId))
|
||||
.orderBy(desc(growQscoreSignals.occurredAt))
|
||||
.limit(100);
|
||||
|
||||
return {
|
||||
kind: "user-qscore",
|
||||
userId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
current: projection
|
||||
? {
|
||||
score: projection.score,
|
||||
signalCount: projection.signalCount,
|
||||
dimensions: projection.dimensions,
|
||||
summary: projection.summary,
|
||||
updatedAt: projection.updatedAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
latestSignals,
|
||||
signalTimeline,
|
||||
globalComparison: {
|
||||
status: "placeholder",
|
||||
percentile: null,
|
||||
cohort: null,
|
||||
sampleSize: null,
|
||||
note: "Global comparison is reserved for the cohort-backed analytics release.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function userActivityAnalytics(userId: string) {
|
||||
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",
|
||||
userId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
events,
|
||||
activeMissions: activeMissions.map((item) => item.mission),
|
||||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
export const analyticsActor = actor({
|
||||
options: { name: "Analytics Actor", icon: "chart-no-axes-column", noSleep: true },
|
||||
state: { updatedAt: Date.now() },
|
||||
actions: {
|
||||
getPlatform: async (c) => {
|
||||
c.state.updatedAt = Date.now();
|
||||
return platformAnalytics();
|
||||
},
|
||||
getUserQscore: async (c, input: { userId: string }) => {
|
||||
c.state.updatedAt = Date.now();
|
||||
return userQscoreAnalytics(input.userId);
|
||||
},
|
||||
getUserActivity: async (c, input: { userId: string }) => {
|
||||
c.state.updatedAt = Date.now();
|
||||
return userActivityAnalytics(input.userId);
|
||||
},
|
||||
},
|
||||
});
|
||||
1
src/actors/analytics/index.ts
Normal file
1
src/actors/analytics/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { analyticsActor } from "./analytics-actor.js";
|
||||
@@ -1,13 +1,35 @@
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { streamText, tool } from "ai";
|
||||
import { generateText, stepCountIs, streamText, tool } from "ai";
|
||||
import { createClient } from "rivetkit/client";
|
||||
import { z } from "zod";
|
||||
import type { ConversationMessage } from "./types.js";
|
||||
import { config } from "../../config.js";
|
||||
import { listMissionDefinitions } from "../../missions/registry.js";
|
||||
import { createMissionAction, listMissionActions } from "../../missions/actions.js";
|
||||
import { getActiveMissionPg, listActiveMissionsPg, listMissionSuggestionsPg } from "../../grow/persistence.js";
|
||||
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
|
||||
import { getSubAgentModules } from "../../lib/prompt-loader.js";
|
||||
import { buildMissionServiceRoute } from "../../services/service-registry.js";
|
||||
|
||||
const SYSTEM_PROMPT = `You are the GrowQR conversation agent.
|
||||
Keep answers concise, practical, and focused on the user's goals.
|
||||
When you learn durable information, call the memory tools. For now these tools
|
||||
are intentionally stubbed so this actor can stay isolated and unwired.`;
|
||||
Keep answers concise, practical, and focused on the user's active mission.
|
||||
Use tools when you need mission state, registry capabilities, memory, or a service handoff.
|
||||
Service tools prepare handoffs and mission actions; the interview, roleplay, and resume services own their detailed flows.
|
||||
|
||||
Style rules:
|
||||
- Use ASCII punctuation only. Do not use em dash or en dash.
|
||||
- Do not start with filler words like Perfect, Great, Absolutely, or Sure.
|
||||
- For Daily Mission turns, ask one short direct question. Keep it under 24 words.`;
|
||||
|
||||
export type ConversationRuntimeContext = {
|
||||
userId?: string;
|
||||
conversationId?: string;
|
||||
missionInstanceId?: string;
|
||||
missionId?: string;
|
||||
stageId?: string;
|
||||
source?: string;
|
||||
systemAddendum?: string;
|
||||
};
|
||||
|
||||
function normalizeModel(model: string): string {
|
||||
if (config.llmProvider === "opencode" && model.startsWith("opencode/")) {
|
||||
@@ -31,46 +53,206 @@ export function getConversationModel() {
|
||||
return conversationProvider.chat(normalizeModel(modelId));
|
||||
}
|
||||
|
||||
export function buildModelMessages(messages: ConversationMessage[]) {
|
||||
type ModelConversationMessage = Pick<ConversationMessage, "role" | "content">;
|
||||
|
||||
export function buildModelMessages(messages: ModelConversationMessage[]) {
|
||||
return messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
}));
|
||||
}
|
||||
|
||||
export function streamConversationResponse(messages: ConversationMessage[]) {
|
||||
let _client: any | null = null;
|
||||
function getRivetClient() {
|
||||
return (_client ??= createClient<any>(config.rivetClientEndpoint));
|
||||
}
|
||||
|
||||
function safeAgentRegistry() {
|
||||
try {
|
||||
return getSubAgentModules();
|
||||
} catch {
|
||||
return [
|
||||
{ id: "interview", name: "Interview Agent", role: "Interview Coach", service: "interview-service", description: "Interview prep specialist.", toolNames: ["prepare_interview_handoff"] },
|
||||
{ id: "roleplay", name: "Roleplay Agent", role: "Roleplay Coach", service: "roleplay-service", description: "Workplace conversation practice specialist.", toolNames: ["prepare_roleplay_handoff"] },
|
||||
{ id: "resume", name: "Resume Agent", role: "Resume Agent", service: "resume-service", description: "Resume positioning and optimization specialist.", toolNames: ["prepare_resume_handoff"] },
|
||||
{ id: "qscore", name: "Q Score Agent", role: "Q Score Analyst", service: "qscore-service", description: "Readiness score analyst.", toolNames: ["explain_qscore"] },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveMission(userId: string, missionInstanceId?: string) {
|
||||
if (missionInstanceId) return getActiveMissionPg(userId, missionInstanceId);
|
||||
const active = await listActiveMissionsPg(userId);
|
||||
return active[0] ?? null;
|
||||
}
|
||||
|
||||
function serviceHref(input: {
|
||||
serviceId: "interview-service" | "roleplay-service" | "resume-service";
|
||||
missionInstanceId: string;
|
||||
missionId: string;
|
||||
stageId?: string;
|
||||
goal?: string;
|
||||
}) {
|
||||
return buildMissionServiceRoute(input);
|
||||
}
|
||||
|
||||
function buildConversationTools(ctx: ConversationRuntimeContext = {}) {
|
||||
const userId = ctx.userId;
|
||||
return {
|
||||
listMissionState: tool({
|
||||
description: "Read mission snapshot, open actions, and suggestions for the current or requested mission.",
|
||||
inputSchema: z.object({ missionInstanceId: z.string().optional() }),
|
||||
execute: async ({ missionInstanceId }) => {
|
||||
if (!userId) return { error: "missing_user_context" };
|
||||
const active = await resolveMission(userId, missionInstanceId ?? ctx.missionInstanceId);
|
||||
if (!active) return { mission: null, actions: [], suggestions: [] };
|
||||
return {
|
||||
mission: active.mission,
|
||||
snapshot: active.snapshot,
|
||||
actions: await listMissionActions(userId, { missionInstanceId: active.mission.instanceId }),
|
||||
suggestions: await listMissionSuggestionsPg(userId, active.mission.instanceId),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
listRegistryCapabilities: tool({
|
||||
description: "List deterministic registry missions, service capabilities, and specialist agents. This does not rank or generate missions.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({
|
||||
missions: listMissionDefinitions().map((mission) => ({
|
||||
id: mission.id,
|
||||
missionId: mission.missionId,
|
||||
title: mission.title,
|
||||
shortTitle: mission.shortTitle,
|
||||
actorBacked: mission.actorBacked,
|
||||
modules: mission.modules.map((module) => ({
|
||||
id: module.id,
|
||||
title: module.title,
|
||||
role: module.role,
|
||||
service: module.service,
|
||||
})),
|
||||
})),
|
||||
services: listServiceCapabilities(),
|
||||
agents: safeAgentRegistry(),
|
||||
}),
|
||||
}),
|
||||
|
||||
prepareServiceHandoff: tool({
|
||||
description: "Prepare an interview, roleplay, or resume handoff as a mission action and return the UI route. Do not directly complete the service.",
|
||||
inputSchema: z.object({
|
||||
serviceId: z.enum(["interview-service", "roleplay-service", "resume-service"]),
|
||||
missionInstanceId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
goal: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ serviceId, missionInstanceId, stageId, title, body, goal }) => {
|
||||
if (!userId) return { error: "missing_user_context" };
|
||||
const active = await resolveMission(userId, missionInstanceId ?? ctx.missionInstanceId);
|
||||
if (!active) return { error: "mission_not_found" };
|
||||
const selectedStageId = stageId ?? ctx.stageId ?? active.mission.currentStageId;
|
||||
const href = serviceHref({
|
||||
serviceId,
|
||||
missionInstanceId: active.mission.instanceId,
|
||||
missionId: active.mission.missionId,
|
||||
stageId: selectedStageId,
|
||||
goal: goal ?? active.mission.goal,
|
||||
});
|
||||
const agent = safeAgentRegistry().find((item) => item.service === serviceId);
|
||||
const action = await createMissionAction({
|
||||
userId,
|
||||
missionInstanceId: active.mission.instanceId,
|
||||
missionId: active.mission.missionId,
|
||||
stageId: selectedStageId,
|
||||
agentId: agent?.id ?? serviceId,
|
||||
agentName: agent?.name ?? serviceId,
|
||||
baseAgent: agent?.role,
|
||||
serviceId,
|
||||
toolName: `prepare_${serviceId.replace("-service", "")}_handoff`,
|
||||
mode: "suggestion",
|
||||
status: "queued",
|
||||
title: title ?? `Open ${agent?.name ?? serviceId}`,
|
||||
body: body ?? `Continue this mission in ${agent?.name ?? serviceId}.`,
|
||||
prompt: goal ?? active.mission.goal,
|
||||
payload: { href, goal: goal ?? active.mission.goal, source: "conversation-actor" },
|
||||
idempotencyKey: `conversation-handoff:${active.mission.instanceId}:${selectedStageId ?? "mission"}:${serviceId}`,
|
||||
priority: 25,
|
||||
urgency: "today",
|
||||
});
|
||||
return { action, href, serviceId, missionInstanceId: active.mission.instanceId };
|
||||
},
|
||||
}),
|
||||
|
||||
askSubAgent: tool({
|
||||
description: "Ask a specialist sub-agent for a focused answer without starting a service session.",
|
||||
inputSchema: z.object({
|
||||
agentId: z.enum(["interview", "roleplay", "resume", "qscore"]),
|
||||
question: z.string(),
|
||||
context: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ agentId, question, context }) => {
|
||||
const agent = safeAgentRegistry().find((item) => item.id === agentId);
|
||||
const answer = await generateText({
|
||||
model: getConversationModel(),
|
||||
system: `You are ${agent?.name ?? agentId}, a GrowQR specialist. Be concise and practical. Do not start external tools.`,
|
||||
prompt: `Question:\n${question}\n\nContext:\n${context ?? "No extra context."}`,
|
||||
});
|
||||
return { agent, answerMd: answer.text };
|
||||
},
|
||||
}),
|
||||
|
||||
readMemory: tool({
|
||||
description: "Read a markdown memory file for this user.",
|
||||
inputSchema: z.object({ path: z.string() }),
|
||||
execute: async ({ path }) => {
|
||||
if (!userId) return { error: "missing_user_context" };
|
||||
return { memory: await getRivetClient().memoryActor.getOrCreate([userId]).read(path) };
|
||||
},
|
||||
}),
|
||||
|
||||
writeMemory: tool({
|
||||
description: "Write a durable markdown memory file for this user.",
|
||||
inputSchema: z.object({
|
||||
path: z.string(),
|
||||
contentMd: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}),
|
||||
execute: async ({ path, contentMd, tags }) => {
|
||||
if (!userId) return { error: "missing_user_context" };
|
||||
const result = await getRivetClient().memoryActor.getOrCreate([userId]).write({ path, contentMd, tags });
|
||||
return { path, queued: result.queued };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function streamConversationResponse(messages: ModelConversationMessage[], context: ConversationRuntimeContext = {}) {
|
||||
const system = [SYSTEM_PROMPT, context.systemAddendum].filter(Boolean).join("\n\n");
|
||||
if (context.source === "daily-mission-start") {
|
||||
return streamText({
|
||||
model: getConversationModel(),
|
||||
system,
|
||||
messages: buildModelMessages(messages),
|
||||
});
|
||||
}
|
||||
|
||||
return streamText({
|
||||
model: getConversationModel(),
|
||||
system: SYSTEM_PROMPT,
|
||||
system,
|
||||
messages: buildModelMessages(messages),
|
||||
tools: {
|
||||
readMemory: tool({
|
||||
description: "Read a markdown memory file. Stubbed until memoryActor is wired.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("Memory path, e.g. /profile.md"),
|
||||
}),
|
||||
execute: async ({ path }) => ({
|
||||
path,
|
||||
found: false,
|
||||
content: "",
|
||||
note: "memoryActor is not wired yet",
|
||||
}),
|
||||
}),
|
||||
writeMemory: tool({
|
||||
description: "Write a markdown memory file. Stubbed until memoryActor is wired.",
|
||||
inputSchema: z.object({
|
||||
path: z.string(),
|
||||
contentMd: z.string(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ path, contentMd, reason }) => ({
|
||||
path,
|
||||
bytes: contentMd.length,
|
||||
reason,
|
||||
saved: false,
|
||||
note: "memoryActor is not wired yet",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
tools: buildConversationTools(context),
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateConversationResponse(messages: ModelConversationMessage[], context: ConversationRuntimeContext = {}) {
|
||||
const system = [SYSTEM_PROMPT, context.systemAddendum].filter(Boolean).join("\n\n");
|
||||
return generateText({
|
||||
model: getConversationModel(),
|
||||
system,
|
||||
messages: buildModelMessages(messages),
|
||||
tools: buildConversationTools(context),
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ function conversationIdFromKey(key: unknown[]) {
|
||||
return String(key[1] ?? key[0] ?? "default");
|
||||
}
|
||||
|
||||
function userIdFromKey(key: unknown[]) {
|
||||
return String(key[0] ?? "");
|
||||
}
|
||||
|
||||
function toPublicMessage(row: typeof conversationMessages.$inferSelect): ConversationMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -118,7 +122,14 @@ export const conversationActor = actor({
|
||||
c.broadcast("status", c.state.status);
|
||||
|
||||
try {
|
||||
const result = streamConversationResponse(history);
|
||||
const result = streamConversationResponse(history, {
|
||||
userId: body.context?.userId ?? userIdFromKey(c.key),
|
||||
conversationId,
|
||||
missionInstanceId: body.context?.missionInstanceId,
|
||||
missionId: body.context?.missionId,
|
||||
stageId: body.context?.stageId,
|
||||
source: body.context?.source,
|
||||
});
|
||||
|
||||
let content = "";
|
||||
for await (const delta of result.textStream) {
|
||||
|
||||
@@ -18,6 +18,14 @@ export type ConversationMessage = {
|
||||
export type ConversationQueueMessage = {
|
||||
text: string;
|
||||
sender?: string;
|
||||
context?: {
|
||||
userId?: string;
|
||||
conversationId?: string;
|
||||
missionInstanceId?: string;
|
||||
missionId?: string;
|
||||
stageId?: string;
|
||||
source?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConversationResponseEvent = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getProjectionInsight } from "../../events/projectors/projection-agent.j
|
||||
import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing } from "../../events/record-grow-event.js";
|
||||
import { reducersForMission } from "../../missions/event-reducers.js";
|
||||
import type { MissionArtifactPatch, MissionStagePatch } from "../../missions/reducer-types.js";
|
||||
import { createMissionActionsFromPatches } from "../../missions/actions.js";
|
||||
|
||||
export type UserEventCommand = {
|
||||
userId: string;
|
||||
@@ -165,20 +166,21 @@ export const userEventActor = actor({
|
||||
const client = loopCtx.client<any>();
|
||||
for (const active of activeRows) {
|
||||
const mission = active.mission;
|
||||
const actorHandle = missionActorHandle(client, cmd.userId, mission);
|
||||
if (!actorHandle) continue;
|
||||
await actorHandle.ingestEvent({ eventId: row.id }).catch(() => undefined);
|
||||
|
||||
const reducers = reducersForMission(mission.missionId);
|
||||
if (!reducers.length) continue;
|
||||
for (const reducer of reducers) {
|
||||
const reduceCtx = { userId: cmd.userId, activeMission: mission, event: row, qscoreSignals: qscoreResult.signals, insight };
|
||||
if (!reducer.accepts(reduceCtx)) continue;
|
||||
const reduction = reducer.reduce(reduceCtx);
|
||||
if (!reduction.stagePatches.length && !reduction.artifacts.length && !reduction.eventMessage) continue;
|
||||
|
||||
const actorHandle = missionActorHandle(client, cmd.userId, mission);
|
||||
if (!actorHandle) continue;
|
||||
if (!reduction.stagePatches.length && !reduction.artifacts.length && !reduction.actions.length && !reduction.eventMessage) continue;
|
||||
if (reduction.eventMessage) {
|
||||
await actorHandle.recordEvent({ type: row.type, message: reduction.eventMessage, payload: { sourceEventId: row.id } });
|
||||
}
|
||||
let snapshot: MissionSnapshot | undefined = reduction.stagePatches.length ? (await applyStagePatches(actorHandle, reduction.stagePatches) ?? undefined) : undefined;
|
||||
if (reduction.stagePatches.length) await applyStagePatches(actorHandle, reduction.stagePatches);
|
||||
await applyArtifactPatches({
|
||||
actorHandle,
|
||||
userId: cmd.userId,
|
||||
@@ -188,6 +190,14 @@ export const userEventActor = actor({
|
||||
externalId: typeof row.correlation?.sessionId === "string" ? row.correlation.sessionId : undefined,
|
||||
patches: reduction.artifacts,
|
||||
});
|
||||
if (reduction.actions.length) {
|
||||
await createMissionActionsFromPatches({
|
||||
userId: cmd.userId,
|
||||
mission,
|
||||
eventId: row.id,
|
||||
patches: reduction.actions,
|
||||
});
|
||||
}
|
||||
const finalSnapshot = (await actorHandle.getState()) as MissionSnapshot;
|
||||
await upsertActiveMissionPg(cmd.userId, summarizeMissionSnapshot(finalSnapshot), finalSnapshot);
|
||||
}
|
||||
|
||||
@@ -160,6 +160,66 @@ export const interviewToOfferMissionActor = actor({
|
||||
return entry;
|
||||
},
|
||||
|
||||
ingestEvent: (c, input: { eventId: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
const entry: MissionEvent = {
|
||||
id: eventId(),
|
||||
type: "mission.event_ingested",
|
||||
message: `Event ${input.eventId} ingested by mission runtime.`,
|
||||
payload: { eventId: input.eventId },
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
c.state.events.unshift(entry);
|
||||
c.state.updatedAt = entry.createdAt;
|
||||
c.broadcast("eventAdded", entry);
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
planNextActions: (c, input: { reason?: string } = {}) => {
|
||||
ensureInitialized(c.state);
|
||||
const active = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
|
||||
return {
|
||||
missionInstanceId: c.state.instanceId,
|
||||
missionId: c.state.missionId,
|
||||
currentStageId: active?.id,
|
||||
reason: input.reason ?? "manual",
|
||||
recommendation: active ? `Focus next on ${active.title}.` : "No open stage requires action right now.",
|
||||
};
|
||||
},
|
||||
|
||||
runDailyScrum: (c, input: { trigger?: "manual" | "nightly" } = {}) => {
|
||||
ensureInitialized(c.state);
|
||||
const active = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
|
||||
const entry: MissionEvent = {
|
||||
id: eventId(),
|
||||
type: "mission.daily_scrum.completed",
|
||||
message: active ? `Daily scrum: next focus is ${active.title}.` : "Daily scrum: mission has no blocked action right now.",
|
||||
payload: { trigger: input.trigger ?? "manual", currentStageId: active?.id },
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
c.state.events.unshift(entry);
|
||||
c.state.updatedAt = entry.createdAt;
|
||||
c.broadcast("eventAdded", entry);
|
||||
c.broadcast("updated", c.state);
|
||||
return { snapshot: c.state, summary: entry.message };
|
||||
},
|
||||
|
||||
queueAction: (c, input: { actionId: string; title?: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
return { queued: true, actionId: input.actionId, missionInstanceId: c.state.instanceId, title: input.title };
|
||||
},
|
||||
|
||||
runAction: (c, input: { actionId: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
return { started: true, actionId: input.actionId, missionInstanceId: c.state.instanceId };
|
||||
},
|
||||
|
||||
resolveHitl: (c, input: { actionId: string; resolution: string; input?: Record<string, unknown> }) => {
|
||||
ensureInitialized(c.state);
|
||||
return { resolved: true, actionId: input.actionId, resolution: input.resolution, missionInstanceId: c.state.instanceId };
|
||||
},
|
||||
|
||||
updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
const stage = c.state.stages.find((item) => item.id === input.stageId);
|
||||
|
||||
@@ -166,6 +166,67 @@ export function createMissionActor(options: {
|
||||
return entry;
|
||||
},
|
||||
|
||||
ingestEvent: (c, input: { eventId: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
const entry: MissionEvent = {
|
||||
id: eventId(),
|
||||
type: "mission.event_ingested",
|
||||
message: `Event ${input.eventId} ingested by mission runtime.`,
|
||||
payload: { eventId: input.eventId },
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
c.state.events.unshift(entry);
|
||||
c.state.updatedAt = entry.createdAt;
|
||||
c.broadcast("eventAdded", entry);
|
||||
c.broadcast("updated", c.state);
|
||||
return c.state;
|
||||
},
|
||||
|
||||
planNextActions: (c, input: { reason?: string } = {}) => {
|
||||
ensureInitialized(c.state);
|
||||
const blocked = c.state.stages.find((stage) => stage.status === "blocked");
|
||||
const active = blocked ?? c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress");
|
||||
return {
|
||||
missionInstanceId: c.state.instanceId,
|
||||
missionId: c.state.missionId,
|
||||
currentStageId: active?.id,
|
||||
reason: input.reason ?? "manual",
|
||||
recommendation: active ? `Focus next on ${active.title}.` : "No open stage requires action right now.",
|
||||
};
|
||||
},
|
||||
|
||||
runDailyScrum: (c, input: { trigger?: "manual" | "nightly" } = {}) => {
|
||||
ensureInitialized(c.state);
|
||||
const recommendation = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
|
||||
const entry: MissionEvent = {
|
||||
id: eventId(),
|
||||
type: "mission.daily_scrum.completed",
|
||||
message: recommendation ? `Daily scrum: next focus is ${recommendation.title}.` : "Daily scrum: mission has no blocked action right now.",
|
||||
payload: { trigger: input.trigger ?? "manual", currentStageId: recommendation?.id },
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
c.state.events.unshift(entry);
|
||||
c.state.updatedAt = entry.createdAt;
|
||||
c.broadcast("eventAdded", entry);
|
||||
c.broadcast("updated", c.state);
|
||||
return { snapshot: c.state, summary: entry.message };
|
||||
},
|
||||
|
||||
queueAction: (c, input: { actionId: string; title?: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
return { queued: true, actionId: input.actionId, missionInstanceId: c.state.instanceId, title: input.title };
|
||||
},
|
||||
|
||||
runAction: (c, input: { actionId: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
return { started: true, actionId: input.actionId, missionInstanceId: c.state.instanceId };
|
||||
},
|
||||
|
||||
resolveHitl: (c, input: { actionId: string; resolution: string; input?: Record<string, unknown> }) => {
|
||||
ensureInitialized(c.state);
|
||||
return { resolved: true, actionId: input.actionId, resolution: input.resolution, missionInstanceId: c.state.instanceId };
|
||||
},
|
||||
|
||||
updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => {
|
||||
ensureInitialized(c.state);
|
||||
const stage = c.state.stages.find((item) => item.id === input.stageId);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { conversationActor } from "./conversation/index.js";
|
||||
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,
|
||||
@@ -18,6 +20,8 @@ export const registry = setup({
|
||||
use: {
|
||||
growActor,
|
||||
userEventActor,
|
||||
analyticsActor,
|
||||
curatorActor,
|
||||
conversationActor,
|
||||
memoryActor,
|
||||
interviewToOfferMissionActor,
|
||||
|
||||
@@ -189,7 +189,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_session",
|
||||
description: "Create a real interview practice session via the Interview Agent / interview-service microservice.",
|
||||
description: "Create a real mock interview session via the interview-service microservice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { goal: { type: "string" } },
|
||||
@@ -201,7 +201,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_roleplay_session",
|
||||
description: "Create a real roleplay practice session via the Roleplay Agent / roleplay-service microservice.",
|
||||
description: "Create a real mock roleplay session via the roleplay-service microservice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { goal: { type: "string" } },
|
||||
@@ -213,7 +213,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "compute_qscore",
|
||||
description: "Compute or refresh the user's Q-Score via the Q Score Agent / qscore-service microservice.",
|
||||
description: "Compute or refresh the user's Q Score via the qscore-service microservice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -225,7 +225,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "analyze_resume",
|
||||
description: "Analyze the user's resume using the Resume Agent microservice. Returns completeness score, skill gaps, and optimization recommendations.",
|
||||
description: "Analyze the user's resume using the Resume Building microservice. Returns completeness score, skill gaps, and optimization recommendations.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -253,7 +253,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_to_offer",
|
||||
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze & tailor resume for the role, (2) Create interview practice session with the Interview Agent, (3) Create roleplay session with Roleplay Agent, (4) Compute Q-Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
|
||||
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze and tailor the resume for the role, (2) Create mock interview practice, (3) Create mock roleplay practice, (4) Compute Q Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -563,7 +563,7 @@ export const userActor = actor({
|
||||
|
||||
appendTimelineEvent(
|
||||
c.state,
|
||||
{ id: "grow", name: "Grow Agent" },
|
||||
{ id: "grow", name: "Grow" },
|
||||
"workflow",
|
||||
`${getWorkflowDefinition(workflowId)?.title ?? "Workflow"} started.`,
|
||||
);
|
||||
@@ -581,14 +581,14 @@ export const userActor = actor({
|
||||
|
||||
pauseWorkflow: async (c) => {
|
||||
c.state.workflowStatus = "paused";
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow paused.");
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow paused.");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
return c.state;
|
||||
},
|
||||
|
||||
resumeWorkflow: async (c) => {
|
||||
c.state.workflowStatus = "running";
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow resumed.");
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow resumed.");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
return c.state;
|
||||
},
|
||||
@@ -753,7 +753,7 @@ async function dispatchUnifiedTool(
|
||||
c.state.modules = makeModules();
|
||||
c.state.createdAt = now();
|
||||
c.state.updatedAt = now();
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow started via LLM tool.");
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", "Workflow started via LLM tool.");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
return { ok: true, workflowId: c.state.workflowId, goal };
|
||||
}
|
||||
@@ -799,7 +799,7 @@ async function dispatchUnifiedTool(
|
||||
case "start_roleplay_session": {
|
||||
const goal = String(input.goal ?? "");
|
||||
const roleplayModule = getSubAgentModule("roleplay");
|
||||
if (!roleplayModule?.service) return { ok: false, error: "Roleplay Agent module not available" };
|
||||
if (!roleplayModule?.service) return { ok: false, error: "Mock Roleplay module not available" };
|
||||
const result = await runServiceAgentProbe(
|
||||
{ id: roleplayModule.id, name: roleplayModule.name, role: roleplayModule.role, kind: "microservice", description: roleplayModule.description, service: roleplayModule.service },
|
||||
{ userId, goal },
|
||||
@@ -855,14 +855,14 @@ async function dispatchUnifiedTool(
|
||||
c.state.createdAt = now();
|
||||
c.state.updatedAt = now();
|
||||
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", `Interview-to-Offer workflow started for: ${goal}`);
|
||||
appendTimelineEvent(c.state, { id: "grow", name: "Grow" }, "workflow", `Interview-to-Offer workflow started for: ${goal}`);
|
||||
|
||||
// Step 1: Resume Agent — analyze and tailor
|
||||
// Step 1: Resume Building — analyze and tailor
|
||||
const resumeModule = getSubAgentModule("resume");
|
||||
const resumeMod = c.state.modules.find(m => m.id === "resume");
|
||||
if (resumeMod && resumeModule) {
|
||||
resumeMod.status = "running";
|
||||
appendTimelineEvent(c.state, resumeMod, "module", "Resume Agent analyzing your profile...");
|
||||
appendTimelineEvent(c.state, resumeMod, "module", "Resume Building is analyzing your profile...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
@@ -875,18 +875,18 @@ async function dispatchUnifiedTool(
|
||||
appendTimelineEvent(c.state, resumeMod, "module", resumeResult.summary);
|
||||
} catch (err) {
|
||||
resumeMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, resumeMod, "module", `Resume Agent failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
appendTimelineEvent(c.state, resumeMod, "module", `Resume Building failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 2: Interview Agent — create interview session
|
||||
// Step 2: Mock Interview — create interview session
|
||||
const interviewModule = getSubAgentModule("interview");
|
||||
const interviewMod = c.state.modules.find(m => m.id === "interview");
|
||||
if (interviewMod && interviewModule?.service) {
|
||||
interviewMod.status = "running";
|
||||
appendTimelineEvent(c.state, interviewMod, "module", "Interview Agent creating interview practice session...");
|
||||
appendTimelineEvent(c.state, interviewMod, "module", "Mock Interview is creating an interview practice session...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
@@ -905,12 +905,12 @@ async function dispatchUnifiedTool(
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 3: Roleplay Agent — create roleplay session
|
||||
// Step 3: Mock Roleplay — create roleplay session
|
||||
const roleplayModule = getSubAgentModule("roleplay");
|
||||
const roleplayMod = c.state.modules.find(m => m.id === "roleplay");
|
||||
if (roleplayMod && roleplayModule?.service) {
|
||||
roleplayMod.status = "running";
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", "Roleplay Agent creating roleplay scenario...");
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", "Mock Roleplay is creating a practice scenario...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
@@ -923,18 +923,18 @@ async function dispatchUnifiedTool(
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", roleplayResult.summary);
|
||||
} catch (err) {
|
||||
roleplayMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", `Roleplay Agent session failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
appendTimelineEvent(c.state, roleplayMod, "module", `Mock Roleplay session failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 4: Q Score Agent — compute Q-Score
|
||||
// Step 4: Q Score — compute readiness
|
||||
const qscoreModule = getSubAgentModule("qscore");
|
||||
const qscoreMod = c.state.modules.find(m => m.id === "qscore");
|
||||
if (qscoreMod && qscoreModule?.service) {
|
||||
qscoreMod.status = "running";
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", "Q Score Agent computing your readiness Q-Score...");
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", "Q Score is computing your readiness score...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
@@ -947,7 +947,7 @@ async function dispatchUnifiedTool(
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", qscoreResult.summary);
|
||||
} catch (err) {
|
||||
qscoreMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", `Q-Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", `Q Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export function jobApplicationModuleIds(): string[] {
|
||||
return loaderJobApplicationModuleIds();
|
||||
}
|
||||
|
||||
// Build the unified Grow Agent system prompt from disk (changes.md §3).
|
||||
// Build the unified Grow system prompt from disk (changes.md §3).
|
||||
export function buildUnifiedSystemPrompt(): string {
|
||||
return getUnifiedSystemPrompt();
|
||||
}
|
||||
|
||||
449
src/agents/daily-mission.ts
Normal file
449
src/agents/daily-mission.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import { generateText } from "ai";
|
||||
import { z } from "zod";
|
||||
import { getConversationModel, streamConversationResponse } from "../actors/conversation/agent.js";
|
||||
|
||||
export const dailyMissionTaskSchema = z.object({
|
||||
day: z.number().optional(),
|
||||
questId: z.string().optional(),
|
||||
questTitle: z.string(),
|
||||
subtaskIndex: z.number().optional(),
|
||||
subtask: z.string(),
|
||||
service: z.string().optional(),
|
||||
route: z.string().optional(),
|
||||
intro: z.string().optional(),
|
||||
context: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
|
||||
signals: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const dailyMissionMessageSchema = z.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string().min(1).max(4000),
|
||||
});
|
||||
|
||||
export type DailyMissionTask = z.infer<typeof dailyMissionTaskSchema>;
|
||||
export type DailyMissionMessage = z.infer<typeof dailyMissionMessageSchema>;
|
||||
|
||||
const dailyMissionResponseSchema = z.object({
|
||||
reply: z.string(),
|
||||
completed: z.boolean().default(false),
|
||||
updateSummary: z.string().optional(),
|
||||
actionLabel: z.string().optional(),
|
||||
actionRoute: z.string().optional(),
|
||||
});
|
||||
|
||||
export type DailyMissionResult = z.infer<typeof dailyMissionResponseSchema>;
|
||||
|
||||
type DailyMissionAgentInput = {
|
||||
userId: string;
|
||||
task: DailyMissionTask;
|
||||
messages: DailyMissionMessage[];
|
||||
missionInstanceId?: string;
|
||||
missionId?: string;
|
||||
stageId?: string;
|
||||
conversationId?: string;
|
||||
};
|
||||
|
||||
function stripJsonFence(text: string) {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseDailyMissionResponse(text: string) {
|
||||
const normalize = (value: z.infer<typeof dailyMissionResponseSchema>) => {
|
||||
const nested = maybeParseJsonReply(value.reply);
|
||||
return nested ? { ...value, ...nested } : value;
|
||||
};
|
||||
|
||||
try {
|
||||
return normalize(dailyMissionResponseSchema.parse(JSON.parse(stripJsonFence(text))));
|
||||
} catch {
|
||||
return {
|
||||
reply: cleanAssistantReply(text) || "I could not prepare the next step. Try again.",
|
||||
completed: false,
|
||||
updateSummary: undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function maybeParseJsonReply(text: string) {
|
||||
try {
|
||||
return dailyMissionResponseSchema.partial().parse(JSON.parse(stripJsonFence(text)));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanAssistantReply(text: string) {
|
||||
const stripped = stripJsonFence(text);
|
||||
const nested = maybeParseJsonReply(stripped);
|
||||
if (nested?.reply) return nested.reply;
|
||||
const match = stripped.match(/^\s*\{[\s\S]*"reply"\s*:\s*"([\s\S]*?)"[\s\S]*\}\s*$/);
|
||||
const captured = match?.[1];
|
||||
if (!captured) return stripped.trim();
|
||||
try {
|
||||
return JSON.parse(`"${captured}"`);
|
||||
} catch {
|
||||
return captured.replace(/\\"/g, '"').replace(/\\u2011/g, "-").trim();
|
||||
}
|
||||
}
|
||||
|
||||
function isInterviewMission(task: DailyMissionTask) {
|
||||
const service = (task.service ?? "").toLowerCase();
|
||||
const routePath = task.route ? new URL(task.route, "https://growqr.local").pathname.toLowerCase() : "";
|
||||
const text = [task.questTitle, task.subtask].filter(Boolean).join(" ").toLowerCase();
|
||||
if (service.includes("resume") || routePath.includes("/agents/resume")) return false;
|
||||
if (service.includes("roleplay") || routePath.includes("/agents/roleplay")) return false;
|
||||
if (service.includes("q score") || service.includes("qscore") || routePath.includes("/agents/qscore")) return false;
|
||||
return service.includes("interview") || routePath.includes("/agents/interview") || text.includes("mock question");
|
||||
}
|
||||
|
||||
function getInterviewActionRoute(task: DailyMissionTask) {
|
||||
const source = new URL(task.route ?? "/agents/interview", "https://growqr.local");
|
||||
const roleFromContext = task.context?.find((item) => item.label.toLowerCase().includes("role"))?.value;
|
||||
const params = new URLSearchParams();
|
||||
params.set("role", source.searchParams.get("role") ?? roleFromContext ?? "Product Manager");
|
||||
params.set("type", source.searchParams.get("type") ?? "behavioral");
|
||||
params.set("persona", "payal");
|
||||
params.set("duration", "5");
|
||||
params.set("difficulty", source.searchParams.get("difficulty") ?? "medium");
|
||||
params.set("media", "video");
|
||||
params.set("source", "daily-mission");
|
||||
return `/agents/interview/preview?${params.toString()}`;
|
||||
}
|
||||
|
||||
function compactAnswer(answer: string) {
|
||||
return answer.length > 180 ? `${answer.slice(0, 177).trimEnd()}...` : answer;
|
||||
}
|
||||
|
||||
function isConfidenceCheck(task: DailyMissionTask) {
|
||||
const haystack = [task.questTitle, task.subtask, task.service, task.intro].filter(Boolean).join(" ").toLowerCase();
|
||||
return haystack.includes("confidence check") || (haystack.includes("qx") && haystack.includes("confidence"));
|
||||
}
|
||||
|
||||
function buildDailyMissionSystemPrompt(task: DailyMissionTask) {
|
||||
return `You are Daily Mission, a focused GrowQR dashboard agent.
|
||||
|
||||
The user clicked on the main task called ${task.questTitle}${task.intro ? ` (${task.intro})` : ""} and the subtask within it called ${task.subtask}. You are going to act as an interface for the user to complete this subtask. Garner the right questions and get the input from the user.
|
||||
|
||||
The frontend will send "Start". From there onwards, start with your first message to the user.
|
||||
|
||||
Rules:
|
||||
- Ask one short question or give one short action at a time.
|
||||
- Do not start a larger mission, do not pitch other workflows, and do not send the user away.
|
||||
- Keep the tone warm, practical, and easy to answer.
|
||||
- When the user answer is enough to satisfy the subtask, mark the task complete by returning completed=true.
|
||||
- Return a single JSON object only. Do not wrap the object in a string. Shape: {"reply":"message to show","completed":false,"updateSummary":"optional short saved update"}.`;
|
||||
}
|
||||
|
||||
function buildDailyMissionStreamingSystemPrompt(task: DailyMissionTask) {
|
||||
return `You are the GrowQR conversation actor attached to a mission actor.
|
||||
|
||||
The user clicked a mission task card:
|
||||
${formatTask(task)}
|
||||
|
||||
Your job is to make the mission feel alive:
|
||||
- The mission actor owns progress and completion.
|
||||
- You own the chat turn and can prepare service handoffs.
|
||||
- The service capability is ${task.service ?? "unknown service"} at ${task.route ?? "unknown route"}.
|
||||
|
||||
Rules:
|
||||
- Plain text only. Do not return JSON.
|
||||
- On the first "start" message, do not use a template line like "This is a service handoff".
|
||||
- Ask the next natural question required to advance this exact mission stage.
|
||||
- If the service is Resume, ask for the resume text/file and target role or section in a natural way.
|
||||
- If the service is Interview, ask for role, round type, and the one thing they want to improve.
|
||||
- If the service is Roleplay, ask for scenario, counterpart, and desired outcome.
|
||||
- Keep it short, warm, and specific.`;
|
||||
}
|
||||
|
||||
function withDailyMissionActionDefaults(task: DailyMissionTask, result: z.infer<typeof dailyMissionResponseSchema>) {
|
||||
if (!result.completed || !isInterviewMission(task)) return result;
|
||||
return {
|
||||
...result,
|
||||
actionLabel: result.actionLabel ?? "Generate room",
|
||||
actionRoute: result.actionRoute ?? getInterviewActionRoute(task),
|
||||
};
|
||||
}
|
||||
|
||||
function serviceStartReply(task: DailyMissionTask) {
|
||||
const service = (task.service ?? "").toLowerCase();
|
||||
const routePath = task.route ? new URL(task.route, "https://growqr.local").pathname.toLowerCase() : "";
|
||||
|
||||
if (service.includes("resume") || routePath.includes("/agents/resume")) {
|
||||
return "This is a Resume service handoff. Tell me the target role or resume section you want to improve, and I will save it on this mission stage.";
|
||||
}
|
||||
if (service.includes("roleplay") || routePath.includes("/agents/roleplay")) {
|
||||
return "This is a Roleplay service handoff. Tell me the scenario you want to practice and the outcome you want from the conversation.";
|
||||
}
|
||||
if (service.includes("q score") || service.includes("qscore") || routePath.includes("/agents/qscore")) {
|
||||
return "This is a Q Score check. Tell me the signal you want to improve or the readiness question you want scored.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function latestUserMessage(messages: DailyMissionMessage[]) {
|
||||
return [...messages].reverse().find((message) => message.role === "user")?.content.trim() ?? "";
|
||||
}
|
||||
|
||||
function firstQuestionForTask(task: DailyMissionTask) {
|
||||
const subtask = task.subtask.toLowerCase();
|
||||
const title = task.questTitle.toLowerCase();
|
||||
const intro = (task.intro ?? "").toLowerCase();
|
||||
const service = (task.service ?? "").toLowerCase();
|
||||
const routePath = task.route ? new URL(task.route, "https://growqr.local").pathname.toLowerCase() : "";
|
||||
const isResume = service.includes("resume") || routePath.includes("/agents/resume");
|
||||
const isInterview = service.includes("interview") || routePath.includes("/agents/interview");
|
||||
const isRoleplay = service.includes("roleplay") || routePath.includes("/agents/roleplay");
|
||||
const isPlanner = service.includes("mission planner") || title.includes("target role") || intro.includes("target role") || title.includes("career transition");
|
||||
|
||||
if (subtask.includes("save") || subtask.includes("next action")) {
|
||||
if (isPlanner) return "What next career move should I save: target role, skill gap, or outreach action?";
|
||||
if (isResume) return "What next action should I save: revise bullets, fill gaps, or generate talking points?";
|
||||
if (isInterview) return "What interview prep action should I save for the student to do next?";
|
||||
if (isRoleplay) return "What roleplay action should I save for the next practice round?";
|
||||
return `What next action should I save for "${task.subtask}"?`;
|
||||
}
|
||||
|
||||
if (subtask.includes("handoff") || subtask.includes("prepare")) {
|
||||
if (isPlanner) return "Should I prepare a role shortlist, transition plan, or skill-gap plan?";
|
||||
if (isResume) return "Which resume handoff should I prepare: role-fit proof, gap scan, or talking points?";
|
||||
if (isInterview) return "What interview setup should I prepare: role, round type, and difficulty?";
|
||||
if (isRoleplay) return "What roleplay setup should I prepare: scenario, counterpart, and outcome?";
|
||||
return `What handoff should I prepare for "${task.subtask}"?`;
|
||||
}
|
||||
|
||||
if (isResume) {
|
||||
return "Please share your resume text or file and the target role.";
|
||||
}
|
||||
|
||||
if (isInterview) {
|
||||
return "What role and interview round should this prep focus on?";
|
||||
}
|
||||
|
||||
if (isRoleplay) {
|
||||
return "What conversation scenario do you want to practice?";
|
||||
}
|
||||
|
||||
if (isPlanner) {
|
||||
if (subtask.includes("target role")) {
|
||||
return "What is your current role, target role, and biggest transition constraint?";
|
||||
}
|
||||
if (subtask.includes("requirements")) {
|
||||
return "What requirement should we check first: skills, experience, location, or timeline?";
|
||||
}
|
||||
if (subtask.includes("review") || subtask.includes("recommendation")) {
|
||||
return "Which role option should we review first, and what matters most to you?";
|
||||
}
|
||||
return "What target role are you considering, and what constraint should I account for?";
|
||||
}
|
||||
|
||||
if (subtask.includes("target role")) {
|
||||
return "What is your current role, target role, and biggest constraint?";
|
||||
}
|
||||
|
||||
if (subtask.includes("requirements")) {
|
||||
return "Which requirement should we check first: skills, experience, location, or timeline?";
|
||||
}
|
||||
|
||||
return `What is the key detail for "${task.subtask}"?`;
|
||||
}
|
||||
|
||||
function buildConversationActorMessages(input: DailyMissionAgentInput) {
|
||||
const latest = latestUserMessage(input.messages);
|
||||
const isStart = latest.toLowerCase() === "start";
|
||||
const transcript = input.messages
|
||||
.filter((message) => message.content.trim().toLowerCase() !== "start")
|
||||
.slice(-10)
|
||||
.map((message) => `${message.role === "user" ? "User" : "Assistant"}: ${message.content}`)
|
||||
.join("\n");
|
||||
|
||||
return [{
|
||||
id: `daily-mission-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
conversationId: input.conversationId ?? "daily-mission",
|
||||
role: "user" as const,
|
||||
sender: "Daily Mission UI",
|
||||
createdAt: Date.now(),
|
||||
content: `The user opened a mission-linked Daily Mission chat.
|
||||
|
||||
Mission task:
|
||||
${formatTask(input.task)}
|
||||
|
||||
Mission ids:
|
||||
- missionInstanceId: ${input.missionInstanceId ?? "not linked yet"}
|
||||
- missionId: ${input.missionId ?? "unknown"}
|
||||
- stageId: ${input.stageId ?? "unknown"}
|
||||
|
||||
${transcript ? `Conversation so far:\n${transcript}\n\n` : ""}${isStart
|
||||
? "This is the first assistant turn. Ask one short direct question that advances this stage. No greeting. No filler. No em dash. No long paragraph. For resume, ask for resume text/file and target role."
|
||||
: `The user just replied: ${latest}\nRespond as the GrowQR conversation agent. If the answer is enough for this subtask, acknowledge what will be saved. Keep it concise. Use ASCII punctuation only.`}`,
|
||||
}];
|
||||
}
|
||||
|
||||
function runInterviewRoomSetup(task: DailyMissionTask, messages: DailyMissionMessage[]) {
|
||||
const latestUser = latestUserMessage(messages);
|
||||
const subtask = task.subtask.toLowerCase();
|
||||
const actionRoute = getInterviewActionRoute(task);
|
||||
|
||||
if (latestUser.toLowerCase() === "start") {
|
||||
if (subtask.includes("generate") || subtask.includes("jump") || subtask.includes("start")) {
|
||||
return {
|
||||
reply: "The interview room setup is ready. Review the details and tap Generate room to open the interview UI.",
|
||||
completed: true,
|
||||
updateSummary: "Interview room setup ready.",
|
||||
actionLabel: "Generate room",
|
||||
actionRoute,
|
||||
};
|
||||
}
|
||||
|
||||
if (subtask.includes("pressure") || subtask.includes("difficulty")) {
|
||||
return {
|
||||
reply: "Pick the pressure level for the student: easy, medium, or hard.",
|
||||
completed: false,
|
||||
updateSummary: undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reply: "What should this interview room be for? Share the role, round type, and one thing the student wants to improve.",
|
||||
completed: false,
|
||||
updateSummary: undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const updateSummary = compactAnswer(latestUser);
|
||||
return {
|
||||
reply: `Got it. I saved this for the student: ${updateSummary}. Tap Generate room when you are ready to open the interview UI.`,
|
||||
completed: true,
|
||||
updateSummary,
|
||||
actionLabel: "Generate room",
|
||||
actionRoute,
|
||||
};
|
||||
}
|
||||
|
||||
function formatTask(task: DailyMissionTask) {
|
||||
const lines = [
|
||||
task.day ? `Sprint day: ${task.day}` : undefined,
|
||||
`Quest: ${task.questTitle}`,
|
||||
`Subtask: ${task.subtask}`,
|
||||
task.service ? `Service: ${task.service}` : undefined,
|
||||
task.route ? `Service route: ${task.route}` : undefined,
|
||||
task.intro ? `Quest intent: ${task.intro}` : undefined,
|
||||
task.context?.length
|
||||
? `Visible context: ${task.context.map((item) => `${item.label}: ${item.value}`).join("; ")}`
|
||||
: undefined,
|
||||
task.signals?.length ? `Signals to improve: ${task.signals.join(", ")}` : undefined,
|
||||
].filter(Boolean);
|
||||
|
||||
return lines.map((line) => `- ${line}`).join("\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function runDailyMissionAgent(input: DailyMissionAgentInput) {
|
||||
const started = input.messages.some(
|
||||
(message) => message.role === "user" && message.content.trim().toLowerCase() === "start",
|
||||
);
|
||||
if (!started) {
|
||||
return {
|
||||
reply: "I am ready to begin this daily mission.",
|
||||
completed: false,
|
||||
updateSummary: undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (isInterviewMission(input.task)) {
|
||||
return runInterviewRoomSetup(input.task, input.messages);
|
||||
}
|
||||
|
||||
const transcript = input.messages
|
||||
.slice(-12)
|
||||
.map((message) => `${message.role === "user" ? "Student" : "Daily Mission"}: ${message.content}`)
|
||||
.join("\n");
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: getConversationModel(),
|
||||
system: buildDailyMissionSystemPrompt(input.task),
|
||||
prompt: `User id: ${input.userId}
|
||||
|
||||
Daily task context:
|
||||
${formatTask(input.task)}
|
||||
|
||||
Conversation so far:
|
||||
${transcript}`,
|
||||
});
|
||||
|
||||
return withDailyMissionActionDefaults(input.task, parseDailyMissionResponse(result.text));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn("daily mission model failed; returning unavailable state", { message });
|
||||
return {
|
||||
reply: "Daily mission is temporarily unavailable right now. No progress was saved. Please retry in a moment.",
|
||||
completed: false,
|
||||
updateSummary: undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamDailyMissionAgent(input: DailyMissionAgentInput) {
|
||||
const started = input.messages.some(
|
||||
(message) => message.role === "user" && message.content.trim().toLowerCase() === "start",
|
||||
);
|
||||
if (!started) {
|
||||
return { kind: "static" as const, result: await runDailyMissionAgent(input) };
|
||||
}
|
||||
|
||||
const latest = latestUserMessage(input.messages);
|
||||
const userMessagesAfterStart = input.messages.filter((message) => message.role === "user");
|
||||
const isStart = latest.toLowerCase() === "start";
|
||||
|
||||
if (isStart) {
|
||||
return {
|
||||
kind: "static" as const,
|
||||
result: {
|
||||
reply: firstQuestionForTask(input.task),
|
||||
completed: false,
|
||||
updateSummary: undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = streamConversationResponse(buildConversationActorMessages(input), {
|
||||
userId: input.userId,
|
||||
conversationId: input.conversationId,
|
||||
missionInstanceId: input.missionInstanceId,
|
||||
missionId: input.missionId,
|
||||
stageId: input.stageId,
|
||||
source: isStart ? "daily-mission-start" : "daily-mission",
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "stream" as const,
|
||||
textStream: result.textStream,
|
||||
finalize: (reply: string): DailyMissionResult => {
|
||||
const completed = !isStart && userMessagesAfterStart.length > 1 && reply.trim().length > 0;
|
||||
return {
|
||||
reply: cleanAssistantReply(reply),
|
||||
completed,
|
||||
updateSummary: completed ? compactAnswer(latest) : undefined,
|
||||
actionLabel: undefined,
|
||||
actionRoute: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
|
||||
const auth = c.req.header("authorization") ?? "";
|
||||
const token = auth.replace(/^Bearer\s+/i, "").trim();
|
||||
|
||||
// Service-to-service path (Grow Agent actor calling backend).
|
||||
// Service-to-service path (Grow stack calling backend).
|
||||
// Header `x-growqr-user` is REQUIRED so we can scope the call.
|
||||
const trustedServiceTokens = new Set(
|
||||
[
|
||||
|
||||
@@ -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 ??
|
||||
@@ -126,6 +144,12 @@ export const config = {
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
|
||||
// Passive mission refresh loop. Dedupe keys make this safe across retries and
|
||||
// multiple staging replicas; set MISSION_PASSIVE_LOOP_ENABLED=false to disable.
|
||||
missionPassiveLoopEnabled: (process.env.MISSION_PASSIVE_LOOP_ENABLED ?? "true").toLowerCase() !== "false",
|
||||
missionPassiveLoopIntervalMs: Number(process.env.MISSION_PASSIVE_LOOP_INTERVAL_MS ?? 60 * 60 * 1000),
|
||||
missionPassiveLoopBatchSize: Number(process.env.MISSION_PASSIVE_LOOP_BATCH_SIZE ?? 100),
|
||||
|
||||
// Used by LLM requests.
|
||||
maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096),
|
||||
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -280,6 +280,7 @@ export const growConversations = pgTable(
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull().default("Talk to Me"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
@@ -458,6 +459,52 @@ export const growQscoreProjectionState = pgTable("grow_qscore_projection_state",
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const missionActions = pgTable(
|
||||
"mission_actions",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
|
||||
missionId: text("mission_id").notNull(),
|
||||
stageId: text("stage_id"),
|
||||
|
||||
agentId: text("agent_id").notNull(),
|
||||
agentName: text("agent_name").notNull(),
|
||||
baseAgent: text("base_agent"),
|
||||
serviceId: text("service_id"),
|
||||
toolName: text("tool_name"),
|
||||
|
||||
mode: text("mode", { enum: ["autonomous", "approval_required", "user_input_required", "suggestion"] }).notNull(),
|
||||
status: text("status", {
|
||||
enum: ["queued", "running", "waiting_approval", "waiting_user_input", "done", "failed", "dismissed", "snoozed"],
|
||||
}).notNull().default("queued"),
|
||||
|
||||
title: text("title").notNull(),
|
||||
body: text("body").notNull(),
|
||||
prompt: text("prompt"),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
||||
result: jsonb("result").$type<Record<string, unknown>>(),
|
||||
error: text("error"),
|
||||
|
||||
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
|
||||
idempotencyKey: text("idempotency_key"),
|
||||
priority: integer("priority").notNull().default(0),
|
||||
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
|
||||
dueAt: timestamp("due_at", { withTimezone: true }),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => ({
|
||||
missionIdx: index("mission_actions_mission_idx").on(t.userId, t.missionInstanceId, t.status, t.priority),
|
||||
userIdx: index("mission_actions_user_idx").on(t.userId, t.status, t.updatedAt),
|
||||
sourceIdx: index("mission_actions_source_idx").on(t.sourceEventId),
|
||||
dueIdx: index("mission_actions_due_idx").on(t.dueAt),
|
||||
idempotencyIdx: uniqueIndex("mission_actions_idempotency_idx").on(t.idempotencyKey),
|
||||
}),
|
||||
);
|
||||
|
||||
export const missionSuggestions = pgTable(
|
||||
"mission_suggestions",
|
||||
{
|
||||
@@ -544,6 +591,8 @@ export const growHomeNotifications = pgTable(
|
||||
|
||||
export type GrowEventRow = typeof growEvents.$inferSelect;
|
||||
export type NewGrowEvent = typeof growEvents.$inferInsert;
|
||||
export type MissionActionRow = typeof missionActions.$inferSelect;
|
||||
export type NewMissionAction = typeof missionActions.$inferInsert;
|
||||
export type MissionSuggestionRow = typeof missionSuggestions.$inferSelect;
|
||||
export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert;
|
||||
export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect;
|
||||
|
||||
@@ -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;
|
||||
|
||||
201
src/events/onboarding-ledger.ts
Normal file
201
src/events/onboarding-ledger.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents, type GrowEventRow } from "../db/schema.js";
|
||||
import { asRecord } from "./envelope.js";
|
||||
import {
|
||||
markGrowEventFailed,
|
||||
markGrowEventProcessed,
|
||||
markGrowEventProcessing,
|
||||
recordGrowEvent,
|
||||
} from "./record-grow-event.js";
|
||||
import { ensureOnboardingBaselineQscoreForCompletedAt } from "./onboarding-qscore.js";
|
||||
import {
|
||||
onboardingCompletedAtFromEvent,
|
||||
runCuratorOnboardingLoopSafely,
|
||||
} from "../v1/curator/curator-onboarding-loop.js";
|
||||
import { ensureOnboardingActiveMissions } from "../missions/lifecycle.js";
|
||||
|
||||
export const ONBOARDING_LEDGER_EVENT_TYPES = [
|
||||
"onboarding.snapshot.saved",
|
||||
"onboarding.completed",
|
||||
"user.onboarding.completed",
|
||||
"profile.onboarding.completed",
|
||||
] as const;
|
||||
|
||||
export type OnboardingLedgerEventType = (typeof ONBOARDING_LEDGER_EVENT_TYPES)[number];
|
||||
|
||||
export const ONBOARDING_LEDGER_QUERY_TYPES = [
|
||||
...ONBOARDING_LEDGER_EVENT_TYPES,
|
||||
"onboarding_snapshot_saved",
|
||||
"onboarding_completed",
|
||||
"user_onboarding_completed",
|
||||
"profile_onboarding_completed",
|
||||
] as const;
|
||||
|
||||
export type OnboardingStatusEvent = {
|
||||
id: string;
|
||||
type: string;
|
||||
occurredAt: Date;
|
||||
processingStatus: string;
|
||||
};
|
||||
|
||||
const COMPLETION_EVENT_TYPES = new Set<string>([
|
||||
"onboarding.completed",
|
||||
"user.onboarding.completed",
|
||||
"profile.onboarding.completed",
|
||||
]);
|
||||
|
||||
export function normalizeOnboardingEventType(type: string) {
|
||||
return type.toLowerCase().replaceAll("_", ".");
|
||||
}
|
||||
|
||||
export function completedAtFromOnboardingPayload(payload: Record<string, unknown> | null | undefined) {
|
||||
const data = payload ?? {};
|
||||
const preferences = asRecord(data.preferences);
|
||||
const onboarding = asRecord(data.onboarding ?? preferences.onboarding);
|
||||
const candidate =
|
||||
data.completedAt ??
|
||||
data.completed_at ??
|
||||
data.onboardingCompletedAt ??
|
||||
data.onboarding_completed_at ??
|
||||
onboarding.completed_at ??
|
||||
onboarding.completedAt ??
|
||||
asRecord(preferences.onboarding).completed_at ??
|
||||
asRecord(preferences.onboarding).completedAt;
|
||||
|
||||
if (candidate instanceof Date) {
|
||||
return Number.isNaN(candidate.getTime()) ? undefined : candidate.toISOString();
|
||||
}
|
||||
if (typeof candidate !== "string" || !candidate.trim()) return undefined;
|
||||
const parsed = new Date(candidate);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
||||
}
|
||||
|
||||
export function isValidOnboardingLedgerEvent(event: Pick<GrowEventRow, "type" | "payload">) {
|
||||
const normalizedType = normalizeOnboardingEventType(event.type);
|
||||
if (COMPLETION_EVENT_TYPES.has(normalizedType)) return true;
|
||||
|
||||
// Snapshots are status-valid only when they are completion snapshots. Plain
|
||||
// intermediate step saves must not let a new seeker bypass onboarding.
|
||||
if (normalizedType === "onboarding.snapshot.saved") {
|
||||
return Boolean(completedAtFromOnboardingPayload(event.payload));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function getLatestValidOnboardingLedgerEvent(userId: string): Promise<OnboardingStatusEvent | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: growEvents.id,
|
||||
type: growEvents.type,
|
||||
payload: growEvents.payload,
|
||||
occurredAt: growEvents.occurredAt,
|
||||
processingStatus: growEvents.processingStatus,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type, [...ONBOARDING_LEDGER_QUERY_TYPES]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(25);
|
||||
|
||||
const row = rows.find((event) => isValidOnboardingLedgerEvent(event));
|
||||
if (!row) return null;
|
||||
const { payload: _payload, ...statusEvent } = row;
|
||||
return statusEvent;
|
||||
}
|
||||
|
||||
export async function ensureOnboardingBaselineQscoreFromLedger(userId: string) {
|
||||
const event = await getLatestValidOnboardingLedgerEvent(userId);
|
||||
if (!event) return false;
|
||||
return ensureOnboardingBaselineQscoreForCompletedAt(userId, event.occurredAt);
|
||||
}
|
||||
|
||||
function onboardingContextFromInput(context?: Record<string, unknown>) {
|
||||
const input = context ?? {};
|
||||
const preferences = asRecord(input.preferences);
|
||||
const onboarding = asRecord(input.onboarding ?? preferences.onboarding);
|
||||
return {
|
||||
...input,
|
||||
onboarding,
|
||||
preferences: Object.keys(preferences).length ? preferences : { onboarding },
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
|
||||
if (!event.userId || !isValidOnboardingLedgerEvent(event)) {
|
||||
return {
|
||||
qscoreBaselineSeeded: false,
|
||||
curatorOnboarding: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id" },
|
||||
missions: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id", started: [], existing: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const completedAt =
|
||||
onboardingCompletedAtFromEvent(event) ??
|
||||
completedAtFromOnboardingPayload(event.payload) ??
|
||||
event.occurredAt.toISOString();
|
||||
|
||||
const qscoreBaselineSeeded = await ensureOnboardingBaselineQscoreForCompletedAt(event.userId, completedAt);
|
||||
const curatorOnboarding = await runCuratorOnboardingLoopSafely({
|
||||
userId: event.userId,
|
||||
completedAt,
|
||||
sourceEventId: event.id,
|
||||
source: event.source,
|
||||
context: onboardingContextFromInput(event.payload),
|
||||
});
|
||||
const missions = await ensureOnboardingActiveMissions({
|
||||
userId: event.userId,
|
||||
completedAt,
|
||||
sourceEventId: event.id,
|
||||
source: event.source,
|
||||
context: onboardingContextFromInput(event.payload),
|
||||
});
|
||||
|
||||
return { qscoreBaselineSeeded, curatorOnboarding, missions };
|
||||
}
|
||||
|
||||
export async function recordAndProcessOnboardingCompletion(input: {
|
||||
userId: string;
|
||||
completedAt: string | Date;
|
||||
source?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}) {
|
||||
const completedAt =
|
||||
input.completedAt instanceof Date
|
||||
? input.completedAt.toISOString()
|
||||
: input.completedAt;
|
||||
const context = onboardingContextFromInput(input.context);
|
||||
const event = await recordGrowEvent(
|
||||
{
|
||||
source: input.source ?? "onboarding",
|
||||
type: "onboarding.completed",
|
||||
category: "usage",
|
||||
userId: input.userId,
|
||||
occurredAt: completedAt,
|
||||
payload: {
|
||||
completedAt,
|
||||
...context,
|
||||
},
|
||||
dedupeKey: `onboarding:completed:${input.userId}`,
|
||||
},
|
||||
{ userId: input.userId, source: input.source ?? "onboarding" },
|
||||
);
|
||||
|
||||
if (event.processingStatus !== "processed") {
|
||||
await markGrowEventProcessing(event.id);
|
||||
}
|
||||
|
||||
const sideEffects = await ensureOnboardingSideEffectsForEvent(event);
|
||||
if (sideEffects.curatorOnboarding.status === "skipped" && sideEffects.curatorOnboarding.reason === "loop_failed") {
|
||||
await markGrowEventFailed(event.id, new Error("curator_onboarding_loop_failed"));
|
||||
} else if (event.processingStatus !== "processed") {
|
||||
await markGrowEventProcessed(event.id);
|
||||
}
|
||||
|
||||
return { event, ...sideEffects };
|
||||
}
|
||||
166
src/events/onboarding-qscore.ts
Normal file
166
src/events/onboarding-qscore.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growQscoreLatest, growQscoreProjectionState, growQscoreSignals } from "../db/schema.js";
|
||||
|
||||
export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed_baseline";
|
||||
export const ONBOARDING_BASELINE_QSCORE = 35;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function parseCompletedAt(value: unknown): Date | null {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}
|
||||
|
||||
function onboardingCompletedAt(preferences: Record<string, unknown> | undefined): Date | null {
|
||||
const onboarding = asRecord(preferences?.onboarding);
|
||||
return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the first real Q Score projection when onboarding is completed.
|
||||
*
|
||||
* The onboarding UI tells users their QX baseline starts at 35. Previously that
|
||||
* number was only cosmetic, while the header showed a separate home-feed
|
||||
* fallback and the Q Score page stayed empty. This makes the onboarding
|
||||
* baseline a persisted readiness signal, but only when the user has no Q Score
|
||||
* signals/projection yet so we do not overwrite mature accounts.
|
||||
*/
|
||||
export async function ensureOnboardingBaselineQscore(
|
||||
userId: string,
|
||||
preferences: Record<string, unknown> | undefined,
|
||||
): Promise<boolean> {
|
||||
const completedAt = onboardingCompletedAt(preferences);
|
||||
if (!completedAt) return false;
|
||||
return ensureOnboardingBaselineQscoreForCompletedAt(userId, completedAt);
|
||||
}
|
||||
|
||||
export async function ensureOnboardingBaselineQscoreForCompletedAt(
|
||||
userId: string,
|
||||
completedAtInput: string | Date,
|
||||
): Promise<boolean> {
|
||||
const completedAt = parseCompletedAt(completedAtInput);
|
||||
if (!completedAt) return false;
|
||||
const latestSignals = await db
|
||||
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score })
|
||||
.from(growQscoreLatest)
|
||||
.where(and(eq(growQscoreLatest.userId, userId), eq(growQscoreLatest.present, true)));
|
||||
|
||||
const [existingProjection] = await db
|
||||
.select({ score: growQscoreProjectionState.score, signalCount: growQscoreProjectionState.signalCount })
|
||||
.from(growQscoreProjectionState)
|
||||
.where(eq(growQscoreProjectionState.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Repair users affected by the old resume-upload projector, which treated a
|
||||
// plain upload as a perfect 100 score. Uploading a resume during onboarding is
|
||||
// only baseline evidence; parsed resume/interview/roleplay results should be
|
||||
// what moves the score upward.
|
||||
if (
|
||||
latestSignals.length === 1 &&
|
||||
latestSignals[0]?.signalId === "resume.uploaded" &&
|
||||
latestSignals[0].score > ONBOARDING_BASELINE_QSCORE
|
||||
) {
|
||||
await db
|
||||
.update(growQscoreLatest)
|
||||
.set({
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
raw: {
|
||||
reason: "resume upload baseline correction",
|
||||
correctedFrom: latestSignals[0].score,
|
||||
correctedAt: now.toISOString(),
|
||||
},
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(growQscoreLatest.userId, userId), eq(growQscoreLatest.signalId, "resume.uploaded")));
|
||||
|
||||
await db
|
||||
.insert(growQscoreProjectionState)
|
||||
.values({
|
||||
userId,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: ["resume.uploaded"], corrected: true },
|
||||
summary: "Baseline Q Score from onboarding resume upload.",
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: growQscoreProjectionState.userId,
|
||||
set: {
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: ["resume.uploaded"], corrected: true },
|
||||
summary: "Baseline Q Score from onboarding resume upload.",
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (latestSignals.length > 0 || (existingProjection?.score ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
const raw = {
|
||||
reason: "completed onboarding baseline",
|
||||
completedAt: completedAt.toISOString(),
|
||||
};
|
||||
|
||||
const inserted = await db
|
||||
.insert(growQscoreLatest)
|
||||
.values({
|
||||
userId,
|
||||
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
present: true,
|
||||
source: "onboarding",
|
||||
raw,
|
||||
occurredAt: completedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ signalId: growQscoreLatest.signalId });
|
||||
|
||||
if (!inserted.length) return false;
|
||||
|
||||
await db.insert(growQscoreSignals).values({
|
||||
userId,
|
||||
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
present: true,
|
||||
source: "onboarding",
|
||||
raw,
|
||||
occurredAt: completedAt,
|
||||
});
|
||||
|
||||
await db
|
||||
.insert(growQscoreProjectionState)
|
||||
.values({
|
||||
userId,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: [ONBOARDING_BASELINE_SIGNAL_ID] },
|
||||
summary: "Baseline Q Score from completed onboarding.",
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: growQscoreProjectionState.userId,
|
||||
set: {
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: [ONBOARDING_BASELINE_SIGNAL_ID] },
|
||||
summary: "Baseline Q Score from completed onboarding.",
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ function nestedNumber(record: Record<string, unknown>, keys: string[]): number |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const RESUME_UPLOAD_BASELINE_SCORE = 35;
|
||||
|
||||
function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const analysis = asRecord(payload.analysis ?? payload.result ?? payload);
|
||||
@@ -38,8 +40,11 @@ function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
}
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
if (event.type.includes("uploaded") || event.type.includes("created") || event.type.includes("analysis")) {
|
||||
signals.push(signal("resume.uploaded", 100, { eventId: event.id }));
|
||||
if (event.type.includes("uploaded") || event.type.includes("created")) {
|
||||
// Uploading a resume is only a baseline readiness signal. The actual Q Score
|
||||
// should rise from parsed resume/interview/roleplay evidence, not jump to 100
|
||||
// immediately after onboarding.
|
||||
signals.push(signal("resume.uploaded", RESUME_UPLOAD_BASELINE_SCORE, { eventId: event.id }));
|
||||
}
|
||||
const ats = byCategory.get("ATS Compatibility") ?? nestedNumber(analysis, ["ats_score", "ats_compatibility", "atsCompatibility"]);
|
||||
if (ats !== undefined) signals.push(signal("resume.ats_compatibility", ats, { eventId: event.id }));
|
||||
@@ -108,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";
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { config } from "../config.js";
|
||||
import { log } from "../log.js";
|
||||
import { recordGrowEvent } from "./record-grow-event.js";
|
||||
import {
|
||||
markGrowEventFailed,
|
||||
markGrowEventProcessed,
|
||||
markGrowEventProcessing,
|
||||
recordGrowEvent,
|
||||
} from "./record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "./route-to-user-actor.js";
|
||||
import { applyQscoreProjection } from "./projectors/qscore-projector.js";
|
||||
import { ensureOnboardingSideEffectsForEvent } from "./onboarding-ledger.js";
|
||||
|
||||
// This file has two Redis ingestion modes:
|
||||
// 1. Canonical GrowEvent stream: grow.events.raw — future service event bus.
|
||||
@@ -100,26 +107,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("_", ".")}`;
|
||||
@@ -150,6 +157,23 @@ async function recordAndRoute(input: unknown) {
|
||||
await routeGrowEventToUserActor(event).catch((err) => {
|
||||
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
|
||||
});
|
||||
if (!event.userId || event.processingStatus === "processed") return event;
|
||||
|
||||
await markGrowEventProcessing(event.id);
|
||||
try {
|
||||
await applyQscoreProjection(event);
|
||||
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
|
||||
if (
|
||||
onboarding.curatorOnboarding.status === "skipped" &&
|
||||
onboarding.curatorOnboarding.reason === "loop_failed"
|
||||
) {
|
||||
throw new Error("curator_onboarding_loop_failed");
|
||||
}
|
||||
await markGrowEventProcessed(event.id);
|
||||
} catch (err) {
|
||||
await markGrowEventFailed(event.id, err);
|
||||
throw err;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { asc, desc, eq, and } from "drizzle-orm";
|
||||
import { asc, desc, eq, and, sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growActiveMissions, growConversationMessages, growConversations, missionCoachRuns, missionSuggestions } from "../db/schema.js";
|
||||
import type { GrowActiveMission, MissionSnapshot } from "../actors/missions/types.js";
|
||||
@@ -42,6 +42,118 @@ export async function createConversationPg(userId: string, title = "Talk to Me")
|
||||
return toConversation(row);
|
||||
}
|
||||
|
||||
export async function ensureCuratorTaskConversationPg(input: {
|
||||
userId: string;
|
||||
curatorTaskId: string;
|
||||
subtaskIndex?: number;
|
||||
subtask?: string;
|
||||
missionInstanceId?: string;
|
||||
missionId?: string;
|
||||
stageId?: string;
|
||||
title?: string;
|
||||
}): Promise<GrowConversation> {
|
||||
const curatorTaskKey = `${input.curatorTaskId}:${input.subtaskIndex ?? "task"}`;
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(growConversations)
|
||||
.where(and(
|
||||
eq(growConversations.userId, input.userId),
|
||||
sql`${growConversations.metadata}->>'curatorTaskKey' = ${curatorTaskKey}`,
|
||||
))
|
||||
.orderBy(desc(growConversations.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
const metadata = {
|
||||
source: "curator-v1",
|
||||
curatorTaskId: input.curatorTaskId,
|
||||
curatorTaskKey,
|
||||
...(Number.isInteger(input.subtaskIndex) ? { subtaskIndex: input.subtaskIndex } : {}),
|
||||
...(input.subtask ? { subtask: input.subtask } : {}),
|
||||
...(input.missionInstanceId ? { missionInstanceId: input.missionInstanceId } : {}),
|
||||
...(input.missionId ? { missionId: input.missionId } : {}),
|
||||
...(input.stageId ? { stageId: input.stageId } : {}),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
const [row] = await db.update(growConversations).set({
|
||||
title: input.title?.trim() || existing.title,
|
||||
metadata,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(eq(growConversations.userId, input.userId), eq(growConversations.id, existing.id))).returning();
|
||||
if (!row) throw new Error("Failed to update curator task conversation");
|
||||
return toConversation(row);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [row] = await db.insert(growConversations).values({
|
||||
id: buildId("conversation"),
|
||||
userId: input.userId,
|
||||
title: input.title?.trim() || input.subtask?.trim() || "V1 Curator chat",
|
||||
metadata,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
if (!row) throw new Error("Failed to create curator task conversation");
|
||||
return toConversation(row);
|
||||
}
|
||||
|
||||
export async function ensureMissionConversationPg(input: {
|
||||
userId: string;
|
||||
missionInstanceId: string;
|
||||
missionId: string;
|
||||
stageId?: string;
|
||||
title?: string;
|
||||
source?: string;
|
||||
}): Promise<GrowConversation> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(growConversations)
|
||||
.where(and(
|
||||
eq(growConversations.userId, input.userId),
|
||||
sql`${growConversations.metadata}->>'missionInstanceId' = ${input.missionInstanceId}`,
|
||||
))
|
||||
.orderBy(desc(growConversations.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
const metadata = {
|
||||
missionInstanceId: input.missionInstanceId,
|
||||
missionId: input.missionId,
|
||||
...(input.stageId ? { stageId: input.stageId } : {}),
|
||||
source: input.source ?? "mission",
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
const [row] = await db.update(growConversations).set({
|
||||
title: input.title?.trim() || existing.title,
|
||||
metadata,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(eq(growConversations.userId, input.userId), eq(growConversations.id, existing.id))).returning();
|
||||
if (!row) throw new Error("Failed to update mission conversation");
|
||||
return toConversation(row);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [row] = await db.insert(growConversations).values({
|
||||
id: buildId("conversation"),
|
||||
userId: input.userId,
|
||||
title: input.title?.trim() || "Mission chat",
|
||||
metadata,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
if (!row) throw new Error("Failed to create mission conversation");
|
||||
return toConversation(row);
|
||||
}
|
||||
|
||||
export async function getConversationMetadataPg(userId: string, conversationId: string) {
|
||||
const [row] = await db
|
||||
.select({ metadata: growConversations.metadata })
|
||||
.from(growConversations)
|
||||
.where(and(eq(growConversations.userId, userId), eq(growConversations.id, conversationId)))
|
||||
.limit(1);
|
||||
return row?.metadata ?? {};
|
||||
}
|
||||
|
||||
export async function getConversationPg(userId: string, conversationId: string): Promise<GrowConversation | null> {
|
||||
const [row] = await db.select().from(growConversations).where(and(eq(growConversations.userId, userId), eq(growConversations.id, conversationId))).limit(1);
|
||||
return row ? toConversation(row) : null;
|
||||
@@ -162,6 +274,20 @@ export async function listActiveMissionsPg(userId: string) {
|
||||
return rows.map((row) => ({ mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) }));
|
||||
}
|
||||
|
||||
export async function listActiveMissionsForPassiveReviewPg(opts: { userId?: string; limit?: number } = {}) {
|
||||
const conditions = [eq(growActiveMissions.status, "active")];
|
||||
if (opts.userId) conditions.push(eq(growActiveMissions.userId, opts.userId));
|
||||
const query = db
|
||||
.select()
|
||||
.from(growActiveMissions)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(growActiveMissions.updatedAt));
|
||||
const rows = typeof opts.limit === "number" && opts.limit > 0
|
||||
? await query.limit(opts.limit)
|
||||
: await query;
|
||||
return rows.map((row) => ({ userId: row.userId, mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) }));
|
||||
}
|
||||
|
||||
export async function getActiveMissionPg(userId: string, instanceId: string) {
|
||||
const [row] = await db.select().from(growActiveMissions).where(and(eq(growActiveMissions.userId, userId), eq(growActiveMissions.instanceId, instanceId))).limit(1);
|
||||
return row ? { mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) } : null;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Output, generateText } from "ai";
|
||||
import { generateText } from "ai";
|
||||
import { z } from "zod";
|
||||
import { getConversationModel } from "../actors/conversation/agent.js";
|
||||
import { config } from "../config.js";
|
||||
import { log } from "../log.js";
|
||||
import { ALLOWED_NOTIFICATION_HREFS, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.js";
|
||||
import { isAllowedNotificationHref, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.js";
|
||||
|
||||
const notificationSchema = z.object({
|
||||
moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]),
|
||||
@@ -16,12 +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 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.
|
||||
@@ -31,61 +52,179 @@ 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 (ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
|
||||
if (href.startsWith("/agents/resume")) return "/agents/resume";
|
||||
if (href.startsWith("/agents/interview")) return "/agents/interview";
|
||||
if (href.startsWith("/agents/roleplay")) return "/agents/roleplay";
|
||||
if (href.startsWith("/agents/qscore")) return "/agents/qscore";
|
||||
if (href.startsWith("/missions")) return "/missions";
|
||||
if (href.startsWith("/social")) return "/social";
|
||||
if (href.startsWith("/pathways")) return "/pathways";
|
||||
if (isAllowedNotificationHref(href)) return href;
|
||||
if (href.startsWith("/missions")) return "/missions/active";
|
||||
if (href.startsWith("/agents/social-branding")) return "/agents/social-branding";
|
||||
if (href.startsWith("/agents/matchmaking")) return "/agents/matchmaking";
|
||||
if (href.startsWith("/rewards")) return "/rewards";
|
||||
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 {
|
||||
return JSON.parse(cleaned);
|
||||
} catch {
|
||||
const start = cleaned.indexOf("{");
|
||||
const end = cleaned.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end <= start) throw new Error("home_feed_agent_invalid_json");
|
||||
return JSON.parse(cleaned.slice(start, end + 1));
|
||||
}
|
||||
}
|
||||
|
||||
export async function refineHomeNotificationsWithAgent(input: {
|
||||
userId: string;
|
||||
context: Record<string, unknown>;
|
||||
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>;
|
||||
}): Promise<Array<AgentHomeNotification & { id: string; createdAt: string }>> {
|
||||
if (!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(),
|
||||
output: Output.object({ schema: feedSchema }),
|
||||
system: SYSTEM,
|
||||
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 now = new Date().toISOString();
|
||||
return result.output.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,
|
||||
@@ -8,15 +9,20 @@ import {
|
||||
growQscoreProjectionState,
|
||||
missionArtifacts,
|
||||
missionServiceSessions,
|
||||
missionSuggestions,
|
||||
qscoreSnapshots,
|
||||
users,
|
||||
type GrowHomeNotificationRow,
|
||||
type NewGrowHomeNotification,
|
||||
} from "../db/schema.js";
|
||||
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
|
||||
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
|
||||
import { buildServiceLink } from "../services/service-registry.js";
|
||||
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
|
||||
import { HomeFeedAgentError, refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
|
||||
import { listAvailableMissionDefinitions } from "../missions/registry.js";
|
||||
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
|
||||
import {
|
||||
ALLOWED_NOTIFICATION_HREFS,
|
||||
isAllowedNotificationHref,
|
||||
MODULE_IDS,
|
||||
MODULE_META,
|
||||
type HomeFeedResponse,
|
||||
@@ -31,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",
|
||||
mission: "/missions",
|
||||
social: "/social",
|
||||
pathways: "/pathways",
|
||||
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: 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 };
|
||||
@@ -50,10 +56,13 @@ type HomeContext = {
|
||||
qscore: { score: number; signalCount: number; summary: string | null; dimensions: Record<string, unknown> | null } | undefined;
|
||||
qscoreSignals: Array<{ signalId: string; score: number; source: string | null; updatedAt: Date }>;
|
||||
activeMissions: Array<{ instanceId: string; missionId: string; title: string; status: string; progressPercent: number; currentStageId: string | null; updatedAt: Date }>;
|
||||
missionSuggestions: Array<{ id: string; missionInstanceId: string; missionId: string; stageId: string | null; role: string; type: string; title: string; body: string; reason: string | null; priority: number; urgency: string; ctaLabel: string; ctaHref: string; updatedAt: Date }>;
|
||||
sessions: Array<{ serviceId: string; externalId: string; status: string; updatedAt: Date; metadata: Record<string, unknown> | null }>;
|
||||
artifacts: Array<{ serviceId: string | null; type: string; title: string; status: string; summary: string | null; createdAt: Date }>;
|
||||
events: Array<{ source: string; type: string; occurredAt: Date; payload: Record<string, unknown> }>;
|
||||
serviceStates: Record<string, unknown>;
|
||||
userProfile?: Record<string, unknown>;
|
||||
preferences: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -64,13 +73,70 @@ function numberFrom(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function arrayOfStrings(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
|
||||
}
|
||||
|
||||
function recordOf(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function profileFromPreferences(preferences: Record<string, unknown>) {
|
||||
const onboarding = recordOf(preferences.onboarding);
|
||||
const interview = recordOf(preferences.interview_preferences);
|
||||
const resume = recordOf(preferences.resume_preferences);
|
||||
const mission = recordOf(preferences.mission_preferences);
|
||||
const targetRoles = arrayOfStrings(preferences.target_roles);
|
||||
const targetCompanies = arrayOfStrings(preferences.target_companies);
|
||||
const focusAreas = arrayOfStrings(interview.focus_areas);
|
||||
return {
|
||||
targetRole: targetRoles[0] ?? (typeof resume.target_title === "string" ? resume.target_title : "Senior Data Scientist"),
|
||||
targetCompany: targetCompanies[0] ?? "target company",
|
||||
industry: typeof preferences.industry === "string" ? preferences.industry : "AI / SaaS",
|
||||
focusAreas,
|
||||
weakSpots: arrayOfStrings(interview.weak_spots),
|
||||
jobDescription: typeof interview.job_description === "string" ? interview.job_description : undefined,
|
||||
activeGoal: typeof mission.active_goal === "string" ? mission.active_goal : typeof onboarding.goal === "string" ? onboarding.goal : undefined,
|
||||
onboardingComplete: Boolean(onboarding.completed_at),
|
||||
};
|
||||
}
|
||||
|
||||
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
|
||||
const profile = profileFromPreferences(ctx.preferences);
|
||||
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 {
|
||||
const value = role.toLowerCase();
|
||||
if (value.includes("resume")) return "resume";
|
||||
if (value.includes("roleplay")) return "roleplay";
|
||||
if (value.includes("interview")) return "interview";
|
||||
if (value.includes("q")) return "qscore";
|
||||
return "mission";
|
||||
}
|
||||
|
||||
function sanitizeUrgency(value: string): HomeUrgency {
|
||||
if (value === "now" || value === "today" || value === "soon" || value === "calm") return value;
|
||||
return "calm";
|
||||
}
|
||||
|
||||
function sanitizeHref(href: string | undefined, fallback: string) {
|
||||
if (href && ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
|
||||
if (href && isAllowedNotificationHref(href)) return href;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -97,28 +163,65 @@ function hasAnyRealActivity(ctx: HomeContext) {
|
||||
ctx.activeMissions.length ||
|
||||
ctx.sessions.length ||
|
||||
ctx.artifacts.length ||
|
||||
ctx.events.length,
|
||||
ctx.events.length ||
|
||||
ctx.missionSuggestions.length ||
|
||||
profileFromPreferences(ctx.preferences).onboardingComplete,
|
||||
);
|
||||
}
|
||||
|
||||
function buildDayOneSeeds(): SeedNotification[] {
|
||||
const seeds: SeedNotification[] = [];
|
||||
pushSeed(seeds, { moduleId: "suggestions", title: "Start with your Q Score", subtitle: "A quick readiness scan calibrates resume, interview, and roleplay tips.", tag: "Start", urgency: "now", href: SERVICE_HREFS.qscore, source: "qscore", priority: 90 });
|
||||
pushSeed(seeds, { moduleId: "suggestions", title: "Add your target role", subtitle: "One role goal makes every recommendation sharper.", tag: "Profile", urgency: "today", href: SERVICE_HREFS.suggestions, source: "system", priority: 80 });
|
||||
pushSeed(seeds, { moduleId: "missions", title: "Explore Interview-to-Offer", subtitle: "A guided mission connects resume fit, mock practice, and readiness scoring.", tag: "Browse", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
|
||||
pushSeed(seeds, { moduleId: "missions", title: "No approvals pending yet", subtitle: "Start a mission and this tile will track missing steps and progress.", tag: "Quiet", urgency: "calm", href: SERVICE_HREFS.mission, source: "mission", priority: 55 });
|
||||
pushSeed(seeds, { moduleId: "social", title: "Connect LinkedIn when ready", subtitle: "Social branding recommendations unlock after your profile is available.", tag: "Setup", urgency: "soon", href: SERVICE_HREFS.social, source: "social", priority: 60 });
|
||||
pushSeed(seeds, { moduleId: "social", title: "Build proof before posting", subtitle: "Resume and mock interview artifacts can become stronger featured pins.", tag: "Proof", urgency: "calm", href: SERVICE_HREFS.social, source: "social", priority: 50 });
|
||||
pushSeed(seeds, { moduleId: "pathways", title: "Pathways are warming up", subtitle: "Complete resume + interview activity to unlock better route recommendations.", tag: "Soon", urgency: "calm", href: SERVICE_HREFS.pathways, source: "pathways", priority: 40 });
|
||||
pushSeed(seeds, { moduleId: "productivity", title: "Open Resume Builder", subtitle: "Upload or create a resume to generate ATS and content recommendations.", tag: "Resume", urgency: "now", href: SERVICE_HREFS.resume, source: "resume", priority: 85 });
|
||||
pushSeed(seeds, { moduleId: "productivity", title: "Try a 10-minute mock interview", subtitle: "The interview service creates a role-aware live practice session.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 70 });
|
||||
pushSeed(seeds, { moduleId: "productivity", title: "Roleplay is available for pressure practice", subtitle: "Use it for recruiter screens, salary asks, or manager conversations.", tag: "Roleplay", urgency: "calm", href: SERVICE_HREFS.roleplay, source: "roleplay", priority: 55 });
|
||||
pushSeed(seeds, { moduleId: "rewards", title: "Rewards unlock after activity", subtitle: "Finish readiness actions to start earning demo streaks and perks.", tag: "Locked", urgency: "calm", href: SERVICE_HREFS.rewards, source: "rewards", priority: 35 });
|
||||
|
||||
const missions = listAvailableMissionDefinitions();
|
||||
for (const [index, mission] of missions.slice(0, 3).entries()) {
|
||||
const firstServiceModule = mission.modules.find((module) => module.execution === "service" && module.service);
|
||||
pushSeed(seeds, {
|
||||
moduleId: "missions",
|
||||
title: mission.shortTitle || mission.title,
|
||||
subtitle: mission.promise,
|
||||
tag: mission.urgency === "high" ? "High" : mission.estimatedDuration,
|
||||
urgency: mission.urgency === "high" ? "today" : "soon",
|
||||
href: `/missions/available?missionId=${encodeURIComponent(mission.missionId)}`,
|
||||
source: "mission",
|
||||
reason: firstServiceModule ? `Registry workflow using ${firstServiceModule.role}.` : "Registry workflow.",
|
||||
priority: 92 - index * 4,
|
||||
});
|
||||
}
|
||||
|
||||
const services = listServiceCapabilities().filter((service) => service.enabled);
|
||||
const serviceCards = [
|
||||
{ id: "resume-service", moduleId: "productivity" as const, href: SERVICE_HREFS.resume, source: "resume" as const, urgency: "today" as const },
|
||||
{ id: "interview-service", moduleId: "productivity" as const, href: SERVICE_HREFS.interview, source: "interview" as const, urgency: "today" as const },
|
||||
{ id: "roleplay-service", moduleId: "productivity" as const, href: SERVICE_HREFS.roleplay, source: "roleplay" as const, urgency: "soon" as const },
|
||||
{ id: "qscore-service", moduleId: "suggestions" as const, href: SERVICE_HREFS.qscore, source: "qscore" as const, urgency: "now" as const },
|
||||
{ id: "social-branding-service", moduleId: "social" as const, href: SERVICE_HREFS.social, source: "social" as const, urgency: "soon" as const },
|
||||
{ id: "matchmaking-service", moduleId: "pathways" as const, href: SERVICE_HREFS.pathways, source: "pathways" as const, urgency: "calm" as const },
|
||||
];
|
||||
|
||||
for (const [index, card] of serviceCards.entries()) {
|
||||
const service = services.find((item) => item.id === card.id);
|
||||
if (!service) continue;
|
||||
pushSeed(seeds, {
|
||||
moduleId: card.moduleId,
|
||||
title: service.name,
|
||||
subtitle: service.operations.slice(0, 3).join(", ") || "Registered GrowQR service capability.",
|
||||
tag: service.featureId?.replaceAll("-", " ").slice(0, 14) || "Service",
|
||||
urgency: card.urgency,
|
||||
href: card.href,
|
||||
source: card.source,
|
||||
reason: `From service registry: ${service.promptModulePath ?? service.id}.`,
|
||||
priority: 84 - index * 3,
|
||||
});
|
||||
}
|
||||
|
||||
pushSeed(seeds, { moduleId: "suggestions", title: "Set your target outcome", subtitle: "One role or career goal sharpens every registry mission and service handoff.", tag: "Profile", urgency: "today", href: SERVICE_HREFS.suggestions, source: "system", priority: 80 });
|
||||
pushSeed(seeds, { moduleId: "rewards", title: "Rewards unlock from mission progress", subtitle: "Complete registry-backed mission stages to earn streak and coin progress.", tag: "Locked", urgency: "calm", href: SERVICE_HREFS.rewards, source: "rewards", priority: 35 });
|
||||
return seeds;
|
||||
}
|
||||
|
||||
function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
const seeds = buildDayOneSeeds().filter((seed) => seed.moduleId === "pathways" || seed.moduleId === "rewards");
|
||||
const profile = profileFromPreferences(ctx.preferences);
|
||||
const qscore = ctx.qscore?.score ?? Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1));
|
||||
const ats = latestScore(ctx.qscoreSignals, "resume.ats_compatibility");
|
||||
const interviewOverall = latestScore(ctx.qscoreSignals, "interview.overall_score");
|
||||
@@ -130,14 +233,55 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
const roleplayReview = serviceEvent(ctx, "roleplay.", "review");
|
||||
const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis");
|
||||
|
||||
for (const suggestion of ctx.missionSuggestions.slice(0, 5)) {
|
||||
const mission = ctx.activeMissions.find((item) => item.instanceId === suggestion.missionInstanceId);
|
||||
const source = sourceFromSuggestionRole(suggestion.role);
|
||||
const href = sanitizeHref(suggestion.ctaHref, mission ? `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}` : SERVICE_HREFS.mission);
|
||||
pushSeed(seeds, {
|
||||
moduleId: "suggestions",
|
||||
title: suggestion.title,
|
||||
subtitle: suggestion.body,
|
||||
tag: suggestion.ctaLabel.replace(/\s+/g, " ").slice(0, 14),
|
||||
urgency: sanitizeUrgency(suggestion.urgency),
|
||||
href,
|
||||
source,
|
||||
reason: suggestion.reason ?? undefined,
|
||||
priority: Math.max(100, suggestion.priority + 10),
|
||||
});
|
||||
pushSeed(seeds, {
|
||||
moduleId: suggestion.role.toLowerCase().includes("resume") || suggestion.role.toLowerCase().includes("interview") || suggestion.role.toLowerCase().includes("roleplay") ? "productivity" : "missions",
|
||||
title: `${suggestion.role}: ${suggestion.title}`,
|
||||
subtitle: mission ? `${mission.title} · ${suggestion.body}` : suggestion.body,
|
||||
tag: suggestion.urgency === "now" ? "Now" : suggestion.urgency === "today" ? "Today" : "Next",
|
||||
urgency: sanitizeUrgency(suggestion.urgency),
|
||||
href,
|
||||
source,
|
||||
reason: suggestion.reason ?? undefined,
|
||||
priority: suggestion.priority,
|
||||
});
|
||||
}
|
||||
|
||||
if (profile.onboardingComplete) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "suggestions",
|
||||
title: `${profile.targetRole} plan is calibrated`,
|
||||
subtitle: profile.activeGoal ?? `Today's recommendations are tuned for ${profile.targetRole}${profile.targetCompany !== "target company" ? ` at ${profile.targetCompany}` : ""}.`,
|
||||
tag: "Profile",
|
||||
urgency: "today",
|
||||
href: "/suggestions",
|
||||
source: "system",
|
||||
priority: 91,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.qscore || ctx.qscoreSignals.length) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "suggestions",
|
||||
title: qscore >= 80 ? "Protect your Q Score momentum" : "Raise your Q Score next",
|
||||
subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving.` : `Current estimate is ${qscore || 64}. Resume + mock practice are the fastest signals.`,
|
||||
subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving for ${profile.targetRole}.` : `Current estimate is ${qscore || 64}. Resume + mock practice are fastest for ${profile.targetRole}.`,
|
||||
tag: "Q Score",
|
||||
urgency: qscore >= 80 ? "today" : "now",
|
||||
href: SERVICE_HREFS.qscore,
|
||||
href: serviceHref("qscore", ctx),
|
||||
source: "qscore",
|
||||
priority: 95,
|
||||
});
|
||||
@@ -147,10 +291,10 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "suggestions",
|
||||
title: ats >= 80 ? "ATS is demo-ready" : "Resume ATS needs one pass",
|
||||
subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review role-specific keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable bullets.`,
|
||||
subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review ${profile.targetRole} keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable data-science bullets.`,
|
||||
tag: ats >= 80 ? "Ready" : "Fix",
|
||||
urgency: ats >= 80 ? "today" : "now",
|
||||
href: SERVICE_HREFS.resume,
|
||||
href: serviceHref("resume", ctx),
|
||||
source: "resume",
|
||||
priority: 92,
|
||||
});
|
||||
@@ -163,7 +307,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
subtitle: mission.currentStageId ? `Current stage: ${mission.currentStageId.replaceAll("-", " ")}` : "Next action is ready on the mission dashboard.",
|
||||
tag: mission.status === "paused" ? "Paused" : "Active",
|
||||
urgency: mission.status === "paused" ? "soon" : "today",
|
||||
href: SERVICE_HREFS.mission,
|
||||
href: `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}`,
|
||||
source: "mission",
|
||||
priority: 90 - mission.progressPercent,
|
||||
});
|
||||
@@ -186,7 +330,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "social",
|
||||
title: "Turn proof into LinkedIn updates",
|
||||
subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : "Connect LinkedIn and use mission proof to improve your profile.",
|
||||
subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : `Connect LinkedIn and use ${profile.targetRole} proof to improve your profile.`,
|
||||
tag: ctx.artifacts.length ? "Proof" : "Setup",
|
||||
urgency: ctx.artifacts.length ? "today" : "soon",
|
||||
href: SERVICE_HREFS.social,
|
||||
@@ -198,51 +342,52 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "productivity",
|
||||
title: ats !== undefined ? `Resume ATS ${Math.round(ats)}` : "Resume analysis is ready",
|
||||
subtitle: ats !== undefined && ats >= 80 ? "Use this version for role-fit scan or final polish." : "Open Resume Builder for recommendations and bullet fixes.",
|
||||
subtitle: ats !== undefined && ats >= 80 ? `Use this version for ${profile.targetRole} role-fit scan or final polish.` : "Open Resume Builder for recommendations and bullet fixes.",
|
||||
tag: "Resume",
|
||||
urgency: ats !== undefined && ats < 75 ? "now" : "today",
|
||||
href: SERVICE_HREFS.resume,
|
||||
href: serviceHref("resume", ctx),
|
||||
source: "resume",
|
||||
priority: 90,
|
||||
});
|
||||
}
|
||||
|
||||
const firstMission = ctx.activeMissions[0];
|
||||
if (interviewReview || interviewOverall !== undefined || interviewSession) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "productivity",
|
||||
title: interviewOverall !== undefined ? `Mock interview score ${Math.round(interviewOverall)}` : "Mock interview review is tracking",
|
||||
subtitle: interviewReview?.type.includes("processing") ? "Review is still processing; check back from the interview page." : "Open interview practice for review, next drill, or a new session.",
|
||||
subtitle: interviewReview?.type.includes("processing") ? "Review is still processing; check back from the interview page." : `Open ${profile.targetRole} interview practice for review, next drill, or a new session.`,
|
||||
tag: interviewReview?.type.includes("processing") ? "Wait" : "Mock",
|
||||
urgency: interviewReview?.type.includes("processing") ? "soon" : "today",
|
||||
href: SERVICE_HREFS.interview,
|
||||
href: serviceHref("interview", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }),
|
||||
source: "interview",
|
||||
priority: 86,
|
||||
});
|
||||
} else {
|
||||
pushSeed(seeds, { moduleId: "productivity", title: "Schedule a mock interview", subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 72 });
|
||||
pushSeed(seeds, { moduleId: "productivity", title: `Schedule a ${profile.targetRole} mock`, subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: serviceHref("interview", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }), source: "interview", priority: 72 });
|
||||
}
|
||||
|
||||
if (roleplayReview || roleplayComms !== undefined || roleplaySession) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "productivity",
|
||||
title: roleplayComms !== undefined ? `Roleplay communication ${Math.round(roleplayComms)}` : "Roleplay scenario is ready",
|
||||
subtitle: "Practice recruiter, manager, salary, or stakeholder conversations.",
|
||||
subtitle: `Practice recruiter, manager, salary, or stakeholder conversations for ${profile.targetRole}.`,
|
||||
tag: "Roleplay",
|
||||
urgency: "soon",
|
||||
href: SERVICE_HREFS.roleplay,
|
||||
href: serviceHref("roleplay", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }),
|
||||
source: "roleplay",
|
||||
priority: 78,
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.activeMissions.length) {
|
||||
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: "Bundle resume fit, mock practice, and Q Score deltas into one journey.", tag: "Begin", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
|
||||
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and Q Score deltas for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
|
||||
}
|
||||
|
||||
return seeds;
|
||||
}
|
||||
|
||||
async function collectContext(userId: string): Promise<HomeContext> {
|
||||
async function collectContext(userId: string, input: { userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeContext> {
|
||||
const [user] = await db.select({ id: users.id, email: users.email, displayName: users.displayName }).from(users).where(eq(users.id, userId)).limit(1);
|
||||
const [qscore] = await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, userId)).limit(1);
|
||||
const qscoreSignals = await db
|
||||
@@ -257,6 +402,27 @@ async function collectContext(userId: string): Promise<HomeContext> {
|
||||
.where(eq(growActiveMissions.userId, userId))
|
||||
.orderBy(desc(growActiveMissions.updatedAt))
|
||||
.limit(6);
|
||||
const suggestions = await db
|
||||
.select({
|
||||
id: missionSuggestions.id,
|
||||
missionInstanceId: missionSuggestions.missionInstanceId,
|
||||
missionId: missionSuggestions.missionId,
|
||||
stageId: missionSuggestions.stageId,
|
||||
role: missionSuggestions.role,
|
||||
type: missionSuggestions.type,
|
||||
title: missionSuggestions.title,
|
||||
body: missionSuggestions.body,
|
||||
reason: missionSuggestions.reason,
|
||||
priority: missionSuggestions.priority,
|
||||
urgency: missionSuggestions.urgency,
|
||||
ctaLabel: missionSuggestions.ctaLabel,
|
||||
ctaHref: missionSuggestions.ctaHref,
|
||||
updatedAt: missionSuggestions.updatedAt,
|
||||
})
|
||||
.from(missionSuggestions)
|
||||
.where(and(eq(missionSuggestions.userId, userId), eq(missionSuggestions.status, "active")))
|
||||
.orderBy(desc(missionSuggestions.priority), desc(missionSuggestions.updatedAt))
|
||||
.limit(12);
|
||||
const sessions = await db
|
||||
.select({ serviceId: missionServiceSessions.serviceId, externalId: missionServiceSessions.externalId, status: missionServiceSessions.status, updatedAt: missionServiceSessions.updatedAt, metadata: missionServiceSessions.metadata })
|
||||
.from(missionServiceSessions)
|
||||
@@ -296,10 +462,13 @@ async function collectContext(userId: string): Promise<HomeContext> {
|
||||
: undefined,
|
||||
qscoreSignals,
|
||||
activeMissions,
|
||||
missionSuggestions: suggestions,
|
||||
sessions: sessions.map((s) => ({ ...s, metadata: isRecord(s.metadata) ? s.metadata : null })),
|
||||
artifacts,
|
||||
events: events.map((e) => ({ ...e, payload: isRecord(e.payload) ? e.payload : {} })),
|
||||
serviceStates: { resume: resumeState, interview: interviewState, roleplay: roleplayState },
|
||||
userProfile: input.userProfile,
|
||||
preferences: input.preferences ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -327,6 +496,21 @@ async function readPersistedNotifications(userId: string) {
|
||||
.limit(60);
|
||||
}
|
||||
|
||||
function hasLegacyMockSeed(rows: GrowHomeNotificationRow[]) {
|
||||
const legacyTitles = new Set([
|
||||
"Complete your QX self-check",
|
||||
"Create your interview room",
|
||||
"Browse 1 career pathway",
|
||||
"Start with your Q Score",
|
||||
"Explore Interview-to-Offer",
|
||||
"Pathways are warming up",
|
||||
"Open Resume Builder",
|
||||
"Try a 10-minute mock interview",
|
||||
"Rewards unlock after activity",
|
||||
]);
|
||||
return rows.some((row) => legacyTitles.has(row.title));
|
||||
}
|
||||
|
||||
async function replaceGeneratedNotifications(userId: string, notifications: Array<SeedNotification>, generatedBy: "deterministic" | "agent") {
|
||||
await db
|
||||
.delete(growHomeNotifications)
|
||||
@@ -430,14 +614,16 @@ async function buildIdentity(ctx: HomeContext) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getHomeFeed(userId: string, opts: { refresh?: boolean } = {}): Promise<HomeFeedResponse> {
|
||||
const ctx = await collectContext(userId);
|
||||
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
|
||||
await ensureOnboardingBaselineQscoreFromLedger(userId);
|
||||
const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences });
|
||||
const persisted = await readPersistedNotifications(userId);
|
||||
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
|
||||
const hasDemo = persisted.some((row) => row.generatedBy === "demo");
|
||||
const hasLegacyMock = hasLegacyMockSeed(persisted);
|
||||
const fresh = newest > Date.now() - FRESH_MS;
|
||||
|
||||
if (persisted.length && (hasDemo || (!opts.refresh && fresh))) {
|
||||
if (persisted.length && !hasLegacyMock && (hasDemo || (!opts.refresh && fresh))) {
|
||||
const mode = hasDemo ? "demo" : hasAnyRealActivity(ctx) ? "dynamic" : "day1";
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -449,20 +635,29 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean } =
|
||||
|
||||
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,
|
||||
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 },
|
||||
|
||||
@@ -40,25 +40,57 @@ export type HomeFeedResponse = {
|
||||
};
|
||||
|
||||
export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notifications">> = {
|
||||
suggestions: { id: "suggestions", label: "Suggestions", href: "/suggestions", accent: "orange" },
|
||||
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" },
|
||||
productivity: { id: "productivity", label: "Productivity", href: "/productivity", accent: "orange" },
|
||||
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" },
|
||||
};
|
||||
|
||||
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "social", "pathways", "productivity", "rewards"];
|
||||
export const MODULE_IDS: HomeModuleId[] = [
|
||||
"suggestions",
|
||||
"missions",
|
||||
"social",
|
||||
"pathways",
|
||||
"productivity",
|
||||
"rewards",
|
||||
];
|
||||
|
||||
export const ALLOWED_NOTIFICATION_HREFS = new Set([
|
||||
"/suggestions",
|
||||
"/missions",
|
||||
"/social",
|
||||
"/pathways",
|
||||
"/productivity",
|
||||
"/missions/active",
|
||||
"/missions/available",
|
||||
"/agents/social-branding",
|
||||
"/agents/matchmaking",
|
||||
"/agents",
|
||||
"/rewards",
|
||||
"/agents/resume",
|
||||
"/agents/interview",
|
||||
"/agents/interview/setup",
|
||||
"/agents/roleplay",
|
||||
"/agents/roleplay/setup",
|
||||
"/agents/qscore",
|
||||
]);
|
||||
|
||||
export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
|
||||
"/missions/",
|
||||
"/missions/active",
|
||||
"/missions/available",
|
||||
"/agents/resume",
|
||||
"/agents/interview",
|
||||
"/agents/interview/setup",
|
||||
"/agents/roleplay",
|
||||
"/agents/roleplay/setup",
|
||||
"/agents/qscore",
|
||||
] as const;
|
||||
|
||||
export function isAllowedNotificationHref(href: string) {
|
||||
if (ALLOWED_NOTIFICATION_HREFS.has(href)) return true;
|
||||
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) =>
|
||||
prefix.endsWith("/")
|
||||
? href.startsWith(prefix)
|
||||
: href === prefix || href.startsWith(`${prefix}?`),
|
||||
);
|
||||
}
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -18,14 +18,21 @@ import { growRoutes } from "./routes/grow.js";
|
||||
import { missionRoutes } from "./routes/missions.js";
|
||||
import { eventRoutes } from "./routes/events.js";
|
||||
import { homeRoutes } from "./routes/home.js";
|
||||
import { dailyMissionRoutes } from "./routes/daily-mission.js";
|
||||
import { analyticsRoutes } from "./routes/analytics.js";
|
||||
import { logRoutes } from "./routes/logs.js";
|
||||
import { v1Routes } from "./v1/index.js";
|
||||
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
|
||||
import { startPassiveMissionReviewLoop } from "./missions/passive-runner.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).
|
||||
@@ -41,6 +48,7 @@ async function main() {
|
||||
|
||||
await reconcileOnBoot();
|
||||
startGrowEventsRedisConsumer().catch((err) => log.error({ err }, "failed to start grow events redis consumer"));
|
||||
startPassiveMissionReviewLoop();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -86,7 +94,11 @@ async function main() {
|
||||
app.route("/grow", growRoutes());
|
||||
app.route("/missions", missionRoutes());
|
||||
app.route("/events", eventRoutes());
|
||||
app.route("/analytics", analyticsRoutes());
|
||||
app.route("/v1", v1Routes());
|
||||
app.route("/logs", logRoutes());
|
||||
app.route("/home", homeRoutes());
|
||||
app.route("/daily-mission", dailyMissionRoutes());
|
||||
app.route("/conversations", conversationRoutes());
|
||||
app.route("/opencode", opencodeRoutes());
|
||||
app.route("/git", gitRoutes());
|
||||
|
||||
@@ -164,7 +164,7 @@ export async function loadPromptsFromDisk(): Promise<void> {
|
||||
} catch (err) {
|
||||
log.error({ err, path: SYSTEM_PROMPT_FILE }, "failed to load system prompt — using fallback");
|
||||
// Fallback: assemble from modules without a template file.
|
||||
const fallback = `You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.\n\n## Sub-Agent Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`;
|
||||
const fallback = `You are Grow — a unified AI career assistant for the GrowQR platform.\n\n## Specialist Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`;
|
||||
cachedSystemPrompt = fallback;
|
||||
}
|
||||
}
|
||||
|
||||
80
src/missions/action-types.ts
Normal file
80
src/missions/action-types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { missionActions } from "../db/schema.js";
|
||||
|
||||
export type MissionActionMode = "autonomous" | "approval_required" | "user_input_required" | "suggestion";
|
||||
export type MissionActionStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "waiting_approval"
|
||||
| "waiting_user_input"
|
||||
| "done"
|
||||
| "failed"
|
||||
| "dismissed"
|
||||
| "snoozed";
|
||||
export type MissionActionUrgency = "now" | "today" | "soon" | "calm";
|
||||
|
||||
export type MissionActionRow = InferSelectModel<typeof missionActions>;
|
||||
|
||||
export type MissionActionDto = {
|
||||
id: string;
|
||||
userId: string;
|
||||
missionInstanceId: string;
|
||||
missionId: string;
|
||||
stageId?: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
baseAgent?: string;
|
||||
serviceId?: string;
|
||||
toolName?: string;
|
||||
mode: MissionActionMode;
|
||||
status: MissionActionStatus;
|
||||
title: string;
|
||||
body: string;
|
||||
prompt?: string;
|
||||
payload: Record<string, unknown>;
|
||||
result?: Record<string, unknown>;
|
||||
error?: string;
|
||||
sourceEventId?: string;
|
||||
idempotencyKey?: string;
|
||||
priority: number;
|
||||
urgency: MissionActionUrgency;
|
||||
dueAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
resolvedAt?: string;
|
||||
};
|
||||
|
||||
export type NewMissionActionInput = {
|
||||
userId: string;
|
||||
missionInstanceId: string;
|
||||
missionId: string;
|
||||
stageId?: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
baseAgent?: string;
|
||||
serviceId?: string;
|
||||
toolName?: string;
|
||||
mode: MissionActionMode;
|
||||
status?: MissionActionStatus;
|
||||
title: string;
|
||||
body: string;
|
||||
prompt?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
result?: Record<string, unknown>;
|
||||
error?: string;
|
||||
sourceEventId?: string;
|
||||
idempotencyKey?: string;
|
||||
priority?: number;
|
||||
urgency?: MissionActionUrgency;
|
||||
dueAt?: Date | string;
|
||||
};
|
||||
|
||||
export function defaultMissionActionStatus(mode: MissionActionMode): MissionActionStatus {
|
||||
if (mode === "approval_required") return "waiting_approval";
|
||||
if (mode === "user_input_required") return "waiting_user_input";
|
||||
return "queued";
|
||||
}
|
||||
|
||||
export function isOpenMissionActionStatus(status: MissionActionStatus) {
|
||||
return status === "queued" || status === "running" || status === "waiting_approval" || status === "waiting_user_input" || status === "failed";
|
||||
}
|
||||
196
src/missions/actions.ts
Normal file
196
src/missions/actions.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
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 } 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"];
|
||||
|
||||
function toDate(value?: Date | string) {
|
||||
if (!value) return undefined;
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
export function actionToDto(row: MissionActionRow): MissionActionDto {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
missionInstanceId: row.missionInstanceId,
|
||||
missionId: row.missionId,
|
||||
stageId: row.stageId ?? undefined,
|
||||
agentId: row.agentId,
|
||||
agentName: row.agentName,
|
||||
baseAgent: row.baseAgent ?? undefined,
|
||||
serviceId: row.serviceId ?? undefined,
|
||||
toolName: row.toolName ?? undefined,
|
||||
mode: row.mode,
|
||||
status: row.status,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
prompt: row.prompt ?? undefined,
|
||||
payload: row.payload ?? {},
|
||||
result: row.result ?? undefined,
|
||||
error: row.error ?? undefined,
|
||||
sourceEventId: row.sourceEventId ?? undefined,
|
||||
idempotencyKey: row.idempotencyKey ?? undefined,
|
||||
priority: row.priority,
|
||||
urgency: row.urgency,
|
||||
dueAt: row.dueAt?.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
resolvedAt: row.resolvedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
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 missionHref = missionDetailHref(action.missionInstanceId);
|
||||
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 };
|
||||
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;
|
||||
const category = getService(action.serviceId)?.category;
|
||||
if (category === "practice") return "practice" as const;
|
||||
if (category === "document") return "artifact" as const;
|
||||
return "action" as const;
|
||||
}
|
||||
|
||||
async function refreshSuggestionForAction(row: MissionActionRow) {
|
||||
const active = OPEN_STATUSES.includes(row.status);
|
||||
const { ctaLabel, ctaHref } = ctaForAction(row);
|
||||
const status = active ? "active" : row.status === "done" ? "done" : "dismissed";
|
||||
|
||||
await db.insert(missionSuggestions).values({
|
||||
id: `suggestion:${row.id}`,
|
||||
userId: row.userId,
|
||||
missionInstanceId: row.missionInstanceId,
|
||||
missionId: row.missionId,
|
||||
stageId: row.stageId,
|
||||
role: row.agentName,
|
||||
type: suggestionTypeForAction(row),
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
reason: row.prompt,
|
||||
priority: row.priority,
|
||||
urgency: row.urgency,
|
||||
status,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
sourceRefs: { actionId: row.id, sourceEventId: row.sourceEventId, toolName: row.toolName },
|
||||
generatedBy: "agent",
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: missionSuggestions.id,
|
||||
set: {
|
||||
stageId: row.stageId,
|
||||
role: row.agentName,
|
||||
type: suggestionTypeForAction(row),
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
reason: row.prompt,
|
||||
priority: row.priority,
|
||||
urgency: row.urgency,
|
||||
status,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
sourceRefs: { actionId: row.id, sourceEventId: row.sourceEventId, toolName: row.toolName },
|
||||
generatedBy: "agent",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMissionAction(input: NewMissionActionInput) {
|
||||
const [row] = await db.insert(missionActions).values({
|
||||
...input,
|
||||
status: input.status ?? defaultMissionActionStatus(input.mode),
|
||||
priority: input.priority ?? 0,
|
||||
urgency: input.urgency ?? "calm",
|
||||
payload: input.payload ?? {},
|
||||
result: input.result,
|
||||
dueAt: toDate(input.dueAt),
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoNothing().returning();
|
||||
if (!row) return null;
|
||||
await refreshSuggestionForAction(row);
|
||||
return actionToDto(row);
|
||||
}
|
||||
|
||||
export async function createMissionActionsFromPatches(input: { userId: string; mission: GrowActiveMission; eventId: string; patches: MissionActionPatch[] }) {
|
||||
const created: MissionActionDto[] = [];
|
||||
for (const patch of input.patches) {
|
||||
const action = await createMissionAction({
|
||||
userId: input.userId,
|
||||
missionInstanceId: input.mission.instanceId,
|
||||
missionId: input.mission.missionId,
|
||||
stageId: patch.stageId,
|
||||
agentId: patch.agentId,
|
||||
agentName: patch.agentName,
|
||||
baseAgent: patch.baseAgent,
|
||||
serviceId: patch.serviceId,
|
||||
toolName: patch.toolName,
|
||||
mode: patch.mode,
|
||||
status: patch.status,
|
||||
title: patch.title,
|
||||
body: patch.body,
|
||||
prompt: patch.prompt,
|
||||
payload: patch.payload ?? {},
|
||||
sourceEventId: patch.sourceEventId ?? input.eventId,
|
||||
idempotencyKey: patch.idempotencyKey,
|
||||
priority: patch.priority,
|
||||
urgency: patch.urgency,
|
||||
dueAt: patch.dueAt,
|
||||
});
|
||||
if (action) created.push(action);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function listMissionActions(userId: string, opts: { missionInstanceId?: string; openOnly?: boolean } = {}) {
|
||||
const conditions = [eq(missionActions.userId, userId)];
|
||||
if (opts.missionInstanceId) conditions.push(eq(missionActions.missionInstanceId, opts.missionInstanceId));
|
||||
if (opts.openOnly ?? true) conditions.push(inArray(missionActions.status, OPEN_STATUSES));
|
||||
const rows = await db.select().from(missionActions).where(and(...conditions)).orderBy(desc(missionActions.priority), desc(missionActions.updatedAt));
|
||||
return rows.map(actionToDto);
|
||||
}
|
||||
|
||||
export async function getMissionAction(userId: string, actionId: string) {
|
||||
const [row] = await db.select().from(missionActions).where(and(eq(missionActions.userId, userId), eq(missionActions.id, actionId))).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function updateMissionActionStatus(userId: string, actionId: string, patch: { status: MissionActionStatus; result?: Record<string, unknown>; error?: string; payload?: Record<string, unknown> }) {
|
||||
const resolvedAt = DONE_STATUSES.includes(patch.status) ? new Date() : undefined;
|
||||
const [row] = await db.update(missionActions).set({
|
||||
status: patch.status,
|
||||
result: patch.result,
|
||||
error: patch.error,
|
||||
payload: patch.payload,
|
||||
resolvedAt,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(eq(missionActions.userId, userId), eq(missionActions.id, actionId))).returning();
|
||||
if (!row) return null;
|
||||
await refreshSuggestionForAction(row);
|
||||
return actionToDto(row);
|
||||
}
|
||||
129
src/missions/career-transition/reducer.ts
Normal file
129
src/missions/career-transition/reducer.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractResumeSignals,
|
||||
extractWeakAreas,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
|
||||
export const careerTransitionReducer: MissionReducer = {
|
||||
missionId: "career-transition",
|
||||
accepts(ctx) {
|
||||
return ctx.activeMission.missionId === "career-transition" &&
|
||||
(missionExplicitlyMatches(ctx.event.mission, "career-transition") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
|
||||
},
|
||||
reduce(ctx): MissionReduction {
|
||||
const { event, activeMission } = ctx;
|
||||
const type = event.type;
|
||||
const payload = event.payload ?? {};
|
||||
const stagePatches: MissionStagePatch[] = [];
|
||||
const artifacts: MissionReduction["artifacts"] = [];
|
||||
const actions: MissionReduction["actions"] = [];
|
||||
let eventMessage = ctx.insight.summary;
|
||||
|
||||
if (!activeMission.goal && (type === "mission.started" || type.startsWith("mission."))) {
|
||||
actions.push(actionForAgent("career-transition", "planner", {
|
||||
stageId: "clarify-target",
|
||||
mode: "user_input_required",
|
||||
title: "Choose the target role for your transition",
|
||||
body: "Career transition needs a clear adjacent role before resume repositioning or practice will be useful.",
|
||||
prompt: "What role are you exploring next, and what role/background are you moving from?",
|
||||
payload: { fields: ["current_role", "target_role", "constraints"] },
|
||||
idempotencyKey: `${activeMission.instanceId}:clarify-target-role`,
|
||||
priority: 100,
|
||||
urgency: "now",
|
||||
}));
|
||||
}
|
||||
|
||||
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
|
||||
const signals = extractResumeSignals(payload);
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Transferable skills mapped from resume evidence." });
|
||||
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Validate the transition story in an adjacent-role mock interview." });
|
||||
artifacts.push({ type: "transferable_skills_map", title: "Transferable skills map", stageId: "resume", summary: signals[0] ?? "Resume proof was mapped into transition evidence.", metadata: { sourceEventId: event.id, signals } });
|
||||
actions.push(actionForAgent("career-transition", "resume", {
|
||||
stageId: "resume",
|
||||
serviceId: "resume-service",
|
||||
toolName: "resume.create_version_prompt_draft",
|
||||
mode: "approval_required",
|
||||
title: "Draft a repositioned resume for the target role?",
|
||||
body: "Approve a Resume Agent draft that reframes your transferable skills for the career switch.",
|
||||
payload: { signals, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:resume-transition-draft:${event.id}`,
|
||||
priority: 95,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passiveResumeAnalysisInterviewPractice({
|
||||
missionId: "career-transition",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "interview",
|
||||
priority: 98,
|
||||
}));
|
||||
eventMessage = "Transferable skills map created; repositioned resume action is ready.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && type.includes("configured")) {
|
||||
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 40, outputSummary: "Adjacent-role mock interview configured." });
|
||||
eventMessage = "Adjacent-role interview practice started.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Adjacent-role credibility checked." });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the 'why I am switching' narrative next." });
|
||||
artifacts.push({ type: "transition_interview_diagnosis", title: "Adjacent-role credibility diagnosis", stageId: "interview", summary: weakAreas.length ? `Needs work: ${weakAreas.join(", ")}` : "Interview review completed for transition credibility.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
actions.push(actionForAgent("career-transition", "roleplay", {
|
||||
stageId: "roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
toolName: "roleplay.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Practice your 'why I am switching' pitch",
|
||||
body: "Turn the adjacent-role feedback into a confident transition narrative before real conversations.",
|
||||
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:transition-pitch-roleplay:${event.id}`,
|
||||
priority: 92,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passiveInterviewFeedbackResumeUpgrade({
|
||||
missionId: "career-transition",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "resume",
|
||||
priority: 104,
|
||||
}));
|
||||
eventMessage = "Career transition interview feedback produced the next pitch-practice action.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const passive = passiveRoleplayFeedbackStoryBank({
|
||||
missionId: "career-transition",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "roleplay",
|
||||
priority: 94,
|
||||
});
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Transition pitch practice reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Transition confidence signals updated." });
|
||||
artifacts.push(passive.artifact);
|
||||
actions.push(passive.action);
|
||||
eventMessage = "Transition narrative practice completed.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Transition readiness signals updated." });
|
||||
return { stagePatches, artifacts, actions, eventMessage };
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { MissionReducer } from "./reducer-types.js";
|
||||
import { interviewToOfferReducer } from "./interview-to-offer/reducer.js";
|
||||
import { careerTransitionReducer } from "./career-transition/reducer.js";
|
||||
import { salaryNegotiationReducer } from "./salary-negotiation-war-room/reducer.js";
|
||||
import { promotionReadinessReducer } from "./promotion-readiness/reducer.js";
|
||||
import { personalBrandOpportunityReducer } from "./personal-brand-opportunity-engine/reducer.js";
|
||||
|
||||
export const missionEventReducers: MissionReducer[] = [interviewToOfferReducer];
|
||||
export const missionEventReducers: MissionReducer[] = [
|
||||
interviewToOfferReducer,
|
||||
careerTransitionReducer,
|
||||
salaryNegotiationReducer,
|
||||
promotionReadinessReducer,
|
||||
personalBrandOpportunityReducer,
|
||||
];
|
||||
|
||||
export function reducersForMission(missionId: string) {
|
||||
return missionEventReducers.filter((reducer) => reducer.missionId === missionId);
|
||||
|
||||
@@ -1,99 +1,229 @@
|
||||
import { asRecord, getNumber, getString } from "../../events/envelope.js";
|
||||
import type { MissionArtifactPatch, MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import { getString } from "../../events/envelope.js";
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractMissingProof,
|
||||
extractOverallScore,
|
||||
extractResumeProofPoints,
|
||||
extractResumeSignals,
|
||||
extractStoryIssues,
|
||||
extractWeakAreas,
|
||||
isInterviewEvent,
|
||||
isQscoreEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionExplicitlyMatches,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
|
||||
function eventMatchesSource(source: string, type: string) {
|
||||
return (
|
||||
source.includes("resume") ||
|
||||
source.includes("interview") ||
|
||||
source.includes("qscore") ||
|
||||
type.startsWith("resume.") ||
|
||||
type.startsWith("interview.") ||
|
||||
type.startsWith("qscore.") ||
|
||||
type.startsWith("mission.interview_to_offer")
|
||||
);
|
||||
function acceptsMission(ctx: Parameters<MissionReducer["accepts"]>[0]) {
|
||||
return ctx.activeMission.missionId === "interview-to-offer" &&
|
||||
(missionExplicitlyMatches(ctx.event.mission, "interview-to-offer") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
|
||||
}
|
||||
|
||||
function reviewSummary(payload: Record<string, unknown>) {
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
const overall = getNumber(review.overall_score ?? review.overallScore);
|
||||
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
|
||||
if (overall !== undefined && summary) return `Mock interview review completed with score ${overall}. ${summary}`;
|
||||
if (overall !== undefined) return `Mock interview review completed with score ${overall}.`;
|
||||
const score = extractOverallScore(payload);
|
||||
const summary = getString(payload.summary ?? payload.feedback_summary ?? payload.overall_feedback);
|
||||
if (score !== undefined && summary) return `Mock interview review completed with score ${score}. ${summary}`;
|
||||
if (score !== undefined) return `Mock interview review completed with score ${score}.`;
|
||||
return summary ?? "Mock interview review completed.";
|
||||
}
|
||||
|
||||
function isFeedbackEvent(type: string) {
|
||||
return type.includes("review_completed") || type.includes("review.completed") || type.includes("feedback.generated");
|
||||
}
|
||||
|
||||
export const interviewToOfferReducer: MissionReducer = {
|
||||
missionId: "interview-to-offer",
|
||||
|
||||
accepts(ctx) {
|
||||
if (ctx.activeMission.missionId !== "interview-to-offer") return false;
|
||||
const mission = asRecord(ctx.event.mission);
|
||||
const explicitMissionId = getString(mission.missionId ?? mission.mission_id);
|
||||
return explicitMissionId === "interview-to-offer" || eventMatchesSource(ctx.event.source.toLowerCase(), ctx.event.type);
|
||||
},
|
||||
accepts: acceptsMission,
|
||||
|
||||
reduce(ctx): MissionReduction {
|
||||
const type = ctx.event.type;
|
||||
const payload = ctx.event.payload ?? {};
|
||||
const { event, activeMission } = ctx;
|
||||
const type = event.type;
|
||||
const payload = event.payload ?? {};
|
||||
const stagePatches: MissionStagePatch[] = [...ctx.insight.missionStageHints.map((hint) => ({
|
||||
stageId: hint.stageId,
|
||||
status: hint.status,
|
||||
progressPercent: hint.progressPercent,
|
||||
outputSummary: hint.reason,
|
||||
}))];
|
||||
const artifacts: MissionArtifactPatch[] = [];
|
||||
const artifacts: MissionReduction["artifacts"] = [];
|
||||
const actions: MissionReduction["actions"] = [];
|
||||
let eventMessage = ctx.insight.summary;
|
||||
|
||||
if (type.startsWith("resume.") && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) {
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume fit scan completed." });
|
||||
stagePatches.push({ stageId: "interview-plan", status: "ready", progressPercent: 0 });
|
||||
if (isResumeEvent(event.source, type) && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) {
|
||||
const signals = extractResumeSignals(payload);
|
||||
const proofPoints = extractResumeProofPoints(payload);
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume talking points and fit scan are ready." });
|
||||
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0 });
|
||||
artifacts.push({
|
||||
type: "resume_fit_scan",
|
||||
title: "Resume Fit Scan",
|
||||
type: "resume_talking_points",
|
||||
title: "Resume-based talking points",
|
||||
stageId: "resume",
|
||||
summary: getString(payload.summary) ?? "Resume analysis completed and readiness signals were updated.",
|
||||
metadata: { sourceEventId: ctx.event.id, payload },
|
||||
summary: signals[0] ?? "Resume analysis completed and role-fit talking points are ready.",
|
||||
metadata: { sourceEventId: event.id, signals, payload },
|
||||
});
|
||||
eventMessage = "Resume fit scan completed for Interview-to-Offer.";
|
||||
actions.push(actionForAgent("interview-to-offer", "interview", {
|
||||
stageId: "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Start the first mock interview",
|
||||
body: "Your resume proof is ready. Run a targeted mock interview for the scheduled role now.",
|
||||
payload: { href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:resume-analysis:start-interview:${event.id}`,
|
||||
priority: 92,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(actionForAgent("interview-to-offer", "interview", {
|
||||
stageId: "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Practice explaining your strongest resume proof",
|
||||
body: proofPoints.strengths.length
|
||||
? `Run a mock focused on ${proofPoints.strengths.slice(0, 2).join(" and ")} so your resume turns into interview-ready stories.`
|
||||
: "Run a resume-led mock interview so your strongest proof turns into interview-ready stories.",
|
||||
payload: {
|
||||
passiveAction: "resume_analysis_to_interview_practice",
|
||||
resumeSignals: signals,
|
||||
proofPoints,
|
||||
prompt: proofPoints.strengths[0]
|
||||
? `Practice explaining ${proofPoints.strengths[0]} with clear ownership, impact, and tradeoffs.`
|
||||
: "Practice explaining your strongest resume project with clear ownership, impact, and tradeoffs.",
|
||||
href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview"),
|
||||
},
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:resume-analysis:proof-interview:${event.id}`,
|
||||
priority: 98,
|
||||
urgency: "today",
|
||||
}));
|
||||
eventMessage = "Resume fit scan completed; mock interview is ready to run.";
|
||||
}
|
||||
|
||||
if (type.startsWith("interview.") && (type.includes("configured") || type.includes("created"))) {
|
||||
if (isInterviewEvent(event.source, type) && (type.includes("configured") || type.includes("created"))) {
|
||||
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 35, outputSummary: "Mock interview session configured." });
|
||||
eventMessage = "Mock interview session configured.";
|
||||
}
|
||||
|
||||
if (type.startsWith("interview.") && (type.includes("session_completed") || type.includes("session.completed"))) {
|
||||
if (isInterviewEvent(event.source, type) && (type.includes("session_completed") || type.includes("session.completed"))) {
|
||||
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 75, outputSummary: "Interview completed; review is being prepared." });
|
||||
eventMessage = "Mock interview completed; waiting for review.";
|
||||
}
|
||||
|
||||
if (type.startsWith("interview.") && (type.includes("review_completed") || type.includes("review.completed"))) {
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
const missingProof = extractMissingProof(payload);
|
||||
const storyIssues = extractStoryIssues(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: reviewSummary(payload) });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the communication gaps surfaced by interview feedback." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 60, outputSummary: "Readiness signals updated from interview review." });
|
||||
artifacts.push({
|
||||
type: "mock_interview_review",
|
||||
title: "Mock Interview Review",
|
||||
title: "Weakness diagnosis",
|
||||
stageId: "interview",
|
||||
summary: reviewSummary(payload),
|
||||
metadata: { sourceEventId: ctx.event.id, payload },
|
||||
metadata: { sourceEventId: event.id, weakAreas, payload },
|
||||
});
|
||||
eventMessage = "Mock interview review completed and mission readiness was updated.";
|
||||
actions.push(actionForAgent("interview-to-offer", "resume", {
|
||||
stageId: "resume",
|
||||
serviceId: "resume-service",
|
||||
toolName: "resume.create_version_prompt_draft",
|
||||
mode: "approval_required",
|
||||
title: "Draft stronger resume bullets from interview feedback?",
|
||||
body: [...weakAreas, ...missingProof, ...storyIssues].length
|
||||
? `Approve a Resume Agent draft that fixes ${[...weakAreas, ...missingProof, ...storyIssues].slice(0, 3).join(", ")} with stronger bullets and proof.`
|
||||
: "Approve a Resume Agent draft that turns the interview feedback into stronger bullets and talking points.",
|
||||
prompt: "Create a resume upgrade draft from this interview feedback. Focus on measurable impact, ownership, missing proof, and reusable talking points.",
|
||||
payload: {
|
||||
passiveAction: "interview_feedback_to_resume_upgrade",
|
||||
weakAreas,
|
||||
missingProof,
|
||||
storyIssues,
|
||||
sourceReviewEventId: event.id,
|
||||
draftInstructions: [
|
||||
"Rewrite weak bullets with action, scope, metric, and result.",
|
||||
"Add proof for any interview gaps that lacked evidence.",
|
||||
"Create talking points that match the feedback themes.",
|
||||
],
|
||||
href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume"),
|
||||
},
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:interview-review:tailor-resume:${event.id}`,
|
||||
priority: 108,
|
||||
urgency: "now",
|
||||
}));
|
||||
if (weakAreas.some((area) => /communication|story|clarity|confidence|concise/i.test(area))) {
|
||||
actions.push(actionForAgent("interview-to-offer", "roleplay", {
|
||||
stageId: "roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
toolName: "roleplay.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Run a communication recovery drill",
|
||||
body: "Practice the exact communication weakness from the mock interview before the real interview.",
|
||||
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:interview-review:roleplay:${event.id}`,
|
||||
priority: 94,
|
||||
urgency: "today",
|
||||
}));
|
||||
}
|
||||
eventMessage = "Interview review completed; resume and roleplay next actions were created.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length > 0) {
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
const storyIssues = extractStoryIssues(payload);
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Communication drill reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Communication readiness updated." });
|
||||
artifacts.push({
|
||||
type: "story_bank_update",
|
||||
title: "Story bank updates from roleplay feedback",
|
||||
stageId: "roleplay",
|
||||
summary: [...weakAreas, ...storyIssues].length
|
||||
? `Turn these into reusable STAR stories: ${[...weakAreas, ...storyIssues].slice(0, 5).join(", ")}`
|
||||
: "Roleplay feedback captured story bank improvements for future interviews.",
|
||||
metadata: { sourceEventId: event.id, weakAreas, storyIssues, payload },
|
||||
});
|
||||
actions.push(actionForAgent("interview-to-offer", "interview", {
|
||||
stageId: "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Run a story-bank recovery mock",
|
||||
body: [...weakAreas, ...storyIssues].length
|
||||
? `Practice the communication gaps from roleplay: ${[...weakAreas, ...storyIssues].slice(0, 3).join(", ")}.`
|
||||
: "Run a targeted mock interview that converts roleplay feedback into reusable STAR stories.",
|
||||
payload: {
|
||||
passiveAction: "roleplay_feedback_to_communication_drill",
|
||||
weakAreas,
|
||||
storyIssues,
|
||||
storyBankInstructions: [
|
||||
"Convert each weak communication moment into a STAR story prompt.",
|
||||
"Practice the answer in an interview setting.",
|
||||
"Save the strongest version as reusable story-bank material.",
|
||||
],
|
||||
href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview"),
|
||||
},
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:roleplay-review:story-bank-interview:${event.id}`,
|
||||
priority: 96,
|
||||
urgency: "today",
|
||||
}));
|
||||
eventMessage = "Roleplay review improved interview communication readiness.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length > 0 || (isQscoreEvent(event.source, type) && type.includes("updated"))) {
|
||||
stagePatches.push({
|
||||
stageId: "qscore",
|
||||
status: "in_progress",
|
||||
progressPercent: Math.max(40, Math.min(90, ctx.qscoreSignals.length * 15)),
|
||||
outputSummary: `${ctx.qscoreSignals.length} readiness signal${ctx.qscoreSignals.length === 1 ? "" : "s"} updated.`,
|
||||
status: type.startsWith("qscore.") ? "done" : "in_progress",
|
||||
progressPercent: type.startsWith("qscore.") ? 100 : Math.max(40, Math.min(90, ctx.qscoreSignals.length * 15)),
|
||||
outputSummary: `${ctx.qscoreSignals.length || 1} readiness signal${ctx.qscoreSignals.length === 1 ? "" : "s"} updated.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (type.startsWith("qscore.") && (type.includes("snapshot") || type.includes("computed") || type.includes("updated"))) {
|
||||
stagePatches.push({ stageId: "qscore", status: "done", progressPercent: 100, outputSummary: "Readiness Q Score updated." });
|
||||
eventMessage = "Readiness Q Score updated.";
|
||||
}
|
||||
|
||||
return { stagePatches, artifacts, eventMessage };
|
||||
return { stagePatches, artifacts, actions, eventMessage };
|
||||
},
|
||||
};
|
||||
|
||||
354
src/missions/lifecycle.ts
Normal file
354
src/missions/lifecycle.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import crypto from "node:crypto";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { config } from "../config.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents, users } from "../db/schema.js";
|
||||
import {
|
||||
completeMissionCoachRunPg,
|
||||
createMissionCoachRunPg,
|
||||
getActiveMissionPg,
|
||||
listActiveMissionsForPassiveReviewPg,
|
||||
listActiveMissionsPg,
|
||||
replaceMissionSuggestionsPg,
|
||||
upsertActiveMissionPg,
|
||||
} from "../grow/persistence.js";
|
||||
import { recordGrowEvent, markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { getPersistedMissionDefinition } from "./postgres-registry.js";
|
||||
import { buildDeterministicMissionSuggestions } from "./suggestions.js";
|
||||
import type { Registry } from "../actors/registry.js";
|
||||
import type { GrowActiveMission, MissionActorType, MissionId, MissionSnapshot } from "../actors/missions/types.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
const ONBOARDING_MISSION_LIMIT = 2;
|
||||
const PASSIVE_REVIEW_SOURCE = "growqr-backend:mission-passive";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
|
||||
}
|
||||
|
||||
function missionActorFor(userId: string, instanceId: string, actorType: MissionActorType) {
|
||||
const client = getClient();
|
||||
switch (actorType) {
|
||||
case "interviewToOfferMissionActor":
|
||||
return client.interviewToOfferMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "careerTransitionMissionActor":
|
||||
return client.careerTransitionMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "salaryNegotiationWarRoomMissionActor":
|
||||
return client.salaryNegotiationWarRoomMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "promotionReadinessMissionActor":
|
||||
return client.promotionReadinessMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "personalBrandOpportunityEngineMissionActor":
|
||||
return client.personalBrandOpportunityEngineMissionActor.getOrCreate([userId, instanceId]);
|
||||
}
|
||||
}
|
||||
|
||||
function activeMissionFromSnapshot(snapshot: MissionSnapshot): GrowActiveMission {
|
||||
return {
|
||||
instanceId: snapshot.instanceId,
|
||||
missionId: snapshot.missionId,
|
||||
workflowId: snapshot.workflowId,
|
||||
title: snapshot.title,
|
||||
shortTitle: snapshot.shortTitle,
|
||||
status: snapshot.status,
|
||||
progressPercent: snapshot.progressPercent,
|
||||
currentStageId: snapshot.currentStageId,
|
||||
goal: snapshot.goal,
|
||||
actorType: actorTypeFor(snapshot.missionId),
|
||||
createdAt: new Date(snapshot.createdAt).getTime(),
|
||||
updatedAt: new Date(snapshot.updatedAt).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function actorTypeFor(missionId: MissionId): MissionActorType | undefined {
|
||||
if (missionId === "interview-to-offer") return "interviewToOfferMissionActor";
|
||||
if (missionId === "career-transition") return "careerTransitionMissionActor";
|
||||
if (missionId === "salary-negotiation-war-room") return "salaryNegotiationWarRoomMissionActor";
|
||||
if (missionId === "promotion-readiness") return "promotionReadinessMissionActor";
|
||||
if (missionId === "personal-brand-opportunity-engine") return "personalBrandOpportunityEngineMissionActor";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hashUser(userId: string) {
|
||||
return crypto.createHash("sha256").update(userId).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
export function onboardingMissionInstanceId(userId: string, missionId: MissionId) {
|
||||
return `mission-${missionId}-${hashUser(userId)}`;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function stringValues(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())).map((item) => item.trim());
|
||||
if (typeof value === "string" && value.trim()) return [value.trim()];
|
||||
return [];
|
||||
}
|
||||
|
||||
function onboardingText(context?: Record<string, unknown>) {
|
||||
const source = asRecord(context);
|
||||
const preferences = asRecord(source.preferences);
|
||||
const onboarding = asRecord(source.onboarding ?? preferences.onboarding);
|
||||
const mission = asRecord(preferences.mission_preferences);
|
||||
const resume = asRecord(preferences.resume_preferences);
|
||||
const interview = asRecord(preferences.interview_preferences);
|
||||
const values = [
|
||||
...stringValues(onboarding.goal),
|
||||
...stringValues(onboarding.target_role ?? onboarding.targetRole ?? onboarding.role ?? onboarding.current_role),
|
||||
...stringValues(onboarding.timeline),
|
||||
...stringValues(mission.active_goal),
|
||||
...stringValues(resume.target_title),
|
||||
...stringValues(interview.job_description),
|
||||
...stringValues(preferences.target_roles),
|
||||
...stringValues(preferences.target_companies),
|
||||
];
|
||||
return values.join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
export function selectOnboardingMissionIds(context?: Record<string, unknown>, limit = ONBOARDING_MISSION_LIMIT): MissionId[] {
|
||||
const text = onboardingText(context);
|
||||
const primary: MissionId =
|
||||
/salary|compensation|negotiat/.test(text) ? "salary-negotiation-war-room" :
|
||||
/promot|manager|leadership|level up|level-up/.test(text) ? "promotion-readiness" :
|
||||
/transition|switch|pivot|career change|new field/.test(text) ? "career-transition" :
|
||||
/brand|linkedin|network|visibility|opportunit/.test(text) ? "personal-brand-opportunity-engine" :
|
||||
"interview-to-offer";
|
||||
|
||||
const ordered: MissionId[] = [
|
||||
primary,
|
||||
"personal-brand-opportunity-engine",
|
||||
"interview-to-offer",
|
||||
"career-transition",
|
||||
"promotion-readiness",
|
||||
"salary-negotiation-war-room",
|
||||
];
|
||||
return Array.from(new Set(ordered)).slice(0, Math.max(1, limit));
|
||||
}
|
||||
|
||||
async function ensureUser(userId: string) {
|
||||
await db
|
||||
.insert(users)
|
||||
.values({ id: userId, email: `${userId}@service.local`, displayName: userId })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function ensureOnboardingActiveMissions(input: {
|
||||
userId: string;
|
||||
context?: Record<string, unknown>;
|
||||
completedAt?: string | Date | null;
|
||||
sourceEventId?: string;
|
||||
source?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const userId = input.userId.trim();
|
||||
if (!userId) return { status: "skipped" as const, reason: "missing_user_id" as const, started: [], existing: [] };
|
||||
|
||||
await ensureUser(userId);
|
||||
const missionIds = selectOnboardingMissionIds(input.context, input.limit ?? ONBOARDING_MISSION_LIMIT);
|
||||
const activeRows = await listActiveMissionsPg(userId);
|
||||
const started: GrowActiveMission[] = [];
|
||||
const existing: GrowActiveMission[] = [];
|
||||
|
||||
for (const missionId of missionIds) {
|
||||
const alreadyActive = activeRows.find((item) => item.mission.missionId === missionId && ["active", "paused"].includes(item.mission.status));
|
||||
if (alreadyActive) {
|
||||
existing.push(alreadyActive.mission);
|
||||
continue;
|
||||
}
|
||||
|
||||
const mission = await getPersistedMissionDefinition(missionId);
|
||||
if (!mission?.actorType) continue;
|
||||
|
||||
const instanceId = onboardingMissionInstanceId(userId, missionId);
|
||||
const existingInstance = await getActiveMissionPg(userId, instanceId);
|
||||
if (existingInstance) {
|
||||
existing.push(existingInstance.mission);
|
||||
continue;
|
||||
}
|
||||
|
||||
const actor = missionActorFor(userId, instanceId, mission.actorType);
|
||||
const completedAt = input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt ?? new Date().toISOString();
|
||||
const snapshot = await actor.init({
|
||||
userId,
|
||||
instanceId,
|
||||
missionId,
|
||||
goal: onboardingText(input.context) || undefined,
|
||||
input: {
|
||||
source: "onboarding",
|
||||
sourceEventId: input.sourceEventId,
|
||||
completedAt,
|
||||
context: input.context ?? {},
|
||||
},
|
||||
});
|
||||
const activeMission = activeMissionFromSnapshot(snapshot);
|
||||
await upsertActiveMissionPg(userId, activeMission, snapshot);
|
||||
started.push(activeMission);
|
||||
|
||||
const event = await recordGrowEvent({
|
||||
source: input.source ?? "onboarding",
|
||||
type: "mission.started",
|
||||
category: "mission",
|
||||
userId,
|
||||
occurredAt: completedAt,
|
||||
mission: { instanceId, missionId, stageId: snapshot.currentStageId },
|
||||
correlation: { sourceEventId: input.sourceEventId },
|
||||
payload: {
|
||||
trigger: "onboarding",
|
||||
title: snapshot.title,
|
||||
goal: snapshot.goal,
|
||||
selectedMissionIds: missionIds,
|
||||
},
|
||||
dedupeKey: `mission-onboarding-start:${userId}:${missionId}`,
|
||||
}, { userId, source: input.source ?? "onboarding" });
|
||||
await routeGrowEventToUserActor(event).catch((err) => log.warn({ err, userId, missionId }, "failed to route onboarding mission start event"));
|
||||
}
|
||||
|
||||
return {
|
||||
status: started.length ? "started" as const : "already_ready" as const,
|
||||
selectedMissionIds: missionIds,
|
||||
started,
|
||||
existing,
|
||||
};
|
||||
}
|
||||
|
||||
function passiveReviewDate(input?: string | Date) {
|
||||
const date = input instanceof Date ? input : input ? new Date(input) : new Date();
|
||||
return Number.isNaN(date.getTime()) ? new Date().toISOString().slice(0, 10) : date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function passiveReviewAlreadyRan(instanceId: string, date: string) {
|
||||
const [existing] = await db
|
||||
.select({ id: growEvents.id, processingStatus: growEvents.processingStatus })
|
||||
.from(growEvents)
|
||||
.where(eq(growEvents.dedupeKey, `mission-passive-review:${instanceId}:${date}`))
|
||||
.limit(1);
|
||||
return existing ?? null;
|
||||
}
|
||||
|
||||
export async function runPassiveMissionReviewForMission(input: {
|
||||
userId: string;
|
||||
mission: GrowActiveMission;
|
||||
snapshot?: MissionSnapshot | null;
|
||||
date?: string | Date;
|
||||
force?: boolean;
|
||||
}) {
|
||||
const date = passiveReviewDate(input.date);
|
||||
const existing = input.force ? null : await passiveReviewAlreadyRan(input.mission.instanceId, date);
|
||||
if (existing) {
|
||||
return { status: "skipped" as const, reason: "already_ran" as const, eventId: existing.id, mission: input.mission };
|
||||
}
|
||||
if (!input.mission.actorType) {
|
||||
return { status: "skipped" as const, reason: "missing_actor" as const, mission: input.mission };
|
||||
}
|
||||
|
||||
const dedupeKey = input.force
|
||||
? `mission-passive-review:${input.mission.instanceId}:${date}:${Date.now()}`
|
||||
: `mission-passive-review:${input.mission.instanceId}:${date}`;
|
||||
const event = await recordGrowEvent({
|
||||
source: PASSIVE_REVIEW_SOURCE,
|
||||
type: "mission.passive_review.completed",
|
||||
category: "mission",
|
||||
userId: input.userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
mission: { instanceId: input.mission.instanceId, missionId: input.mission.missionId, stageId: input.mission.currentStageId },
|
||||
payload: {
|
||||
reviewDate: date,
|
||||
status: "started",
|
||||
},
|
||||
dedupeKey,
|
||||
}, { userId: input.userId, source: PASSIVE_REVIEW_SOURCE });
|
||||
await markGrowEventProcessing(event.id);
|
||||
|
||||
try {
|
||||
const actor = missionActorFor(input.userId, input.mission.instanceId, input.mission.actorType);
|
||||
const scrum = await actor.runDailyScrum({ trigger: "nightly" });
|
||||
const snapshot = scrum.snapshot ?? input.snapshot;
|
||||
if (!snapshot) {
|
||||
await markGrowEventFailed(event.id, new Error("mission_passive_review_missing_snapshot"));
|
||||
return { status: "skipped" as const, reason: "missing_snapshot" as const, eventId: event.id, mission: input.mission };
|
||||
}
|
||||
|
||||
const activeMission = activeMissionFromSnapshot(snapshot);
|
||||
await upsertActiveMissionPg(input.userId, activeMission, snapshot);
|
||||
const windowEnd = new Date(`${date}T23:59:59.999Z`);
|
||||
const windowStart = new Date(`${date}T00:00:00.000Z`);
|
||||
const run = await createMissionCoachRunPg({
|
||||
userId: input.userId,
|
||||
missionInstanceId: activeMission.instanceId,
|
||||
missionId: activeMission.missionId,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
skillVersion: snapshot.skillVersion,
|
||||
inputDigest: {
|
||||
passive: true,
|
||||
reviewDate: date,
|
||||
trigger: "nightly",
|
||||
stageCount: snapshot.stages.length,
|
||||
currentStageId: snapshot.currentStageId,
|
||||
progressPercent: snapshot.progressPercent,
|
||||
artifactCount: snapshot.artifacts.length,
|
||||
eventCount: snapshot.events.length,
|
||||
},
|
||||
});
|
||||
|
||||
const snapshotContext = asRecord(snapshot.input?.context);
|
||||
const suggestions = await replaceMissionSuggestionsPg({
|
||||
userId: input.userId,
|
||||
missionInstanceId: activeMission.instanceId,
|
||||
missionId: activeMission.missionId,
|
||||
coachRunId: run.id,
|
||||
suggestions: buildDeterministicMissionSuggestions(snapshot, { preferences: asRecord(snapshotContext.preferences) }),
|
||||
});
|
||||
const summary = suggestions[0]
|
||||
? `Passive mission review refreshed ${suggestions.length} suggestion${suggestions.length === 1 ? "" : "s"}. Top action: ${suggestions[0].title}`
|
||||
: "Passive mission review found no open action.";
|
||||
await completeMissionCoachRunPg({ id: run.id, summary, output: { suggestions, passive: true, reviewDate: date } });
|
||||
|
||||
await db.update(growEvents).set({
|
||||
mission: { instanceId: activeMission.instanceId, missionId: activeMission.missionId, stageId: activeMission.currentStageId },
|
||||
payload: {
|
||||
reviewDate: date,
|
||||
status: "completed",
|
||||
coachRunId: run.id,
|
||||
suggestionIds: suggestions.map((item) => item.id),
|
||||
summary,
|
||||
},
|
||||
}).where(eq(growEvents.id, event.id));
|
||||
await markGrowEventProcessed(event.id);
|
||||
|
||||
return { status: "reviewed" as const, eventId: event.id, coachRunId: run.id, mission: activeMission, suggestionCount: suggestions.length, summary };
|
||||
} catch (err) {
|
||||
await markGrowEventFailed(event.id, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPassiveMissionReviews(input: { userId?: string; date?: string | Date; force?: boolean; limit?: number } = {}) {
|
||||
const rows = await listActiveMissionsForPassiveReviewPg({ userId: input.userId, limit: input.limit });
|
||||
const results = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
results.push(await runPassiveMissionReviewForMission({
|
||||
userId: row.userId,
|
||||
mission: row.mission,
|
||||
snapshot: row.snapshot,
|
||||
date: input.date,
|
||||
force: input.force,
|
||||
}));
|
||||
} catch (err) {
|
||||
log.error({ err, userId: row.userId, missionInstanceId: row.mission.instanceId }, "passive mission review failed");
|
||||
results.push({ status: "failed" as const, mission: row.mission, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
return {
|
||||
date: passiveReviewDate(input.date),
|
||||
reviewed: results.filter((item) => item.status === "reviewed").length,
|
||||
skipped: results.filter((item) => item.status === "skipped").length,
|
||||
failed: results.filter((item) => item.status === "failed").length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
42
src/missions/passive-runner.ts
Normal file
42
src/missions/passive-runner.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { config } from "../config.js";
|
||||
import { log } from "../log.js";
|
||||
import { runPassiveMissionReviews } from "./lifecycle.js";
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let running = false;
|
||||
|
||||
async function runOnce() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
const result = await runPassiveMissionReviews({ limit: config.missionPassiveLoopBatchSize });
|
||||
if (result.reviewed || result.failed) {
|
||||
log.info({
|
||||
reviewed: result.reviewed,
|
||||
skipped: result.skipped,
|
||||
failed: result.failed,
|
||||
date: result.date,
|
||||
}, "passive mission review loop completed");
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err }, "passive mission review loop failed");
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startPassiveMissionReviewLoop() {
|
||||
if (!config.missionPassiveLoopEnabled) {
|
||||
log.info("passive mission review loop disabled");
|
||||
return;
|
||||
}
|
||||
if (timer) return;
|
||||
|
||||
const intervalMs = Math.max(5 * 60 * 1000, config.missionPassiveLoopIntervalMs);
|
||||
const firstDelayMs = Math.min(60_000, intervalMs);
|
||||
const first = setTimeout(() => void runOnce(), firstDelayMs);
|
||||
first.unref?.();
|
||||
timer = setInterval(() => void runOnce(), intervalMs);
|
||||
timer.unref?.();
|
||||
log.info({ intervalMs, batchSize: config.missionPassiveLoopBatchSize }, "passive mission review loop scheduled");
|
||||
}
|
||||
124
src/missions/personal-brand-opportunity-engine/reducer.ts
Normal file
124
src/missions/personal-brand-opportunity-engine/reducer.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractResumeSignals,
|
||||
extractWeakAreas,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionDetailHref,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
|
||||
export const personalBrandOpportunityReducer: MissionReducer = {
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
accepts(ctx) {
|
||||
return ctx.activeMission.missionId === "personal-brand-opportunity-engine" &&
|
||||
(missionExplicitlyMatches(ctx.event.mission, "personal-brand-opportunity-engine") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
|
||||
},
|
||||
reduce(ctx): MissionReduction {
|
||||
const { event, activeMission } = ctx;
|
||||
const type = event.type;
|
||||
const payload = event.payload ?? {};
|
||||
const stagePatches: MissionStagePatch[] = [];
|
||||
const artifacts: MissionReduction["artifacts"] = [];
|
||||
const actions: MissionReduction["actions"] = [];
|
||||
let eventMessage = ctx.insight.summary;
|
||||
|
||||
if (type === "mission.started" || type.startsWith("mission.")) {
|
||||
actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", {
|
||||
stageId: "positioning",
|
||||
mode: "user_input_required",
|
||||
title: "Pick the audience you want to be known by",
|
||||
body: "Personal brand only works when the target audience and credibility theme are explicit.",
|
||||
prompt: "Who should notice you, and what do you want to be known for?",
|
||||
payload: { fields: ["target_audience", "positioning_theme", "proof_points"] },
|
||||
idempotencyKey: `${activeMission.instanceId}:brand-positioning`,
|
||||
priority: 96,
|
||||
urgency: "today",
|
||||
}));
|
||||
}
|
||||
|
||||
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
|
||||
const signals = extractResumeSignals(payload);
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Proof points extracted for positioning." });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice networking pitch next." });
|
||||
artifacts.push({ type: "positioning_statement", title: "Positioning statement draft", stageId: "resume", summary: signals[0] ?? "Strongest proof points were extracted from resume evidence.", metadata: { sourceEventId: event.id, signals } });
|
||||
actions.push(actionForAgent("personal-brand-opportunity-engine", "resume", {
|
||||
stageId: "resume",
|
||||
serviceId: "resume-service",
|
||||
toolName: "resume.create_version_prompt_draft",
|
||||
mode: "approval_required",
|
||||
title: "Draft your profile positioning statement?",
|
||||
body: "Approve a draft that turns your strongest resume proof into a clear LinkedIn/profile positioning statement.",
|
||||
payload: { signals, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:brand-positioning-draft:${event.id}`,
|
||||
priority: 92,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passiveResumeAnalysisInterviewPractice({
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "interview",
|
||||
priority: 90,
|
||||
}));
|
||||
eventMessage = "Resume proof points created a profile positioning action.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
const passive = passiveRoleplayFeedbackStoryBank({
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "roleplay",
|
||||
priority: 92,
|
||||
});
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Networking pitch reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Brand voice/readiness signals updated." });
|
||||
artifacts.push({ type: "networking_scripts", title: "Networking script improvements", stageId: "roleplay", summary: weakAreas.length ? `Improve: ${weakAreas.join(", ")}` : "Networking pitch practice completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
artifacts.push(passive.artifact);
|
||||
actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", {
|
||||
stageId: "positioning",
|
||||
mode: "suggestion",
|
||||
title: "Turn this pitch into weekly content pillars",
|
||||
body: "Use the networking practice feedback to draft 3 credibility themes for weekly posts.",
|
||||
payload: { weakAreas, href: missionDetailHref(activeMission.instanceId) },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:content-pillars:${event.id}`,
|
||||
priority: 82,
|
||||
urgency: "soon",
|
||||
}));
|
||||
actions.push(passive.action);
|
||||
eventMessage = "Networking pitch review created brand content next steps.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Credibility signals mined from interview review." });
|
||||
artifacts.push({ type: "credibility_signal_map", title: "Credibility signal map", stageId: "interview", summary: weakAreas.length ? `Recurring gaps/themes: ${weakAreas.join(", ")}` : "Interview review mined for positioning signals.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
actions.push(passiveInterviewFeedbackResumeUpgrade({
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "resume",
|
||||
priority: 98,
|
||||
}));
|
||||
eventMessage = "Interview feedback was mined for brand positioning signals.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Brand growth/readiness signals updated." });
|
||||
return { stagePatches, artifacts, actions, eventMessage };
|
||||
},
|
||||
};
|
||||
126
src/missions/promotion-readiness/reducer.ts
Normal file
126
src/missions/promotion-readiness/reducer.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractResumeSignals,
|
||||
extractWeakAreas,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
|
||||
export const promotionReadinessReducer: MissionReducer = {
|
||||
missionId: "promotion-readiness",
|
||||
accepts(ctx) {
|
||||
return ctx.activeMission.missionId === "promotion-readiness" &&
|
||||
(missionExplicitlyMatches(ctx.event.mission, "promotion-readiness") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
|
||||
},
|
||||
reduce(ctx): MissionReduction {
|
||||
const { event, activeMission } = ctx;
|
||||
const type = event.type;
|
||||
const payload = event.payload ?? {};
|
||||
const stagePatches: MissionStagePatch[] = [];
|
||||
const artifacts: MissionReduction["artifacts"] = [];
|
||||
const actions: MissionReduction["actions"] = [];
|
||||
let eventMessage = ctx.insight.summary;
|
||||
|
||||
if (type === "mission.started" || type.startsWith("mission.")) {
|
||||
actions.push(actionForAgent("promotion-readiness", "planner", {
|
||||
stageId: "promotion-context",
|
||||
mode: "user_input_required",
|
||||
title: "Clarify your promotion target",
|
||||
body: "Promotion readiness needs the desired level, timeline, manager context, and stakeholder map.",
|
||||
prompt: "What role/level are you targeting, by when, and what does your manager care about most?",
|
||||
payload: { fields: ["current_role", "desired_role", "timeline", "manager_context", "stakeholders"] },
|
||||
idempotencyKey: `${activeMission.instanceId}:promotion-context`,
|
||||
priority: 100,
|
||||
urgency: "today",
|
||||
}));
|
||||
}
|
||||
|
||||
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
|
||||
const signals = extractResumeSignals(payload);
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Promotion evidence extracted from achievement history." });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Manager conversation practice is ready." });
|
||||
artifacts.push({ type: "promotion_evidence_packet", title: "Promotion evidence packet", stageId: "resume", summary: signals[0] ?? "Achievement evidence extracted for promotion case.", metadata: { sourceEventId: event.id, signals } });
|
||||
actions.push(actionForAgent("promotion-readiness", "roleplay", {
|
||||
stageId: "roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
toolName: "roleplay.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Practice the manager promotion conversation",
|
||||
body: "Use your achievement evidence in a realistic manager conversation drill.",
|
||||
payload: { signals, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:promotion-manager-roleplay:${event.id}`,
|
||||
priority: 94,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passiveResumeAnalysisInterviewPractice({
|
||||
missionId: "promotion-readiness",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "interview",
|
||||
priority: 91,
|
||||
}));
|
||||
eventMessage = "Promotion evidence packet is ready; manager conversation practice is next.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
const passive = passiveRoleplayFeedbackStoryBank({
|
||||
missionId: "promotion-readiness",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "roleplay",
|
||||
priority: 95,
|
||||
});
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Manager conversation drill reviewed." });
|
||||
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Practice leadership narratives next if gaps remain." });
|
||||
artifacts.push({ type: "manager_conversation_script", title: "Manager conversation script", stageId: "roleplay", summary: weakAreas.length ? `Follow-up focus: ${weakAreas.join(", ")}` : "Manager conversation review completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
artifacts.push(passive.artifact);
|
||||
actions.push(actionForAgent("promotion-readiness", "interview", {
|
||||
stageId: "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Practice leadership stories",
|
||||
body: "Run a leadership-style mock interview to tighten the promotion case.",
|
||||
payload: { weakAreas, href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:promotion-leadership-interview:${event.id}`,
|
||||
priority: 86,
|
||||
urgency: "soon",
|
||||
}));
|
||||
actions.push(passive.action);
|
||||
eventMessage = "Manager conversation review updated promotion readiness.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Leadership communication gap check completed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Leadership readiness signals updated." });
|
||||
artifacts.push({ type: "leadership_gap_map", title: "Leadership gap map", stageId: "interview", summary: weakAreas.length ? weakAreas.join(", ") : "Leadership practice review completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
actions.push(passiveInterviewFeedbackResumeUpgrade({
|
||||
missionId: "promotion-readiness",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "resume",
|
||||
priority: 102,
|
||||
}));
|
||||
eventMessage = "Leadership practice review updated the promotion gap map.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Promotion readiness signals updated." });
|
||||
return { stagePatches, artifacts, actions, eventMessage };
|
||||
},
|
||||
};
|
||||
341
src/missions/reducer-helpers.ts
Normal file
341
src/missions/reducer-helpers.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { asRecord, getNumber, getString } from "../events/envelope.js";
|
||||
import { buildServiceLink } from "../services/service-registry.js";
|
||||
import type { GrowActiveMission } from "../actors/missions/types.js";
|
||||
import type { MissionActionPatch, MissionArtifactPatch } from "./reducer-types.js";
|
||||
|
||||
export function isResumeEvent(source: string, type: string) {
|
||||
const value = source.toLowerCase();
|
||||
return value.includes("resume") || type.startsWith("resume.");
|
||||
}
|
||||
|
||||
export function isInterviewEvent(source: string, type: string) {
|
||||
const value = source.toLowerCase();
|
||||
return value.includes("interview") || type.startsWith("interview.");
|
||||
}
|
||||
|
||||
export function isRoleplayEvent(source: string, type: string) {
|
||||
const value = source.toLowerCase();
|
||||
return value.includes("roleplay") || type.startsWith("roleplay.");
|
||||
}
|
||||
|
||||
export function isQscoreEvent(source: string, type: string) {
|
||||
const value = source.toLowerCase();
|
||||
return value.includes("qscore") || type.startsWith("qscore.");
|
||||
}
|
||||
|
||||
export function isFeedbackEvent(type: string) {
|
||||
return type.includes("review_completed") || type.includes("review.completed") || type.includes("feedback.generated");
|
||||
}
|
||||
|
||||
export function reviewRecord(payload: Record<string, unknown>) {
|
||||
return asRecord(payload.review ?? payload.result ?? payload.data ?? payload);
|
||||
}
|
||||
|
||||
export function extractOverallScore(payload: Record<string, unknown>) {
|
||||
const review = reviewRecord(payload);
|
||||
return getNumber(review.overall_score ?? review.overallScore ?? review.score ?? payload.overall_score);
|
||||
}
|
||||
|
||||
export function extractWeakAreas(payload: Record<string, unknown>): string[] {
|
||||
const review = reviewRecord(payload);
|
||||
const candidates = [
|
||||
review.weak_areas,
|
||||
review.weakAreas,
|
||||
review.improvement_areas,
|
||||
review.improvementAreas,
|
||||
review.recommendations,
|
||||
review.gaps,
|
||||
];
|
||||
const areas: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (Array.isArray(candidate)) {
|
||||
for (const item of candidate) {
|
||||
if (typeof item === "string" && item.trim()) areas.push(item.trim());
|
||||
else if (item && typeof item === "object" && !Array.isArray(item)) {
|
||||
const row = item as Record<string, unknown>;
|
||||
const text = getString(row.title ?? row.area ?? row.name ?? row.label ?? row.summary);
|
||||
if (text) areas.push(text);
|
||||
}
|
||||
}
|
||||
} else if (typeof candidate === "string" && candidate.trim()) {
|
||||
areas.push(...candidate.split(/[;,]/).map((part) => part.trim()).filter(Boolean));
|
||||
}
|
||||
}
|
||||
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
|
||||
if (!areas.length && summary) {
|
||||
const lower = summary.toLowerCase();
|
||||
if (lower.includes("communication") || lower.includes("clarity") || lower.includes("story")) areas.push("communication clarity");
|
||||
if (lower.includes("technical") || lower.includes("role")) areas.push("role-fit depth");
|
||||
if (lower.includes("confidence") || lower.includes("concise")) areas.push("confidence and concision");
|
||||
}
|
||||
return Array.from(new Set(areas)).slice(0, 5);
|
||||
}
|
||||
|
||||
function extractStringListFromKeys(record: Record<string, unknown>, keys: string[]) {
|
||||
const values: string[] = [];
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item === "string" && item.trim()) values.push(item.trim());
|
||||
else if (item && typeof item === "object" && !Array.isArray(item)) {
|
||||
const row = item as Record<string, unknown>;
|
||||
const text = getString(row.title ?? row.name ?? row.label ?? row.summary ?? row.description ?? row.text);
|
||||
if (text) values.push(text);
|
||||
}
|
||||
}
|
||||
} else if (typeof value === "string" && value.trim()) {
|
||||
values.push(...value.split(/[;\n]/).map((part) => part.trim()).filter(Boolean));
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(values)).slice(0, 8);
|
||||
}
|
||||
|
||||
export function extractMissingProof(payload: Record<string, unknown>): string[] {
|
||||
const review = reviewRecord(payload);
|
||||
return extractStringListFromKeys(review, [
|
||||
"missing_proof",
|
||||
"missingProof",
|
||||
"proof_gaps",
|
||||
"proofGaps",
|
||||
"evidence_gaps",
|
||||
"evidenceGaps",
|
||||
"missing_evidence",
|
||||
"missingEvidence",
|
||||
"gaps",
|
||||
]);
|
||||
}
|
||||
|
||||
export function extractStoryIssues(payload: Record<string, unknown>): string[] {
|
||||
const review = reviewRecord(payload);
|
||||
const values = extractStringListFromKeys(review, [
|
||||
"story_issues",
|
||||
"storyIssues",
|
||||
"story_gaps",
|
||||
"storyGaps",
|
||||
"star_gaps",
|
||||
"starGaps",
|
||||
"communication_gaps",
|
||||
"communicationGaps",
|
||||
"recommendations",
|
||||
]);
|
||||
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
|
||||
if (summary) {
|
||||
const lower = summary.toLowerCase();
|
||||
if (lower.includes("star") || lower.includes("story")) values.push("tighten STAR story structure");
|
||||
if (lower.includes("metric") || lower.includes("impact") || lower.includes("measurable")) values.push("add measurable impact proof");
|
||||
if (lower.includes("ownership")) values.push("clarify ownership and scope");
|
||||
}
|
||||
return Array.from(new Set(values)).slice(0, 8);
|
||||
}
|
||||
|
||||
export function extractResumeSignals(payload: Record<string, unknown>): string[] {
|
||||
const analysis = asRecord(payload.analysis ?? payload.result ?? payload.data ?? payload);
|
||||
const signals: string[] = [];
|
||||
const summary = getString(analysis.summary ?? analysis.overall_feedback ?? payload.summary);
|
||||
if (summary) signals.push(summary);
|
||||
for (const key of ["strengths", "gaps", "recommendations", "missing_keywords", "keyword_gaps"]) {
|
||||
const value = analysis[key];
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value.slice(0, 4)) if (typeof item === "string") signals.push(item);
|
||||
}
|
||||
}
|
||||
return signals.slice(0, 8);
|
||||
}
|
||||
|
||||
export function extractResumeProofPoints(payload: Record<string, unknown>) {
|
||||
const analysis = asRecord(payload.analysis ?? payload.result ?? payload.data ?? payload);
|
||||
const strengths = extractStringListFromKeys(analysis, ["strengths", "top_strengths", "strong_projects", "projects", "achievements"]);
|
||||
const gaps = extractStringListFromKeys(analysis, ["gaps", "recommendations", "missing_keywords", "keyword_gaps", "weak_bullets"]);
|
||||
const keywords = extractStringListFromKeys(analysis, ["keywords", "matched_keywords", "missing_keywords", "keyword_gaps"]);
|
||||
return { strengths, gaps, keywords };
|
||||
}
|
||||
|
||||
export function missionExplicitlyMatches(eventMission: unknown, missionId: string) {
|
||||
const mission = asRecord(eventMission);
|
||||
const explicit = getString(mission.missionId ?? mission.mission_id);
|
||||
return explicit === missionId;
|
||||
}
|
||||
|
||||
export function isRelevantServiceEvent(source: string, type: string) {
|
||||
return isResumeEvent(source, type) || isInterviewEvent(source, type) || isRoleplayEvent(source, type) || isQscoreEvent(source, type);
|
||||
}
|
||||
|
||||
const AGENT_NAMES: Record<string, Record<string, { agentId: string; baseAgent: string; agentName: string }>> = {
|
||||
"interview-to-offer": {
|
||||
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Offer Strategist" },
|
||||
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Resume Fit Agent" },
|
||||
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Mock Interviewer" },
|
||||
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Communication Coach" },
|
||||
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Readiness Analyst" },
|
||||
},
|
||||
"career-transition": {
|
||||
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Transition Strategist" },
|
||||
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Transferable Skills Agent" },
|
||||
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Adjacent Role Interviewer" },
|
||||
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Transition Pitch Coach" },
|
||||
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Transition Readiness Analyst" },
|
||||
},
|
||||
"salary-negotiation-war-room": {
|
||||
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Negotiation Strategist" },
|
||||
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Value Evidence Agent" },
|
||||
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Confidence Coach" },
|
||||
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Negotiation Drill Coach" },
|
||||
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Confidence Analyst" },
|
||||
},
|
||||
"promotion-readiness": {
|
||||
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Promotion Strategist" },
|
||||
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Achievement Evidence Agent" },
|
||||
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Leadership Interview Coach" },
|
||||
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Manager Conversation Coach" },
|
||||
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Leadership Readiness Analyst" },
|
||||
},
|
||||
"personal-brand-opportunity-engine": {
|
||||
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Brand Strategist" },
|
||||
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Proof Point Agent" },
|
||||
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Credibility Coach" },
|
||||
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Networking Pitch Coach" },
|
||||
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Visibility Analyst" },
|
||||
},
|
||||
};
|
||||
|
||||
export function actionForAgent(missionId: string, agent: "planner" | "resume" | "interview" | "roleplay" | "qscore", patch: Omit<MissionActionPatch, "agentId" | "agentName" | "baseAgent">): MissionActionPatch {
|
||||
const fallback = AGENT_NAMES["interview-to-offer"]?.[agent] ?? { agentId: agent, baseAgent: agent, agentName: agent };
|
||||
const spec = AGENT_NAMES[missionId]?.[agent] ?? fallback;
|
||||
return { ...spec, ...patch };
|
||||
}
|
||||
|
||||
export function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", missionInstanceId: string, missionId: string, stageId?: string) {
|
||||
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) {
|
||||
return `/missions/${encodeURIComponent(missionInstanceId)}`;
|
||||
}
|
||||
|
||||
export function passiveResumeAnalysisInterviewPractice(input: {
|
||||
missionId: string;
|
||||
activeMission: GrowActiveMission;
|
||||
eventId: string;
|
||||
payload: Record<string, unknown>;
|
||||
stageId?: string;
|
||||
priority?: number;
|
||||
}): MissionActionPatch {
|
||||
const signals = extractResumeSignals(input.payload);
|
||||
const proofPoints = extractResumeProofPoints(input.payload);
|
||||
return actionForAgent(input.missionId, "interview", {
|
||||
stageId: input.stageId ?? "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Practice explaining your strongest resume proof",
|
||||
body: proofPoints.strengths.length
|
||||
? `Run a mock focused on ${proofPoints.strengths.slice(0, 2).join(" and ")} so your resume turns into interview-ready stories.`
|
||||
: "Run a resume-led mock interview so your strongest proof turns into interview-ready stories.",
|
||||
payload: {
|
||||
passiveAction: "resume_analysis_to_interview_practice",
|
||||
resumeSignals: signals,
|
||||
proofPoints,
|
||||
prompt: proofPoints.strengths[0]
|
||||
? `Practice explaining ${proofPoints.strengths[0]} with clear ownership, impact, and tradeoffs.`
|
||||
: "Practice explaining your strongest resume project with clear ownership, impact, and tradeoffs.",
|
||||
href: serviceHref("interview", input.activeMission.instanceId, input.activeMission.missionId, input.stageId ?? "interview"),
|
||||
},
|
||||
sourceEventId: input.eventId,
|
||||
idempotencyKey: `${input.activeMission.instanceId}:resume-analysis:proof-interview:${input.eventId}`,
|
||||
priority: input.priority ?? 98,
|
||||
urgency: "today",
|
||||
});
|
||||
}
|
||||
|
||||
export function passiveInterviewFeedbackResumeUpgrade(input: {
|
||||
missionId: string;
|
||||
activeMission: GrowActiveMission;
|
||||
eventId: string;
|
||||
payload: Record<string, unknown>;
|
||||
stageId?: string;
|
||||
priority?: number;
|
||||
}): MissionActionPatch {
|
||||
const weakAreas = extractWeakAreas(input.payload);
|
||||
const missingProof = extractMissingProof(input.payload);
|
||||
const storyIssues = extractStoryIssues(input.payload);
|
||||
return actionForAgent(input.missionId, "resume", {
|
||||
stageId: input.stageId ?? "resume",
|
||||
serviceId: "resume-service",
|
||||
toolName: "resume.create_version_prompt_draft",
|
||||
mode: "approval_required",
|
||||
title: "Draft stronger resume bullets from interview feedback?",
|
||||
body: [...weakAreas, ...missingProof, ...storyIssues].length
|
||||
? `Approve a Resume Agent draft that fixes ${[...weakAreas, ...missingProof, ...storyIssues].slice(0, 3).join(", ")} with stronger bullets and proof.`
|
||||
: "Approve a Resume Agent draft that turns the interview feedback into stronger bullets and talking points.",
|
||||
prompt: "Create a resume upgrade draft from this interview feedback. Focus on measurable impact, ownership, missing proof, and reusable talking points.",
|
||||
payload: {
|
||||
passiveAction: "interview_feedback_to_resume_upgrade",
|
||||
weakAreas,
|
||||
missingProof,
|
||||
storyIssues,
|
||||
sourceReviewEventId: input.eventId,
|
||||
draftInstructions: [
|
||||
"Rewrite weak bullets with action, scope, metric, and result.",
|
||||
"Add proof for any interview gaps that lacked evidence.",
|
||||
"Create talking points that match the feedback themes.",
|
||||
],
|
||||
href: serviceHref("resume", input.activeMission.instanceId, input.activeMission.missionId, input.stageId ?? "resume"),
|
||||
},
|
||||
sourceEventId: input.eventId,
|
||||
idempotencyKey: `${input.activeMission.instanceId}:interview-review:tailor-resume:${input.eventId}`,
|
||||
priority: input.priority ?? 108,
|
||||
urgency: "now",
|
||||
});
|
||||
}
|
||||
|
||||
export function passiveRoleplayFeedbackStoryBank(input: {
|
||||
missionId: string;
|
||||
activeMission: GrowActiveMission;
|
||||
eventId: string;
|
||||
payload: Record<string, unknown>;
|
||||
stageId?: string;
|
||||
priority?: number;
|
||||
}): { artifact: MissionArtifactPatch; action: MissionActionPatch } {
|
||||
const weakAreas = extractWeakAreas(input.payload);
|
||||
const storyIssues = extractStoryIssues(input.payload);
|
||||
return {
|
||||
artifact: {
|
||||
type: "story_bank_update",
|
||||
title: "Story bank updates from roleplay feedback",
|
||||
stageId: input.stageId ?? "roleplay",
|
||||
summary: [...weakAreas, ...storyIssues].length
|
||||
? `Turn these into reusable STAR stories: ${[...weakAreas, ...storyIssues].slice(0, 5).join(", ")}`
|
||||
: "Roleplay feedback captured story bank improvements for future interviews.",
|
||||
metadata: { sourceEventId: input.eventId, weakAreas, storyIssues, payload: input.payload },
|
||||
},
|
||||
action: actionForAgent(input.missionId, "interview", {
|
||||
stageId: "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Run a story-bank recovery mock",
|
||||
body: [...weakAreas, ...storyIssues].length
|
||||
? `Practice the communication gaps from roleplay: ${[...weakAreas, ...storyIssues].slice(0, 3).join(", ")}.`
|
||||
: "Run a targeted mock interview that converts roleplay feedback into reusable STAR stories.",
|
||||
payload: {
|
||||
passiveAction: "roleplay_feedback_to_communication_drill",
|
||||
weakAreas,
|
||||
storyIssues,
|
||||
storyBankInstructions: [
|
||||
"Convert each weak communication moment into a STAR story prompt.",
|
||||
"Practice the answer in an interview setting.",
|
||||
"Save the strongest version as reusable story-bank material.",
|
||||
],
|
||||
href: serviceHref("interview", input.activeMission.instanceId, input.activeMission.missionId, "interview"),
|
||||
},
|
||||
sourceEventId: input.eventId,
|
||||
idempotencyKey: `${input.activeMission.instanceId}:roleplay-review:story-bank-interview:${input.eventId}`,
|
||||
priority: input.priority ?? 96,
|
||||
urgency: "today",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { GrowEventRow } from "../db/schema.js";
|
||||
import type { ProjectionInsight } from "../events/projectors/projection-agent.js";
|
||||
import type { QscoreSignal } from "../events/envelope.js";
|
||||
import type { GrowActiveMission, MissionStageStatus } from "../actors/missions/types.js";
|
||||
import type { MissionActionMode, MissionActionStatus, MissionActionUrgency } from "./action-types.js";
|
||||
|
||||
export type MissionReducerContext = {
|
||||
userId: string;
|
||||
@@ -27,9 +28,30 @@ export type MissionStagePatch = {
|
||||
outputSummary?: string;
|
||||
};
|
||||
|
||||
export type MissionActionPatch = {
|
||||
stageId?: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
baseAgent?: string;
|
||||
serviceId?: string;
|
||||
toolName?: string;
|
||||
mode: MissionActionMode;
|
||||
status?: MissionActionStatus;
|
||||
title: string;
|
||||
body: string;
|
||||
prompt?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
sourceEventId?: string;
|
||||
idempotencyKey?: string;
|
||||
priority?: number;
|
||||
urgency?: MissionActionUrgency;
|
||||
dueAt?: string;
|
||||
};
|
||||
|
||||
export type MissionReduction = {
|
||||
stagePatches: MissionStagePatch[];
|
||||
artifacts: MissionArtifactPatch[];
|
||||
actions: MissionActionPatch[];
|
||||
eventMessage?: string;
|
||||
};
|
||||
|
||||
|
||||
128
src/missions/salary-negotiation-war-room/reducer.ts
Normal file
128
src/missions/salary-negotiation-war-room/reducer.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractResumeSignals,
|
||||
extractWeakAreas,
|
||||
isFeedbackEvent,
|
||||
isInterviewEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionExplicitlyMatches,
|
||||
passiveInterviewFeedbackResumeUpgrade,
|
||||
passiveResumeAnalysisInterviewPractice,
|
||||
passiveRoleplayFeedbackStoryBank,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
|
||||
export const salaryNegotiationReducer: MissionReducer = {
|
||||
missionId: "salary-negotiation-war-room",
|
||||
accepts(ctx) {
|
||||
return ctx.activeMission.missionId === "salary-negotiation-war-room" &&
|
||||
(missionExplicitlyMatches(ctx.event.mission, "salary-negotiation-war-room") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
|
||||
},
|
||||
reduce(ctx): MissionReduction {
|
||||
const { event, activeMission } = ctx;
|
||||
const type = event.type;
|
||||
const payload = event.payload ?? {};
|
||||
const stagePatches: MissionStagePatch[] = [];
|
||||
const artifacts: MissionReduction["artifacts"] = [];
|
||||
const actions: MissionReduction["actions"] = [];
|
||||
let eventMessage = ctx.insight.summary;
|
||||
|
||||
if (type === "mission.started" || type.startsWith("mission.")) {
|
||||
actions.push(actionForAgent("salary-negotiation-war-room", "planner", {
|
||||
stageId: "offer-context",
|
||||
mode: "user_input_required",
|
||||
title: "Add your offer and negotiation constraints",
|
||||
body: "The war room needs your current offer, target range, deadline, and leverage before scripts or drills are accurate.",
|
||||
prompt: "What is the current offer/raise, your target, deadline, and any competing leverage?",
|
||||
payload: { fields: ["current_offer", "target_range", "deadline", "competing_offers", "constraints"] },
|
||||
idempotencyKey: `${activeMission.instanceId}:offer-context`,
|
||||
priority: 100,
|
||||
urgency: "now",
|
||||
}));
|
||||
}
|
||||
|
||||
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
|
||||
const signals = extractResumeSignals(payload);
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Value evidence extracted from resume proof." });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Use value evidence in negotiation roleplay." });
|
||||
artifacts.push({ type: "value_evidence_map", title: "Value evidence map", stageId: "resume", summary: signals[0] ?? "Impact proof extracted for negotiation leverage.", metadata: { sourceEventId: event.id, signals } });
|
||||
actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", {
|
||||
stageId: "roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
toolName: "roleplay.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Run the first counteroffer drill",
|
||||
body: "Practice anchoring your number and defending value with the evidence now extracted.",
|
||||
payload: { signals, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:value-evidence-roleplay:${event.id}`,
|
||||
priority: 96,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passiveResumeAnalysisInterviewPractice({
|
||||
missionId: "salary-negotiation-war-room",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "interview",
|
||||
priority: 88,
|
||||
}));
|
||||
eventMessage = "Value evidence is ready for negotiation practice.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("configured")) {
|
||||
stagePatches.push({ stageId: "roleplay", status: "in_progress", progressPercent: 45, outputSummary: "Negotiation drill configured." });
|
||||
eventMessage = "Negotiation drill started.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
const passive = passiveRoleplayFeedbackStoryBank({
|
||||
missionId: "salary-negotiation-war-room",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "roleplay",
|
||||
priority: 93,
|
||||
});
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Negotiation drill reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Confidence signals updated." });
|
||||
artifacts.push({ type: "negotiation_objection_map", title: "Objection handling map", stageId: "roleplay", summary: weakAreas.length ? `Practice objections: ${weakAreas.join(", ")}` : "Negotiation practice review completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
artifacts.push(passive.artifact);
|
||||
actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", {
|
||||
stageId: "roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
toolName: "roleplay.configure_practice",
|
||||
mode: "approval_required",
|
||||
title: "Run one more objection-handling drill?",
|
||||
body: weakAreas.length ? `Recommended focus: ${weakAreas.slice(0, 3).join(", ")}. Approve another drill before the live negotiation.` : "Approve one more objection-handling drill before the live negotiation.",
|
||||
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:negotiation-followup:${event.id}`,
|
||||
priority: 94,
|
||||
urgency: "today",
|
||||
}));
|
||||
actions.push(passive.action);
|
||||
eventMessage = "Negotiation drill review created the next objection-handling action.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && isFeedbackEvent(type)) {
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Communication confidence signal captured from interview review." });
|
||||
actions.push(passiveInterviewFeedbackResumeUpgrade({
|
||||
missionId: "salary-negotiation-war-room",
|
||||
activeMission,
|
||||
eventId: event.id,
|
||||
payload,
|
||||
stageId: "resume",
|
||||
priority: 99,
|
||||
}));
|
||||
eventMessage = "Interview feedback updated negotiation confidence signals.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Negotiation confidence signals updated." });
|
||||
return { stagePatches, artifacts, actions, eventMessage };
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MissionSnapshot, MissionStage } from "../actors/missions/types.js";
|
||||
import { missionDetailHref } from "./reducer-helpers.js";
|
||||
|
||||
export type MissionSuggestionType = "action" | "practice" | "review" | "artifact" | "blocked" | "insight";
|
||||
export type MissionSuggestionUrgency = "now" | "today" | "soon" | "calm";
|
||||
@@ -103,7 +104,7 @@ function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: Missio
|
||||
return { label: "Open resume", href: `/agents/resume?${params.toString()}` };
|
||||
}
|
||||
if (role === "Q Score") return { label: "View Q Score", href: `/agents/qscore?${params.toString()}` };
|
||||
return { label: "Continue", href: `/missions/active?${params.toString()}` };
|
||||
return { label: "Continue", href: `${missionDetailHref(snapshot.instanceId)}?${params.toString()}` };
|
||||
}
|
||||
|
||||
function suggestionId(snapshot: MissionSnapshot, stage: MissionStage, suffix: string) {
|
||||
|
||||
52
src/routes/analytics.ts
Normal file
52
src/routes/analytics.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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> {
|
||||
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
|
||||
}
|
||||
|
||||
export function analyticsRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.get("/platform", async (c) => {
|
||||
return c.json(await getClient().analyticsActor.getOrCreate(["platform"]).getPlatform());
|
||||
});
|
||||
|
||||
app.get("/user/qscore", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await getClient().analyticsActor.getOrCreate(["user", userId]).getUserQscore({ userId }));
|
||||
});
|
||||
|
||||
app.get("/user/activity", async (c) => {
|
||||
const userId = c.get("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;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_session",
|
||||
description: "Create a real interview practice session via the Interview Agent / interview-service microservice. Call this when the user asks to start or launch an interview.",
|
||||
description: "Create a real mock interview session via the interview-service microservice. Call this when the user asks to start or launch interview practice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -66,7 +66,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_roleplay_session",
|
||||
description: "Create a real roleplay session via Roleplay Agent / roleplay-service. Call when user asks for roleplay or negotiation practice.",
|
||||
description: "Create a real mock roleplay session via roleplay-service. Call when the user asks for roleplay or negotiation practice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -80,7 +80,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "analyze_resume",
|
||||
description: "Analyze user's resume using the Resume Agent. Returns completeness, skills, and gaps.",
|
||||
description: "Analyze the user's resume using Resume Building. Returns completeness, skills, and gaps.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -94,7 +94,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "compute_qscore",
|
||||
description: "Compute user's readiness Q-Score via Q Score Agent / qscore-service.",
|
||||
description: "Compute the user's readiness Q Score via qscore-service.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -174,14 +174,14 @@ export function chatRoutes() {
|
||||
switch (toolCall.name) {
|
||||
case "start_interview_session": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "interview", name: "Interview Agent", role: "Interview Agent", kind: "microservice", description: "Interview practice", service: "interview-service" },
|
||||
{ id: "interview", name: "Mock Interview", role: "Interview practice", kind: "microservice", description: "Interview practice", service: "interview-service" },
|
||||
{ userId, goal: String(toolCall.arguments.target_role ?? "general preparation") },
|
||||
);
|
||||
if (toolResult.status === "ok" && toolResult.detail) {
|
||||
const detail = toolResult.detail as Record<string, unknown>;
|
||||
sessions.push({
|
||||
moduleId: "interview",
|
||||
moduleName: "Interview Agent",
|
||||
moduleName: "Mock Interview",
|
||||
status: "done",
|
||||
sessionId: detail.session_id as string,
|
||||
sessionUrl: typeof detail.ui_session_url === "string"
|
||||
@@ -194,14 +194,14 @@ export function chatRoutes() {
|
||||
}
|
||||
case "start_roleplay_session": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "roleplay", name: "Roleplay Agent", role: "Roleplay Agent", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
|
||||
{ id: "roleplay", name: "Mock Roleplay", role: "Roleplay practice", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
|
||||
{ userId, goal: String(toolCall.arguments.goal ?? "general practice") },
|
||||
);
|
||||
if (toolResult.status === "ok" && toolResult.detail) {
|
||||
const detail = toolResult.detail as Record<string, unknown>;
|
||||
sessions.push({
|
||||
moduleId: "roleplay",
|
||||
moduleName: "Roleplay Agent",
|
||||
moduleName: "Mock Roleplay",
|
||||
status: "done",
|
||||
sessionId: detail.session_id as string,
|
||||
sessionUrl: typeof detail.ui_session_url === "string"
|
||||
|
||||
@@ -9,7 +9,8 @@ import { getConversationModel } from "../actors/conversation/agent.js";
|
||||
import { getMissionDefinition, isActorBackedMission, listMissionDefinitions } from "../missions/registry.js";
|
||||
import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js";
|
||||
import { getSubAgentModules } from "../lib/prompt-loader.js";
|
||||
import { addMessagePg, createConversationPg, ensureConversation, getConversationPg, listActiveMissionsPg, listConversationsPg, listMessagesPg, resetConversationPg, touchConversationPg, upsertActiveMissionPg } from "../grow/persistence.js";
|
||||
import { addMessagePg, createConversationPg, ensureConversation, ensureMissionConversationPg, getActiveMissionPg, getConversationMetadataPg, getConversationPg, listActiveMissionsPg, listConversationsPg, listMessagesPg, resetConversationPg, touchConversationPg, upsertActiveMissionPg } from "../grow/persistence.js";
|
||||
import { getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
@@ -101,6 +102,12 @@ function missionActorFor(userId: string, instanceId: string, actorType: MissionA
|
||||
}
|
||||
|
||||
const createConversationSchema = z.object({ title: z.string().optional() });
|
||||
const createMissionConversationSchema = z.object({
|
||||
missionInstanceId: z.string().min(1),
|
||||
stageId: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
});
|
||||
const streamSchema = z.object({
|
||||
messages: z.array(z.custom<UIMessage>()),
|
||||
conversationId: z.string().optional(),
|
||||
@@ -163,7 +170,27 @@ function forcedToolForPrompt(text: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildSystemPrompt() {
|
||||
async function buildMissionContextPrompt(userId: string, conversationId: string) {
|
||||
const metadata = await getConversationMetadataPg(userId, conversationId);
|
||||
const missionInstanceId = typeof metadata.missionInstanceId === "string" ? metadata.missionInstanceId : undefined;
|
||||
if (!missionInstanceId) return "";
|
||||
const active = await getActiveMissionPg(userId, missionInstanceId);
|
||||
if (!active) return "";
|
||||
const actions = await listMissionActions(userId, { missionInstanceId });
|
||||
return `\n\nCurrent mission context:
|
||||
- missionInstanceId: ${active.mission.instanceId}
|
||||
- missionId: ${active.mission.missionId}
|
||||
- title: ${active.mission.title}
|
||||
- status: ${active.mission.status}
|
||||
- progress: ${active.mission.progressPercent}%
|
||||
- currentStageId: ${active.mission.currentStageId ?? "none"}
|
||||
- goal: ${active.mission.goal ?? "none"}
|
||||
- openActions: ${actions.length}
|
||||
|
||||
Use this mission context when answering. If a service is needed, prepare a handoff/action instead of completing the service directly.`;
|
||||
}
|
||||
|
||||
function buildSystemPrompt(missionContext = "") {
|
||||
return `You are Grow — a friendly, normal career buddy inside GrowQR. Talk like a real person, not a coach or a robot.
|
||||
|
||||
Personality & Tone:
|
||||
@@ -182,7 +209,7 @@ How to help:
|
||||
- When the user asks about interview prep, roleplay, resume, or Q Score — just answer normally. Only route to a specialist tool if they explicitly say something like "connect me to the interview specialist" or "let me talk to the roleplay agent".
|
||||
- When the user asks to see missions or plans, call discoverWorkflows or showMissions. When they ask about memory, use the memory tools. Otherwise, just chat.
|
||||
- Only start a mission if the user clearly says yes to starting one. Don't push.
|
||||
- When you write memory, a quick "Saved." is enough. No need to over-confirm.`
|
||||
- When you write memory, a quick "Saved." is enough. No need to over-confirm.${missionContext}`
|
||||
}
|
||||
|
||||
function safeAgentRegistry() {
|
||||
@@ -443,6 +470,49 @@ function buildConversationTools(userId: string) {
|
||||
},
|
||||
}),
|
||||
|
||||
listMissionActions: tool({
|
||||
description: "List open mission queue actions/approvals/questions for the user. Use for: what should I do today, what are my agents doing, why is my mission blocked, show approvals.",
|
||||
inputSchema: z.object({ missionInstanceId: z.string().optional() }),
|
||||
execute: async ({ missionInstanceId }) => ({
|
||||
kind: "mission-actions",
|
||||
actions: await listMissionActions(userId, { missionInstanceId }),
|
||||
}),
|
||||
}),
|
||||
|
||||
approveMissionAction: tool({
|
||||
description: "Approve a mission action by id. Only use when the user explicitly asks to approve a specific action.",
|
||||
inputSchema: z.object({ actionId: z.string() }),
|
||||
execute: async ({ actionId }) => {
|
||||
const action = await getMissionAction(userId, actionId);
|
||||
if (!action) return { kind: "mission-action-approved", actionId, error: "Action not found" };
|
||||
return { kind: "mission-action-approved", action: await updateMissionActionStatus(userId, actionId, { status: "queued", result: { approvedAt: new Date().toISOString() } }) };
|
||||
},
|
||||
}),
|
||||
|
||||
rejectMissionAction: tool({
|
||||
description: "Reject/dismiss a mission action by id. Only use when the user explicitly asks to reject or dismiss a specific action.",
|
||||
inputSchema: z.object({ actionId: z.string() }),
|
||||
execute: async ({ actionId }) => ({
|
||||
kind: "mission-action-rejected",
|
||||
action: await updateMissionActionStatus(userId, actionId, { status: "dismissed", result: { rejectedAt: new Date().toISOString() } }),
|
||||
}),
|
||||
}),
|
||||
|
||||
explainMissionProgress: tool({
|
||||
description: "Explain active mission progress using snapshots and open actions.",
|
||||
inputSchema: z.object({ missionInstanceId: z.string().optional() }),
|
||||
execute: async ({ missionInstanceId }) => {
|
||||
const persisted = await listActiveMissionsPg(userId);
|
||||
const selected = missionInstanceId ? persisted.filter((item) => item.mission.instanceId === missionInstanceId) : persisted;
|
||||
return {
|
||||
kind: "mission-progress",
|
||||
missions: selected.map((item) => item.mission),
|
||||
snapshots: selected.map((item) => item.snapshot).filter(Boolean),
|
||||
actions: await listMissionActions(userId, { missionInstanceId }),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
listMemory: tool({
|
||||
description: "List memory files for this user by path prefix.",
|
||||
inputSchema: z.object({ prefix: z.string().optional() }),
|
||||
@@ -526,6 +596,28 @@ export function conversationRoutes() {
|
||||
return c.json({ conversation }, 201);
|
||||
});
|
||||
|
||||
app.post("/mission", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = createMissionConversationSchema.parse(await c.req.json().catch(() => ({})));
|
||||
const active = await getActiveMissionPg(userId, body.missionInstanceId);
|
||||
if (!active) return c.json({ error: "mission_not_found" }, 404);
|
||||
const conversation = await ensureMissionConversationPg({
|
||||
userId,
|
||||
missionInstanceId: active.mission.instanceId,
|
||||
missionId: active.mission.missionId,
|
||||
stageId: body.stageId,
|
||||
title: body.title ?? active.mission.shortTitle,
|
||||
source: body.source ?? "mission",
|
||||
});
|
||||
setupGrow(userId).then((grow) => grow.touchConversation({ conversationId: conversation.id, title: conversation.title })).catch((err) => console.warn("growActor mission conversation mirror failed", err));
|
||||
return c.json({
|
||||
conversation,
|
||||
mission: active.mission,
|
||||
snapshot: active.snapshot,
|
||||
messages: await listMessagesPg(userId, conversation.id),
|
||||
}, 201);
|
||||
});
|
||||
|
||||
app.get("/:conversationId", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const conversationId = c.req.param("conversationId");
|
||||
@@ -566,9 +658,10 @@ export function conversationRoutes() {
|
||||
}
|
||||
|
||||
const visualTool = forcedToolForPrompt(latestUserText);
|
||||
const missionContext = await buildMissionContextPrompt(userId, conversationId);
|
||||
const result = streamText({
|
||||
model: getConversationModel(),
|
||||
system: buildSystemPrompt(),
|
||||
system: buildSystemPrompt(missionContext),
|
||||
messages: await convertToModelMessages(body.messages),
|
||||
tools: buildConversationTools(userId),
|
||||
toolChoice: visualTool ? { type: "tool", toolName: visualTool } : "auto",
|
||||
|
||||
299
src/routes/daily-mission.ts
Normal file
299
src/routes/daily-mission.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { config } from "../config.js";
|
||||
import { log } from "../log.js";
|
||||
import {
|
||||
dailyMissionMessageSchema,
|
||||
dailyMissionTaskSchema,
|
||||
type DailyMissionResult,
|
||||
runDailyMissionAgent,
|
||||
streamDailyMissionAgent,
|
||||
} from "../agents/daily-mission.js";
|
||||
import type { Registry } from "../actors/registry.js";
|
||||
import type { MissionActorType, MissionSnapshot } from "../actors/missions/types.js";
|
||||
import { addMessagePg, ensureMissionConversationPg, getActiveMissionPg, listMessagesPg, upsertActiveMissionPg } from "../grow/persistence.js";
|
||||
|
||||
const chatSchema = z.object({
|
||||
task: dailyMissionTaskSchema,
|
||||
messages: z.array(dailyMissionMessageSchema).min(1).max(24),
|
||||
missionInstanceId: z.string().optional(),
|
||||
missionId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
conversationId: z.string().optional(),
|
||||
});
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
|
||||
}
|
||||
|
||||
function missionActorFor(userId: string, instanceId: string, actorType: MissionActorType) {
|
||||
const client = getClient();
|
||||
switch (actorType) {
|
||||
case "interviewToOfferMissionActor": return client.interviewToOfferMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "careerTransitionMissionActor": return client.careerTransitionMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "salaryNegotiationWarRoomMissionActor": return client.salaryNegotiationWarRoomMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "promotionReadinessMissionActor": return client.promotionReadinessMissionActor.getOrCreate([userId, instanceId]);
|
||||
case "personalBrandOpportunityEngineMissionActor": return client.personalBrandOpportunityEngineMissionActor.getOrCreate([userId, instanceId]);
|
||||
}
|
||||
}
|
||||
|
||||
function buildId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function latestUserText(messages: z.infer<typeof dailyMissionMessageSchema>[]) {
|
||||
return [...messages].reverse().find((message) => message.role === "user")?.content.trim();
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function sse(event: string, payload: Record<string, unknown>) {
|
||||
return encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
function sanitizeAssistantText(text: string) {
|
||||
return text
|
||||
.replace(/[\u2013\u2014]/g, ". ")
|
||||
.replace(/[\u2018\u2019]/g, "'")
|
||||
.replace(/[\u201C\u201D]/g, '"')
|
||||
.replace(/\u2026/g, "...")
|
||||
.replace(/^\s*(Perfect|Great|Absolutely|Sure)[.!,:;-]*\s*/i, "")
|
||||
.replace(/\s+\./g, ".")
|
||||
.replace(/\.{2,}/g, ".")
|
||||
.replace(/\s{2,}/g, " ");
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function visibleTextChunks(text: string) {
|
||||
const words = text.match(/\S+\s*/g) ?? [text];
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
|
||||
for (const word of words) {
|
||||
current += word;
|
||||
if (current.length >= 12 || /[.!?]\s*$/.test(current)) {
|
||||
chunks.push(current);
|
||||
current = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (current) chunks.push(current);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function enqueueVisibleText(controller: ReadableStreamDefaultController<Uint8Array>, text: string) {
|
||||
for (const chunk of visibleTextChunks(sanitizeAssistantText(text))) {
|
||||
controller.enqueue(sse("delta", { text: chunk }));
|
||||
await sleep(28);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyDailyMissionResult(input: {
|
||||
userId: string;
|
||||
body: z.infer<typeof chatSchema>;
|
||||
result: DailyMissionResult;
|
||||
}) {
|
||||
const { userId, body, result } = input;
|
||||
let conversationId = body.conversationId;
|
||||
let missionInstanceId = body.missionInstanceId;
|
||||
let responseStageId = body.stageId ?? body.task.questId;
|
||||
let snapshot: MissionSnapshot | null | undefined;
|
||||
|
||||
if (missionInstanceId) {
|
||||
const active = await getActiveMissionPg(userId, missionInstanceId);
|
||||
if (active) {
|
||||
const requestedStageId = body.stageId ?? body.task.questId;
|
||||
const stageId = active.snapshot?.stages.some((stage) => stage.id === requestedStageId)
|
||||
? requestedStageId
|
||||
: active.mission.currentStageId;
|
||||
responseStageId = stageId ?? responseStageId;
|
||||
|
||||
const conversation = await ensureMissionConversationPg({
|
||||
userId,
|
||||
missionInstanceId: active.mission.instanceId,
|
||||
missionId: active.mission.missionId,
|
||||
stageId,
|
||||
title: body.task.questTitle,
|
||||
source: "daily-mission",
|
||||
});
|
||||
log.info({
|
||||
userId,
|
||||
agent: "conversation-actor",
|
||||
conversationId: conversation.id,
|
||||
missionInstanceId: active.mission.instanceId,
|
||||
missionId: active.mission.missionId,
|
||||
stageId,
|
||||
service: body.task.service,
|
||||
}, "conversation actor linked to daily mission");
|
||||
conversationId = conversation.id;
|
||||
missionInstanceId = active.mission.instanceId;
|
||||
const conversationActor = getClient().conversationActor.getOrCreate([userId, conversation.id]);
|
||||
const latestUser = latestUserText(body.messages);
|
||||
if (latestUser) {
|
||||
const userMessage = {
|
||||
id: buildId("user"),
|
||||
conversationId: conversation.id,
|
||||
role: "user" as const,
|
||||
sender: "User",
|
||||
content: latestUser,
|
||||
};
|
||||
await addMessagePg(userId, userMessage);
|
||||
conversationActor.addMessage(userMessage).catch(() => undefined);
|
||||
}
|
||||
const assistantMessage = {
|
||||
id: buildId("assistant"),
|
||||
conversationId: conversation.id,
|
||||
role: "assistant" as const,
|
||||
sender: "Daily Mission",
|
||||
content: result.reply,
|
||||
};
|
||||
await addMessagePg(userId, assistantMessage);
|
||||
conversationActor.addMessage(assistantMessage).catch(() => undefined);
|
||||
|
||||
if (result.completed && active.mission.actorType && stageId) {
|
||||
snapshot = await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).updateStage({
|
||||
stageId,
|
||||
status: "done",
|
||||
progressPercent: 100,
|
||||
outputSummary: result.updateSummary,
|
||||
}).catch(() => active.snapshot ?? null);
|
||||
log.info({
|
||||
userId,
|
||||
agent: active.mission.actorType,
|
||||
missionInstanceId: active.mission.instanceId,
|
||||
missionId: active.mission.missionId,
|
||||
stageId,
|
||||
completed: result.completed,
|
||||
progressPercent: snapshot?.progressPercent,
|
||||
currentStageId: snapshot?.currentStageId,
|
||||
}, "mission actor stage update requested from daily mission");
|
||||
if (snapshot) {
|
||||
await upsertActiveMissionPg(userId, {
|
||||
instanceId: snapshot.instanceId,
|
||||
missionId: snapshot.missionId,
|
||||
workflowId: snapshot.workflowId,
|
||||
title: snapshot.title,
|
||||
shortTitle: snapshot.shortTitle,
|
||||
status: snapshot.status,
|
||||
progressPercent: snapshot.progressPercent,
|
||||
currentStageId: snapshot.currentStageId,
|
||||
goal: snapshot.goal,
|
||||
actorType: active.mission.actorType,
|
||||
createdAt: new Date(snapshot.createdAt).getTime(),
|
||||
updatedAt: new Date(snapshot.updatedAt).getTime(),
|
||||
}, snapshot);
|
||||
}
|
||||
} else {
|
||||
snapshot = active.snapshot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent: "daily-mission",
|
||||
message: result.reply,
|
||||
completed: result.completed,
|
||||
updateSummary: result.updateSummary,
|
||||
actionLabel: result.actionLabel,
|
||||
actionRoute: result.actionRoute,
|
||||
conversationId,
|
||||
missionInstanceId,
|
||||
stageId: responseStageId,
|
||||
snapshot,
|
||||
messages: conversationId ? await listMessagesPg(userId, conversationId) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function dailyMissionRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.post("/chat", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = chatSchema.parse(await c.req.json());
|
||||
log.info({
|
||||
userId,
|
||||
agent: "daily-mission",
|
||||
missionInstanceId: body.missionInstanceId,
|
||||
missionId: body.missionId,
|
||||
stageId: body.stageId,
|
||||
service: body.task.service,
|
||||
route: body.task.route,
|
||||
questTitle: body.task.questTitle,
|
||||
subtask: body.task.subtask,
|
||||
}, "daily mission actor request");
|
||||
const result = await runDailyMissionAgent({ userId, ...body });
|
||||
return c.json(await applyDailyMissionResult({ userId, body, result }));
|
||||
});
|
||||
|
||||
app.post("/chat/stream", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = chatSchema.parse(await c.req.json());
|
||||
log.info({
|
||||
userId,
|
||||
agent: "daily-mission",
|
||||
missionInstanceId: body.missionInstanceId,
|
||||
missionId: body.missionId,
|
||||
stageId: body.stageId,
|
||||
service: body.task.service,
|
||||
route: body.task.route,
|
||||
questTitle: body.task.questTitle,
|
||||
subtask: body.task.subtask,
|
||||
streaming: true,
|
||||
}, "daily mission actor stream request");
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
try {
|
||||
const streamed = await streamDailyMissionAgent({ userId, ...body });
|
||||
let reply = "";
|
||||
if (streamed.kind === "static") {
|
||||
reply = streamed.result.reply;
|
||||
await enqueueVisibleText(controller, reply);
|
||||
const finalPayload = await applyDailyMissionResult({ userId, body, result: streamed.result });
|
||||
controller.enqueue(sse("final", finalPayload));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
for await (const delta of streamed.textStream) {
|
||||
const cleanDelta = sanitizeAssistantText(delta);
|
||||
reply += cleanDelta;
|
||||
controller.enqueue(sse("delta", { text: cleanDelta }));
|
||||
}
|
||||
let result = streamed.finalize(reply);
|
||||
result = {
|
||||
...result,
|
||||
reply: sanitizeAssistantText(result.reply),
|
||||
updateSummary: result.updateSummary ? sanitizeAssistantText(result.updateSummary) : result.updateSummary,
|
||||
};
|
||||
if (!result.reply.trim()) {
|
||||
throw new Error("daily_mission_empty_model_reply");
|
||||
}
|
||||
const finalPayload = await applyDailyMissionResult({ userId, body, result });
|
||||
controller.enqueue(sse("final", finalPayload));
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
controller.enqueue(sse("error", { error: error instanceof Error ? error.message : String(error) }));
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache, no-transform",
|
||||
connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -4,8 +4,15 @@ import { config } from "../config.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents } from "../db/schema.js";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import {
|
||||
markGrowEventFailed,
|
||||
markGrowEventProcessed,
|
||||
markGrowEventProcessing,
|
||||
recordGrowEvent,
|
||||
} from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { applyQscoreProjection } from "../events/projectors/qscore-projector.js";
|
||||
import { ensureOnboardingSideEffectsForEvent } from "../events/onboarding-ledger.js";
|
||||
|
||||
function serviceAuthorized(auth: string | undefined) {
|
||||
const token = (auth ?? "").replace(/^Bearer\s+/i, "").trim();
|
||||
@@ -20,7 +27,41 @@ async function ingest(body: unknown, userId?: string, source?: string) {
|
||||
routed: false as const,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
return { event, route };
|
||||
if (event.processingStatus === "processed") {
|
||||
return {
|
||||
event,
|
||||
processingStatus: "processed" as const,
|
||||
route,
|
||||
qscore: { signals: [], score: undefined, idempotent: true },
|
||||
onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "already_processed" } },
|
||||
};
|
||||
}
|
||||
if (!event.userId) {
|
||||
return {
|
||||
event,
|
||||
processingStatus: event.processingStatus,
|
||||
route,
|
||||
qscore: { signals: [], score: undefined, skipped: "missing_user_id" },
|
||||
onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "skipped", reason: "missing_user_id" } },
|
||||
};
|
||||
}
|
||||
|
||||
await markGrowEventProcessing(event.id);
|
||||
try {
|
||||
const qscore = await applyQscoreProjection(event);
|
||||
const onboarding = await ensureOnboardingSideEffectsForEvent(event);
|
||||
if (
|
||||
onboarding.curatorOnboarding.status === "skipped" &&
|
||||
onboarding.curatorOnboarding.reason === "loop_failed"
|
||||
) {
|
||||
throw new Error("curator_onboarding_loop_failed");
|
||||
}
|
||||
await markGrowEventProcessed(event.id);
|
||||
return { event, processingStatus: "processed" as const, route, qscore, onboarding };
|
||||
} catch (err) {
|
||||
await markGrowEventFailed(event.id, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function eventRoutes() {
|
||||
@@ -30,8 +71,8 @@ export function eventRoutes() {
|
||||
app.post("/ingest", requireUser, async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const { event, route } = await ingest(body, userId);
|
||||
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202);
|
||||
const { event, processingStatus, route, qscore, onboarding } = await ingest(body, userId);
|
||||
return c.json({ eventId: event.id, processingStatus, route, qscore, onboarding }, 202);
|
||||
});
|
||||
|
||||
// Service-to-service ingress. Services may include userId directly, or we resolve it from session correlation.
|
||||
@@ -41,8 +82,8 @@ export function eventRoutes() {
|
||||
}
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const source = c.req.header("x-growqr-source") ?? undefined;
|
||||
const { event, route } = await ingest(body, undefined, source);
|
||||
return c.json({ eventId: event.id, processingStatus: event.processingStatus, route }, 202);
|
||||
const { event, processingStatus, route, qscore, onboarding } = await ingest(body, undefined, source);
|
||||
return c.json({ eventId: event.id, processingStatus, route, qscore, onboarding }, 202);
|
||||
});
|
||||
|
||||
app.get("/", requireUser, async (c) => {
|
||||
|
||||
@@ -2,7 +2,10 @@ 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);
|
||||
@@ -14,7 +17,26 @@ export function homeRoutes() {
|
||||
|
||||
app.get("/feed", async (c) => {
|
||||
const refresh = c.req.query("refresh") === "1" || c.req.query("refresh") === "true";
|
||||
return c.json(await getHomeFeed(c.get("userId"), { refresh }));
|
||||
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 {};
|
||||
});
|
||||
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) => {
|
||||
|
||||
116
src/routes/logs.ts
Normal file
116
src/routes/logs.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import Docker from "dockerode";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { Hono } from "hono";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
|
||||
const LOG_CONTAINERS = {
|
||||
backend: "growqr-backend",
|
||||
dashboard: "growqr-dashboard",
|
||||
actors: "growqr-rivet",
|
||||
interview: "interview-service-api-1",
|
||||
roleplay: "roleplay-service-api-1",
|
||||
social: "growqr_social_api",
|
||||
courses: "courses_service-api-1",
|
||||
assessment: "assessment-service-api-1",
|
||||
matchmaking: "matchmaking-service-api-1",
|
||||
} as const;
|
||||
|
||||
type LogService = keyof typeof LOG_CONTAINERS;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function parseServices(value: string | undefined): LogService[] {
|
||||
const requested = (value ?? "backend,dashboard,actors,interview,roleplay")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const services = requested.filter((item): item is LogService => item in LOG_CONTAINERS);
|
||||
return services.length ? services : ["backend", "dashboard", "actors", "interview", "roleplay"];
|
||||
}
|
||||
|
||||
function sse(event: string, payload: Record<string, unknown>) {
|
||||
return encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
function splitLines(chunk: Buffer | string) {
|
||||
return chunk
|
||||
.toString("utf8")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function logRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.get("/stream", (c) => {
|
||||
const services = parseServices(c.req.query("services"));
|
||||
const tail = Math.max(10, Math.min(500, Number(c.req.query("tail") ?? 120)));
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
const openStreams: Array<{ destroy: () => void }> = [];
|
||||
let closed = false;
|
||||
let heartbeat: NodeJS.Timeout | undefined;
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const send = (event: string, payload: Record<string, unknown>) => {
|
||||
if (!closed) controller.enqueue(sse(event, payload));
|
||||
};
|
||||
|
||||
send("ready", { services, tail, at: new Date().toISOString() });
|
||||
|
||||
for (const service of services) {
|
||||
const containerName = LOG_CONTAINERS[service];
|
||||
try {
|
||||
const stream = await docker.getContainer(containerName).logs({
|
||||
follow: true,
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
timestamps: true,
|
||||
tail,
|
||||
});
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
docker.modem.demuxStream(stream, stdout, stderr);
|
||||
openStreams.push(stream as unknown as { destroy: () => void }, stdout, stderr);
|
||||
|
||||
stdout.on("data", (chunk) => {
|
||||
for (const line of splitLines(chunk)) send("log", { service, stream: "stdout", line });
|
||||
});
|
||||
stderr.on("data", (chunk) => {
|
||||
for (const line of splitLines(chunk)) send("log", { service, stream: "stderr", line });
|
||||
});
|
||||
stream.on("end", () => send("status", { service, status: "ended" }));
|
||||
stream.on("error", (error) => send("error", { service, error: error instanceof Error ? error.message : String(error) }));
|
||||
} catch (error) {
|
||||
send("error", { service, container: containerName, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
heartbeat = setInterval(() => send("ping", { at: new Date().toISOString() }), 20_000);
|
||||
c.req.raw.signal.addEventListener("abort", () => {
|
||||
closed = true;
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
for (const stream of openStreams) stream.destroy();
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
for (const stream of openStreams) stream.destroy();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache, no-transform",
|
||||
connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import { isActorBackedMission } from "../missions/registry.js";
|
||||
import { getPersistedMissionDefinition, listPersistedMissionDefinitions } from "../missions/postgres-registry.js";
|
||||
import { completeMissionCoachRunPg, createMissionCoachRunPg, getActiveMissionPg, listActiveMissionsPg, listMissionSuggestionsPg, replaceMissionSuggestionsPg, upsertActiveMissionPg } from "../grow/persistence.js";
|
||||
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";
|
||||
import { runPassiveMissionReviews } from "../missions/lifecycle.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
@@ -55,6 +61,21 @@ const addArtifactSchema = z.object({
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const answerActionSchema = z.object({
|
||||
input: z.record(z.unknown()).optional(),
|
||||
answer: z.string().optional(),
|
||||
});
|
||||
|
||||
const snoozeActionSchema = z.object({
|
||||
until: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const passiveReviewSchema = z.object({
|
||||
date: z.string().optional(),
|
||||
force: z.boolean().optional(),
|
||||
limit: z.number().int().min(1).max(50).optional(),
|
||||
});
|
||||
|
||||
const createInstanceId = (missionId: string) =>
|
||||
`${missionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
@@ -92,18 +113,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);
|
||||
@@ -126,10 +135,15 @@ export function missionRoutes() {
|
||||
await Promise.all(persisted.map(async (item) => {
|
||||
suggestionsByMission[item.mission.instanceId] = await listMissionSuggestionsPg(userId, item.mission.instanceId);
|
||||
}));
|
||||
const actionsByMission: Record<string, unknown[]> = {};
|
||||
await Promise.all(persisted.map(async (item) => {
|
||||
actionsByMission[item.mission.instanceId] = await listMissionActions(userId, { missionInstanceId: item.mission.instanceId });
|
||||
}));
|
||||
return c.json({
|
||||
missions: persisted.map((item) => item.mission),
|
||||
snapshots: persisted.map((item) => item.snapshot).filter((snapshot): snapshot is MissionSnapshot => Boolean(snapshot)),
|
||||
suggestionsByMission,
|
||||
actionsByMission,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,7 +152,24 @@ export function missionRoutes() {
|
||||
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
|
||||
if (!active) return c.json({ error: "mission_not_found" }, 404);
|
||||
const suggestions = await listMissionSuggestionsPg(userId, active.mission.instanceId);
|
||||
return c.json({ mission: active.mission, snapshot: active.snapshot, suggestions });
|
||||
const actions = await listMissionActions(userId, { missionInstanceId: active.mission.instanceId });
|
||||
return c.json({ mission: active.mission, snapshot: active.snapshot, suggestions, actions });
|
||||
});
|
||||
|
||||
app.get("/active/:instanceId/actions", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
|
||||
if (!active) return c.json({ error: "mission_not_found" }, 404);
|
||||
return c.json({ actions: await listMissionActions(userId, { missionInstanceId: active.mission.instanceId }) });
|
||||
});
|
||||
|
||||
app.post("/active/:instanceId/scrum/run", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
|
||||
if (!active?.mission.actorType) return c.json({ error: "mission_not_found" }, 404);
|
||||
const result = await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runDailyScrum({ trigger: "manual" });
|
||||
if (result.snapshot) await upsertActiveMissionPg(userId, activeMissionFromSnapshot(result.snapshot), result.snapshot);
|
||||
return c.json({ summary: result.summary, snapshot: result.snapshot });
|
||||
});
|
||||
|
||||
app.post("/active/:instanceId/coach/run", async (c) => {
|
||||
@@ -148,7 +179,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,
|
||||
@@ -190,6 +221,88 @@ export function missionRoutes() {
|
||||
return c.json({ coachRunId: run.id, summary, suggestions });
|
||||
});
|
||||
|
||||
app.post("/actions/:actionId/approve", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const action = await getMissionAction(userId, c.req.param("actionId"));
|
||||
if (!action) return c.json({ error: "action_not_found" }, 404);
|
||||
const updated = await updateMissionActionStatus(userId, action.id, {
|
||||
status: "queued",
|
||||
result: { approvedAt: new Date().toISOString() },
|
||||
payload: { ...(action.payload ?? {}), approved: true },
|
||||
});
|
||||
const active = await getActiveMissionPg(userId, action.missionInstanceId);
|
||||
if (active?.mission.actorType) {
|
||||
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).resolveHitl({ actionId: action.id, resolution: "approved" }).catch(() => undefined);
|
||||
}
|
||||
return c.json({ action: updated });
|
||||
});
|
||||
|
||||
app.post("/actions/:actionId/reject", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const action = await updateMissionActionStatus(userId, c.req.param("actionId"), { status: "dismissed", result: { rejectedAt: new Date().toISOString() } });
|
||||
if (!action) return c.json({ error: "action_not_found" }, 404);
|
||||
return c.json({ action });
|
||||
});
|
||||
|
||||
app.post("/actions/:actionId/run", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const existing = await getMissionAction(userId, c.req.param("actionId"));
|
||||
if (!existing) return c.json({ error: "action_not_found" }, 404);
|
||||
const active = await getActiveMissionPg(userId, existing.missionInstanceId);
|
||||
if (active?.mission.actorType) {
|
||||
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runAction({ actionId: existing.id }).catch(() => undefined);
|
||||
}
|
||||
const href = typeof existing.payload?.href === "string" ? existing.payload.href : missionDetailHref(existing.missionInstanceId);
|
||||
const action = await updateMissionActionStatus(userId, existing.id, {
|
||||
status: "done",
|
||||
result: {
|
||||
ranAt: new Date().toISOString(),
|
||||
message: existing.toolName?.startsWith("resume.")
|
||||
? "Resume Agent prepared the draft brief. Open Resume Builder to apply it."
|
||||
: "Action marked complete. Continue from the linked GrowQR surface.",
|
||||
href,
|
||||
},
|
||||
});
|
||||
return c.json({ action });
|
||||
});
|
||||
|
||||
app.post("/actions/:actionId/answer", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const existing = await getMissionAction(userId, c.req.param("actionId"));
|
||||
if (!existing) return c.json({ error: "action_not_found" }, 404);
|
||||
const body = answerActionSchema.parse(await c.req.json().catch(() => ({})));
|
||||
const input = body.input ?? (body.answer ? { answer: body.answer } : {});
|
||||
const action = await updateMissionActionStatus(userId, existing.id, {
|
||||
status: "done",
|
||||
result: { answeredAt: new Date().toISOString(), input },
|
||||
payload: { ...(existing.payload ?? {}), userInput: input },
|
||||
});
|
||||
const active = await getActiveMissionPg(userId, existing.missionInstanceId);
|
||||
if (active?.mission.actorType) {
|
||||
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).resolveHitl({ actionId: existing.id, resolution: "answered", input }).catch(() => undefined);
|
||||
}
|
||||
return c.json({ action });
|
||||
});
|
||||
|
||||
app.post("/actions/:actionId/snooze", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = snoozeActionSchema.parse(await c.req.json().catch(() => ({})));
|
||||
const action = await updateMissionActionStatus(userId, c.req.param("actionId"), { status: "snoozed", result: { snoozedAt: new Date().toISOString(), until: body.until } });
|
||||
if (!action) return c.json({ error: "action_not_found" }, 404);
|
||||
return c.json({ action });
|
||||
});
|
||||
|
||||
app.post("/passive/run", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = passiveReviewSchema.parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await runPassiveMissionReviews({
|
||||
userId,
|
||||
date: body.date,
|
||||
force: body.force,
|
||||
limit: body.limit,
|
||||
}));
|
||||
});
|
||||
|
||||
app.post("/:missionId/start", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const missionId = c.req.param("missionId");
|
||||
@@ -215,6 +328,16 @@ export function missionRoutes() {
|
||||
await upsertActiveMissionPg(userId, activeMission, snapshot);
|
||||
const grow = growFor(userId);
|
||||
grow.setup({ userId }).then(() => grow.registerActiveMission(activeMission)).catch((err) => console.warn("growActor mission mirror failed", err));
|
||||
recordGrowEvent({
|
||||
source: "growqr-backend:missions",
|
||||
type: "mission.started",
|
||||
category: "mission",
|
||||
userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
mission: { instanceId, missionId, stageId: snapshot.currentStageId },
|
||||
payload: { goal: body.goal, input: body.input ?? {}, title: snapshot.title },
|
||||
dedupeKey: `mission-started:${instanceId}`,
|
||||
}).then((event) => routeGrowEventToUserActor(event)).catch((err) => console.warn("mission start event routing failed", err));
|
||||
return c.json({ mission: activeMission, snapshot }, 201);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
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 { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
|
||||
import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
const LANDING_AGENTS = [
|
||||
{ id: "resume", title: "The Resume Expert", agent: "AI Resume Expert", description: "Writes resumes and cover letters that get past ATS and into human hands.", route: "/agents/resume" },
|
||||
{ id: "interview", title: "The Interviewer", agent: "AI Interviewer", description: "Puts you through real interviews before the real one. Brutal feedback included.", route: "/agents/interview" },
|
||||
{ id: "roleplay", title: "The Roleplay Coach", agent: "AI Roleplay Coach", description: "Practises the hard conversations with you — so you're never caught off guard.", route: "/agents/roleplay" },
|
||||
{ id: "assessments", title: "The Skill Trainer", agent: "AI Skill Trainer", description: "Finds exactly what's holding you back — then builds a plan to close it.", route: "/agents/assessments" },
|
||||
{ id: "social-branding", title: "The Brand Voice", agent: "AI Brand Voice", description: "Makes you impossible to ignore online — posts, profiles, and positioning sorted.", route: "/agents/social-branding" },
|
||||
{ id: "pathways-report", title: "The Pathway Guide", agent: "AI Pathway Guide", description: "Maps career options, pivots, and what-fits-next at every stage of your journey.", route: "/agents/pathways-report" },
|
||||
{ id: "courses", title: "The Learning Curator", agent: "AI Learning Curator", description: "Picks only what's worth your time — courses and paths that move you forward.", route: "/agents/courses" },
|
||||
{ id: "jobs", title: "The Opportunity Finder", agent: "AI Job Finder", description: "Daily, agent-sourced shortlist of roles matched to your QX and goals.", route: "/agents/jobs" },
|
||||
{ id: "presence", title: "The Presence Coach", agent: "AI Presence Coach", description: "Tells you what to wear, how to speak, and how to carry yourself in every room.", route: "/agents/presence" },
|
||||
{ id: "resume", title: "Resume", agent: "Resume Strategist", description: "Resume proof, versions, parsing, analysis, and mission artifacts.", route: "/agents/resume" },
|
||||
{ id: "interview", title: "Interview", agent: "Interview Coach", description: "Mock interviews, reviews, weakness diagnosis, and readiness signals.", route: "/agents/interview" },
|
||||
{ id: "roleplay", title: "Roleplay", agent: "Roleplay Coach", description: "Negotiation, promotion, transition, and communication drills.", route: "/agents/roleplay" },
|
||||
] as const;
|
||||
|
||||
const DEFAULT_QSCORE = {
|
||||
@@ -48,6 +46,125 @@ function missionFromBody(body: JsonObject): Record<string, unknown> | undefined
|
||||
return mission && typeof mission === "object" && !Array.isArray(mission) ? (mission as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function missionFromRequest(req: Request, body?: JsonObject): Record<string, unknown> | undefined {
|
||||
const fromBody = body ? missionFromBody(body) : undefined;
|
||||
if (fromBody) return fromBody;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const instanceId = getString(url.searchParams.get("missionInstanceId"));
|
||||
const missionId = getString(url.searchParams.get("missionId"));
|
||||
const stageId = getString(url.searchParams.get("stageId"));
|
||||
const source = getString(url.searchParams.get("source"));
|
||||
|
||||
if (!instanceId && !missionId && !stageId) return undefined;
|
||||
return {
|
||||
instanceId,
|
||||
missionId,
|
||||
stageId,
|
||||
source: source ?? "mission",
|
||||
};
|
||||
}
|
||||
|
||||
function curatorTaskIdFromRequest(req: Request, body?: JsonObject) {
|
||||
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"));
|
||||
}
|
||||
|
||||
function stripMissionFromBody(body: JsonObject): JsonObject {
|
||||
if (!("mission" in body)) return body;
|
||||
const { mission: _mission, ...rest } = body;
|
||||
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;
|
||||
@@ -55,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,
|
||||
@@ -69,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,
|
||||
});
|
||||
@@ -79,9 +203,115 @@ 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" || 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 {
|
||||
const parsed = JSON.parse(Buffer.from(body).toString("utf8"));
|
||||
return isRecord(parsed) ? parsed as JsonObject : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
@@ -92,8 +322,13 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
.replace(/^resumes\/([^/]+)\/analyze$/, "ai/analyze/$1")
|
||||
.replace(/^resumes\/([^/]+)\/suggestions$/, "ai/suggestions/$1")
|
||||
.replace(/^resumes\/([^/]+)\/preview$/, "export/resumes/$1/preview");
|
||||
const forwardedQuery = new URLSearchParams(incoming.searchParams);
|
||||
forwardedQuery.delete("missionInstanceId");
|
||||
forwardedQuery.delete("missionId");
|
||||
forwardedQuery.delete("stageId");
|
||||
forwardedQuery.delete("source");
|
||||
const target = new URL(
|
||||
`/api/v1/${normalizedRest}${incoming.search}`,
|
||||
`/api/v1/${normalizedRest}${forwardedQuery.toString() ? `?${forwardedQuery.toString()}` : ""}`,
|
||||
config.resumeServiceUrl.replace(/\/$/, ""),
|
||||
);
|
||||
|
||||
@@ -105,19 +340,244 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
|
||||
const method = req.method.toUpperCase();
|
||||
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
|
||||
const requestJson = parseJsonBody(body, headers);
|
||||
const mission = missionFromRequest(req, requestJson);
|
||||
const forwardBody =
|
||||
body && headers.get("content-type")?.includes("application/json")
|
||||
? Buffer.from(JSON.stringify(stripMissionFromBody(requestJson)))
|
||||
: body;
|
||||
if (forwardBody !== body) headers.delete("content-length");
|
||||
const res = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
body: forwardBody,
|
||||
});
|
||||
|
||||
return new Response(res.body, {
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
||||
}
|
||||
|
||||
const responseBuffer = await res.arrayBuffer();
|
||||
const responseText = Buffer.from(responseBuffer).toString("utf8");
|
||||
let responseJson: unknown;
|
||||
try { responseJson = responseText ? JSON.parse(responseText) : undefined; } catch { responseJson = undefined; }
|
||||
const responseObj = isRecord(responseJson) ? responseJson : { body: responseText.slice(0, 2000) };
|
||||
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "resume-builder",
|
||||
type: resumeEventTypeForRest(method, normalizedRest, res.ok),
|
||||
payload: { request: requestJson, result: responseObj, status: res.status, path: normalizedRest },
|
||||
correlation: {
|
||||
resumeId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
|
||||
externalId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
|
||||
taskId: curatorTaskIdFromRequest(req, requestJson),
|
||||
},
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err, path: normalizedRest }, "failed to record resume gateway event"));
|
||||
|
||||
return new Response(responseBuffer, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function getServiceState(baseUrl: string, path: string): Promise<Record<string, unknown> | undefined> {
|
||||
const target = new URL(path, baseUrl.replace(/\/$/, ""));
|
||||
const res = await fetch(target, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
|
||||
},
|
||||
}).catch(() => null);
|
||||
if (!res?.ok) return undefined;
|
||||
const json = await res.json().catch(() => null);
|
||||
return isRecord(json) ? json : undefined;
|
||||
}
|
||||
|
||||
function mergeUniqueSkills(existing: unknown, incoming: unknown): string[] {
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const skill of [...stringArray(existing), ...stringArray(incoming)]) {
|
||||
const key = skill.toLowerCase();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(skill);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function resolveGrowUserContext(req: Request, userId: string): Promise<Record<string, unknown>> {
|
||||
const { userProfile } = await getRequestUserProfile(req, userId);
|
||||
const userContext: Record<string, unknown> = { ...(userProfile ?? {}) };
|
||||
userContext.clerk_id = String(userContext.clerk_id ?? userId);
|
||||
|
||||
const clerkId = String(userContext.clerk_id || userId);
|
||||
const [resumeState, socialState] = await Promise.all([
|
||||
getServiceState(config.resumeServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`),
|
||||
getServiceState(config.socialBrandingServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`),
|
||||
]);
|
||||
|
||||
if (resumeState) {
|
||||
const resumeSkills = resumeState.skills ?? [
|
||||
...stringArray(resumeState.technical_skills),
|
||||
...stringArray(resumeState.soft_skills),
|
||||
];
|
||||
userContext.skills = mergeUniqueSkills(userContext.skills, resumeSkills);
|
||||
if (resumeState.current_role && !userContext.current_role) userContext.current_role = resumeState.current_role;
|
||||
if (resumeState.current_company && !userContext.current_company) userContext.current_company = resumeState.current_company;
|
||||
if (Array.isArray(resumeState.experience_history)) userContext.experience_history = resumeState.experience_history;
|
||||
if (Array.isArray(resumeState.education)) userContext.education = resumeState.education;
|
||||
}
|
||||
|
||||
if (socialState) {
|
||||
userContext.skills = mergeUniqueSkills(userContext.skills, socialState.skills ?? socialState.linkedin_skills);
|
||||
if (socialState.headline) userContext.linkedin_headline = socialState.headline;
|
||||
if (socialState.summary) userContext.linkedin_summary = socialState.summary;
|
||||
if (Array.isArray(socialState.experience)) userContext.linkedin_experience = socialState.experience;
|
||||
userContext.linkedin_connected = Boolean(socialState.linkedin_connected);
|
||||
}
|
||||
|
||||
return userContext;
|
||||
}
|
||||
|
||||
function hasResumeContext(userContext: Record<string, unknown>): boolean {
|
||||
return Boolean(
|
||||
(Array.isArray(userContext.experience_history) && userContext.experience_history.length > 0) ||
|
||||
(Array.isArray(userContext.skills) && userContext.skills.length > 0) ||
|
||||
getString(userContext.current_role)
|
||||
);
|
||||
}
|
||||
|
||||
function hasLinkedInContext(userContext: Record<string, unknown>): boolean {
|
||||
return Boolean(
|
||||
userContext.linkedin_connected ||
|
||||
getString(userContext.linkedin_headline) ||
|
||||
getString(userContext.linkedin_summary) ||
|
||||
(Array.isArray(userContext.linkedin_experience) && userContext.linkedin_experience.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function composeCandidateProfile(userContext: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
const currentRole = getString(userContext.current_role);
|
||||
const headline = getString(userContext.linkedin_headline);
|
||||
if (currentRole) lines.push(`Current role: ${currentRole}`);
|
||||
if (headline && headline.toLowerCase() !== currentRole?.toLowerCase()) lines.push(`LinkedIn headline: ${headline}`);
|
||||
|
||||
const skills = stringArray(userContext.skills);
|
||||
if (skills.length) lines.push(`Key skills: ${skills.slice(0, 12).join(", ")}`);
|
||||
|
||||
const roles: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addRole = (title?: unknown, company?: unknown) => {
|
||||
const roleTitle = String(title ?? "").trim();
|
||||
const roleCompany = String(company ?? "").trim();
|
||||
if (!roleTitle && !roleCompany) return;
|
||||
const key = `${roleTitle.toLowerCase()}|${roleCompany.toLowerCase()}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
roles.push(roleTitle && roleCompany ? `${roleTitle} at ${roleCompany}` : roleTitle || roleCompany);
|
||||
};
|
||||
for (const item of Array.isArray(userContext.experience_history) ? userContext.experience_history : []) {
|
||||
if (isRecord(item)) addRole(item.position ?? item.title, item.company);
|
||||
}
|
||||
for (const item of Array.isArray(userContext.linkedin_experience) ? userContext.linkedin_experience : []) {
|
||||
if (isRecord(item)) addRole(item.title ?? item.position, item.company);
|
||||
}
|
||||
if (roles.length) lines.push(`Experience: ${roles.slice(0, 6).join("; ")}`);
|
||||
|
||||
const education: string[] = [];
|
||||
for (const item of Array.isArray(userContext.education) ? userContext.education : []) {
|
||||
if (!isRecord(item)) continue;
|
||||
const degree = getString(item.degree);
|
||||
const field = getString(item.field_of_study);
|
||||
const institution = getString(item.institution ?? item.school);
|
||||
let piece = [degree, field].filter(Boolean).join(" ").trim();
|
||||
if (institution) piece = piece ? `${piece} — ${institution}` : institution;
|
||||
if (piece) education.push(piece);
|
||||
}
|
||||
if (education.length) lines.push(`Education: ${education.slice(0, 3).join("; ")}`);
|
||||
|
||||
const summary = getString(userContext.linkedin_summary);
|
||||
if (summary) lines.push(`Profile summary: ${summary.length > 300 ? `${summary.slice(0, 300).trimEnd()}…` : summary}`);
|
||||
|
||||
const text = lines.join("\n");
|
||||
return text.length > 1200 ? `${text.slice(0, 1200).trimEnd()}…` : text;
|
||||
}
|
||||
|
||||
async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject, userId: string): Promise<JsonObject> {
|
||||
const { mission: _mission, ...rest } = body;
|
||||
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for interview configure");
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
const incomingContext = isRecord(rest.context) ? rest.context : {};
|
||||
const context: Record<string, unknown> = {
|
||||
...incomingContext,
|
||||
candidate_name: getString(incomingContext.candidate_name) ?? getString(userContext.first_name) ?? "",
|
||||
target_role: getString(incomingContext.target_role) ?? "",
|
||||
company_name: getString(incomingContext.company_name) ?? "",
|
||||
job_description: getString(incomingContext.job_description) ?? "",
|
||||
difficulty: getString(incomingContext.difficulty) ?? "medium",
|
||||
};
|
||||
|
||||
if (incomingContext.personalize) {
|
||||
const candidateProfile = composeCandidateProfile(userContext);
|
||||
if (candidateProfile) context.candidate_profile = candidateProfile;
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
user_id: String(rest.user_id ?? userId),
|
||||
org_id: String(rest.org_id ?? "growqr"),
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: JsonObject, userId: string): Promise<JsonObject> {
|
||||
const { mission: _mission, ...rest } = body;
|
||||
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for roleplay configure");
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
const incomingMetadata = isRecord(rest.metadata) ? rest.metadata : {};
|
||||
const metadata: Record<string, unknown> = {
|
||||
...incomingMetadata,
|
||||
candidate_name: getString(incomingMetadata.candidate_name) ?? getString(userContext.first_name) ?? "",
|
||||
target_role: getString(incomingMetadata.target_role) ?? getString(userContext.current_role) ?? "General",
|
||||
candidate_role: getString(incomingMetadata.candidate_role) ?? getString(incomingMetadata.target_role) ?? getString(userContext.current_role) ?? "General",
|
||||
difficulty: getString(incomingMetadata.difficulty) ?? "medium",
|
||||
};
|
||||
|
||||
// Match the production orchestrator behavior: roleplay-service receives the
|
||||
// same enriched user context the websocket orchestrator attached to A2A tasks,
|
||||
// plus prompt-safe metadata that can personalize scenario planning/review.
|
||||
const candidateProfile = composeCandidateProfile(userContext);
|
||||
if (candidateProfile && (incomingMetadata.personalize ?? true)) {
|
||||
metadata.candidate_profile = candidateProfile;
|
||||
metadata.context_notes = getString(incomingMetadata.context_notes) ?? candidateProfile;
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
user_id: String(rest.user_id ?? userId),
|
||||
org_id: String(rest.org_id ?? "growqr"),
|
||||
metadata,
|
||||
qscore: (rest.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE),
|
||||
user_context: userContext,
|
||||
};
|
||||
}
|
||||
|
||||
async function proxySocialRequest(req: Request, rest: string, userId: string) {
|
||||
const incoming = new URL(req.url);
|
||||
const normalizedRest = rest.replace(/^\/+/, "");
|
||||
@@ -146,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");
|
||||
@@ -194,6 +654,12 @@ export function serviceRoutes() {
|
||||
|
||||
app.get("/qscore/current", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
try {
|
||||
await ensureOnboardingBaselineQscoreFromLedger(userId);
|
||||
} catch (err) {
|
||||
log.warn({ err, userId }, "failed to seed onboarding Q Score baseline before current Q Score read");
|
||||
}
|
||||
|
||||
const [projection] = await db
|
||||
.select()
|
||||
.from(growQscoreProjectionState)
|
||||
@@ -233,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,
|
||||
@@ -249,31 +715,56 @@ 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) => c.json(await interviewService.pageState(c.get("userId"))));
|
||||
app.get("/interview/page-state", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const [state, userContext] = await Promise.all([
|
||||
interviewService.pageState(userId),
|
||||
resolveGrowUserContext(c.req.raw, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for interview page-state");
|
||||
return {} as Record<string, unknown>;
|
||||
}),
|
||||
]);
|
||||
return c.json({
|
||||
...state,
|
||||
resume_available: hasResumeContext(userContext),
|
||||
linkedin_available: hasLinkedInContext(userContext),
|
||||
});
|
||||
});
|
||||
app.post("/interview/configure", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const payload = {
|
||||
...body,
|
||||
user_id: String(body.user_id ?? userId),
|
||||
org_id: String(body.org_id ?? "growqr"),
|
||||
} satisfies JsonObject;
|
||||
const result = await interviewService.configure(payload);
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId);
|
||||
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) },
|
||||
mission: missionFromBody(body),
|
||||
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 }>();
|
||||
@@ -286,45 +777,59 @@ 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,
|
||||
source: "interview-service",
|
||||
type: eventTypeForReview("interview", resultObj),
|
||||
payload: resultObj,
|
||||
correlation: { sessionId },
|
||||
correlation: { sessionId, taskId: curatorTaskIdFromRequest(c.req.raw) },
|
||||
}).catch((err) => log.warn({ err }, "failed to record interview review event"));
|
||||
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"), await c.req.json<JsonObject>())));
|
||||
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), await c.req.json<JsonObject>())));
|
||||
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) => c.json(await roleplayService.pageState(c.get("userId"))));
|
||||
app.get("/roleplay/page-state", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const [state, userContext] = await Promise.all([
|
||||
roleplayService.pageState(userId),
|
||||
resolveGrowUserContext(c.req.raw, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for roleplay page-state");
|
||||
return {} as Record<string, unknown>;
|
||||
}),
|
||||
]);
|
||||
return c.json({
|
||||
...state,
|
||||
resume_available: hasResumeContext(userContext),
|
||||
linkedin_available: hasLinkedInContext(userContext),
|
||||
});
|
||||
});
|
||||
app.post("/roleplay/configure", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const payload = {
|
||||
...body,
|
||||
user_id: String(body.user_id ?? userId),
|
||||
org_id: String(body.org_id ?? "growqr"),
|
||||
qscore: (body.qscore as JsonObject | undefined) ?? DEFAULT_QSCORE,
|
||||
} satisfies JsonObject;
|
||||
const result = await roleplayService.configure(payload);
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId);
|
||||
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) },
|
||||
mission: missionFromBody(body),
|
||||
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 }>();
|
||||
@@ -337,26 +842,41 @@ 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,
|
||||
source: "roleplay-service",
|
||||
type: eventTypeForReview("roleplay", resultObj),
|
||||
payload: resultObj,
|
||||
correlation: { sessionId },
|
||||
correlation: { sessionId, taskId: curatorTaskIdFromRequest(c.req.raw) },
|
||||
}).catch((err) => log.warn({ err }, "failed to record roleplay review event"));
|
||||
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"), await c.req.json<JsonObject>())));
|
||||
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), await c.req.json<JsonObject>())));
|
||||
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
|
||||
@@ -364,7 +884,9 @@ export function serviceRoutes() {
|
||||
// resumes, templates, uploads, parsing, exports, cover letters, versions,
|
||||
// restore, primary selection, and AI endpoints.
|
||||
app.all("/resume/*", async (c) => {
|
||||
const rest = c.req.path.split("/resume/")[1] ?? "";
|
||||
const marker = "/resume/";
|
||||
const markerIndex = c.req.path.indexOf(marker);
|
||||
const rest = markerIndex >= 0 ? c.req.path.slice(markerIndex + marker.length) : "";
|
||||
return proxyResumeRequest(c.req.raw, rest, c.get("userId"));
|
||||
});
|
||||
|
||||
@@ -374,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;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,15 @@ 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 { log } from "../log.js";
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
onboardingCompletedAtFromPreferences,
|
||||
} from "../v1/curator/curator-onboarding-loop.js";
|
||||
import {
|
||||
getLatestValidOnboardingLedgerEvent,
|
||||
recordAndProcessOnboardingCompletion,
|
||||
} from "../events/onboarding-ledger.js";
|
||||
|
||||
function publicStack(stack: UserStack | null | undefined) {
|
||||
if (!stack) return stack;
|
||||
@@ -17,7 +23,7 @@ function userServiceTarget(path: string, search = "") {
|
||||
return new URL(`/api/v1/users${path}${search}`, config.userServiceUrl.replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
async function proxyUserService(req: Request, path: string) {
|
||||
async function fetchUserService(req: Request, path: string) {
|
||||
const incoming = new URL(req.url);
|
||||
const target = userServiceTarget(path, incoming.search);
|
||||
const headers = new Headers(req.headers);
|
||||
@@ -26,7 +32,11 @@ async function proxyUserService(req: Request, path: string) {
|
||||
|
||||
const method = req.method.toUpperCase();
|
||||
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
|
||||
const res = await fetch(target, { method, headers, body });
|
||||
return fetch(target, { method, headers, body });
|
||||
}
|
||||
|
||||
async function proxyUserService(req: Request, path: string) {
|
||||
const res = await fetchUserService(req, path);
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
@@ -74,12 +84,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,
|
||||
@@ -88,8 +92,60 @@ export function userRoutes() {
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/onboarding-status", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const event = await getLatestValidOnboardingLedgerEvent(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) => proxyUserService(c.req.raw, "/me"));
|
||||
app.patch("/me", async (c) => {
|
||||
const res = await fetchUserService(c.req.raw, "/me");
|
||||
const text = await res.text();
|
||||
|
||||
if (res.ok) {
|
||||
try {
|
||||
const userProfile = JSON.parse(text) as Record<string, unknown>;
|
||||
const preferences = userProfile.preferences;
|
||||
const normalizedPreferences = preferences && typeof preferences === "object" && !Array.isArray(preferences)
|
||||
? (preferences as Record<string, unknown>)
|
||||
: undefined;
|
||||
const completedAt = onboardingCompletedAtFromPreferences(normalizedPreferences);
|
||||
if (completedAt) {
|
||||
await recordAndProcessOnboardingCompletion({
|
||||
userId: c.get("userId"),
|
||||
completedAt,
|
||||
source: "user-service-profile",
|
||||
context: {
|
||||
preferences: normalizedPreferences,
|
||||
profile: userProfile,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ err, userId: c.get("userId") }, "failed to run onboarding side effects after user update");
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(text, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
});
|
||||
});
|
||||
app.get("/me/plan", async (c) => proxyUserService(c.req.raw, "/me/plan"));
|
||||
app.post("/me/photo", async (c) => proxyUserService(c.req.raw, "/me/photo"));
|
||||
app.delete("/me/photo", async (c) => proxyUserService(c.req.raw, "/me/photo"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,8 +6,34 @@ export type ServiceCallOptions = {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
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,
|
||||
@@ -21,21 +47,25 @@ async function serviceJson<T = JsonObject>(
|
||||
...(opts.headers ?? {}),
|
||||
},
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
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 })}`),
|
||||
configure: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload }),
|
||||
preview: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure/preview", { body: payload }),
|
||||
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> }) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/questions", { body: payload }),
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/questions", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
approve: (sessionId: string) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/approve", { body: { session_id: sessionId } }),
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/approve", { body: { session_id: sessionId }, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
createAssignments: (payload: { organization_id: string; role: string; round: "warm_up" | "behavioral" | "technical"; assignee_emails: string[] }) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/interviews/assignments", { body: payload }),
|
||||
listAssignments: (email: string, status = "pending", limit = 20) =>
|
||||
@@ -44,25 +74,45 @@ 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`, { body: payload }),
|
||||
markVideoUploaded: (sessionId: string, payload: JsonObject) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { body: payload }),
|
||||
artifact: (sessionId: string, artifactType: string, userId?: string) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
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 })}`),
|
||||
configure: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure", { body: payload }),
|
||||
preview: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/preview", { body: payload }),
|
||||
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> }) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/questions", { body: payload }),
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/questions", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
approve: (sessionId: string) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/approve", { body: { session_id: sessionId } }),
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/approve", { body: { session_id: sessionId }, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
createAssignments: (payload: { organization_id: string; name: string; scenario: string; assignee_emails: string[] }) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/assignments", { body: payload }),
|
||||
listAssignments: (email: string, status = "pending", limit = 20) =>
|
||||
@@ -71,14 +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`, { body: payload }),
|
||||
markVideoUploaded: (sessionId: string, payload: JsonObject) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { body: payload }),
|
||||
artifact: (sessionId: string, artifactType: string, userId?: string) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
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 = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { config } from "../config.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { buildServiceSessionPath } from "./service-registry.js";
|
||||
|
||||
// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule).
|
||||
export type ServiceAgentRef = {
|
||||
@@ -28,32 +29,17 @@ export function buildServiceSessionUrl(
|
||||
detail: Record<string, unknown> | undefined,
|
||||
goal?: string,
|
||||
): string | undefined {
|
||||
const base = config.workflowsDashboardUrl.replace(/\/$/, "");
|
||||
const sessionId = detail?.session_id ?? detail?.sessionId;
|
||||
const params = new URLSearchParams();
|
||||
if (sessionId && typeof sessionId === "string") params.set("session_id", sessionId);
|
||||
if (goal) params.set("goal", goal);
|
||||
|
||||
if (service === "interview-service") {
|
||||
if (!sessionId || typeof sessionId !== "string") return undefined;
|
||||
params.set("role", String(detail?.target_role ?? goal ?? "Interview practice"));
|
||||
params.set("type", String(detail?.interview_type ?? "behavioral"));
|
||||
return `${base}/v2/service-sessions/interview?${params.toString()}`;
|
||||
if (
|
||||
service !== "interview-service" &&
|
||||
service !== "roleplay-service" &&
|
||||
service !== "resume-service"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (service === "roleplay-service") {
|
||||
if (!sessionId || typeof sessionId !== "string") return undefined;
|
||||
params.set("role", String(detail?.target_role ?? goal ?? "Roleplay practice"));
|
||||
params.set("type", String(detail?.roleplay_type ?? "custom"));
|
||||
return `${base}/v2/service-sessions/roleplay?${params.toString()}`;
|
||||
}
|
||||
|
||||
if (service === "resume-service") {
|
||||
if (goal) params.set("role", goal);
|
||||
return `${base}/v2/service-sessions/resume${params.size ? `?${params.toString()}` : ""}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
const path = buildServiceSessionPath(service, detail, goal);
|
||||
if (!path) return undefined;
|
||||
return `${config.workflowsDashboardUrl.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
function stableUuid(input: string): string {
|
||||
@@ -129,7 +115,7 @@ async function runInterviewService(ctx: ServiceAgentContext): Promise<ServiceAge
|
||||
);
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Interview Agent created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
||||
summary: `Mock Interview created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
||||
detail: {
|
||||
...detail,
|
||||
target_role: payload.context.target_role,
|
||||
@@ -173,7 +159,7 @@ async function runRoleplayService(ctx: ServiceAgentContext): Promise<ServiceAgen
|
||||
);
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Roleplay Agent created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
||||
summary: `Mock Roleplay created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
||||
detail: {
|
||||
...detail,
|
||||
target_role: payload.metadata.target_role,
|
||||
@@ -250,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 Agent 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),
|
||||
},
|
||||
};
|
||||
@@ -269,12 +249,12 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Q Score Agent computed Q-Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
|
||||
summary: `Q Score computed Q Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
|
||||
detail: { ingest, compute, qscore_user_id: qscoreUserId },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Resume Agent (resume-builder service from growqr-app) ──
|
||||
// ── Resume Building (resume-builder service from growqr-app) ──
|
||||
|
||||
async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
||||
// Probe resume state for the user
|
||||
@@ -289,8 +269,8 @@ async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
return {
|
||||
status: "ok",
|
||||
summary: hasResume
|
||||
? `Resume Agent found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.`
|
||||
: "No existing resume found. Resume Agent is ready to build one from scratch.",
|
||||
? `Resume Building found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.`
|
||||
: "No existing resume found. Resume Building is ready to build one from scratch.",
|
||||
detail: {
|
||||
resume_count: detail.resume_count,
|
||||
completeness,
|
||||
@@ -302,7 +282,7 @@ async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
summary: `Resume Agent unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
||||
summary: `Resume Building unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -317,7 +297,7 @@ async function runResumeTailor(ctx: ServiceAgentContext): Promise<ServiceAgentRe
|
||||
// Return summary with optimization guidance
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Resume Agent analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`,
|
||||
summary: `Resume Building analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`,
|
||||
detail: {
|
||||
...(stateResult.detail as Record<string, unknown> ?? {}),
|
||||
goal: ctx.goal,
|
||||
@@ -382,11 +362,11 @@ export async function runServiceAgentProbe(
|
||||
case "interview-service":
|
||||
return ctx
|
||||
? await runInterviewService(ctx)
|
||||
: healthCheck(config.interviewServiceUrl, "Interview Agent / interview-service");
|
||||
: healthCheck(config.interviewServiceUrl, "Mock Interview / interview-service");
|
||||
case "roleplay-service":
|
||||
return ctx
|
||||
? await runRoleplayService(ctx)
|
||||
: healthCheck(config.roleplayServiceUrl, "Roleplay Agent / roleplay-service");
|
||||
: healthCheck(config.roleplayServiceUrl, "Mock Roleplay / roleplay-service");
|
||||
case "qscore-service":
|
||||
return ctx
|
||||
? await runQScoreService(ctx)
|
||||
@@ -394,7 +374,7 @@ export async function runServiceAgentProbe(
|
||||
case "resume-service":
|
||||
return ctx
|
||||
? await runResumeTailor(ctx)
|
||||
: healthCheck(config.resumeServiceUrl, "Resume Agent / resume-service");
|
||||
: healthCheck(config.resumeServiceUrl, "Resume Building / resume-service");
|
||||
case "matchmaking-service":
|
||||
return ctx
|
||||
? await runMatchmaking(ctx)
|
||||
|
||||
1021
src/services/service-registry.ts
Normal file
1021
src/services/service-registry.ts
Normal file
File diff suppressed because it is too large
Load Diff
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;
|
||||
}
|
||||
10
src/v1/analytics/README.md
Normal file
10
src/v1/analytics/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# V1 Analytics
|
||||
|
||||
V1 Analytics reuses the existing Analytics Actor for platform, Q-score, and activity reads.
|
||||
|
||||
The added responsibility here is the nightly improvement loop:
|
||||
|
||||
1. Read Grow events, service events, Q-score signals, and conversation summaries.
|
||||
2. Generate validated improvement signal objects with the Vercel AI SDK.
|
||||
3. Apply those signals to the V1 Curator as events.
|
||||
4. The Curator uses them on the next day when shaping tasks and nudges.
|
||||
136
src/v1/analytics/analytics-actor.ts
Normal file
136
src/v1/analytics/analytics-actor.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { generateText, tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { desc, eq, gte } from "drizzle-orm";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { config } from "../../config.js";
|
||||
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 { curatorService } from "../curator/curator-actor.js";
|
||||
import { curatorImprovementSignalSchema } from "../curator/curator-types.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
|
||||
}
|
||||
|
||||
const signalsSchema = z.object({
|
||||
signals: z.array(curatorImprovementSignalSchema.omit({ userId: true, date: true }).extend({
|
||||
id: z.string(),
|
||||
})).max(5),
|
||||
});
|
||||
|
||||
function parseJsonObject(text: string) {
|
||||
const cleaned = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
|
||||
try {
|
||||
return JSON.parse(cleaned);
|
||||
} catch {
|
||||
const start = cleaned.indexOf("{");
|
||||
const end = cleaned.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end <= start) throw new Error("analytics_actor_invalid_json");
|
||||
return JSON.parse(cleaned.slice(start, end + 1));
|
||||
}
|
||||
}
|
||||
|
||||
export const analyticsTools = {
|
||||
read_platform_events: tool({
|
||||
description: "Read latest platform events.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(100).default(50) }),
|
||||
execute: async ({ limit }) => db.select().from(growEvents).orderBy(desc(growEvents.occurredAt)).limit(limit),
|
||||
}),
|
||||
read_user_service_events: tool({
|
||||
description: "Read latest service events for a user.",
|
||||
inputSchema: z.object({ userId: z.string(), limit: z.number().int().min(1).max(100).default(50) }),
|
||||
execute: async ({ userId, limit }) => db.select().from(growEvents).where(eq(growEvents.userId, userId)).orderBy(desc(growEvents.occurredAt)).limit(limit),
|
||||
}),
|
||||
read_conversation_summaries: tool({
|
||||
description: "Read latest conversation messages for a user.",
|
||||
inputSchema: z.object({ userId: z.string(), limit: z.number().int().min(1).max(100).default(30) }),
|
||||
execute: async ({ userId, limit }) => db.select().from(growConversationMessages).where(eq(growConversationMessages.userId, userId)).orderBy(desc(growConversationMessages.createdAt)).limit(limit),
|
||||
}),
|
||||
generate_improvement_signals: tool({
|
||||
description: "Generate curator improvement signals for a user.",
|
||||
inputSchema: z.object({ userId: z.string(), date: z.string() }),
|
||||
execute: async ({ userId, date }) => v1AnalyticsActor.generateImprovementSignals({ userId, date }),
|
||||
}),
|
||||
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 }) => curatorService.applyImprovementSignals({ userId, date, signals }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const v1AnalyticsActor = {
|
||||
async getPlatform() {
|
||||
return getClient().analyticsActor.getOrCreate(["platform"]).getPlatform();
|
||||
},
|
||||
|
||||
async getUserQscore(input: { userId: string }) {
|
||||
return getClient().analyticsActor.getOrCreate(["user", input.userId]).getUserQscore(input);
|
||||
},
|
||||
|
||||
async getUserActivity(input: { userId: string }) {
|
||||
return getClient().analyticsActor.getOrCreate(["user", input.userId]).getUserActivity(input);
|
||||
},
|
||||
|
||||
async generateImprovementSignals(input: { userId: string; date: string }) {
|
||||
const events = await db.select().from(growEvents).where(eq(growEvents.userId, input.userId)).orderBy(desc(growEvents.occurredAt)).limit(80);
|
||||
const messages = await db.select().from(growConversationMessages).where(eq(growConversationMessages.userId, input.userId)).orderBy(desc(growConversationMessages.createdAt)).limit(40);
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: getConversationModel(),
|
||||
system: [
|
||||
"You are the GrowQR V1 Analytics Actor. Generate small overnight improvement signals for the Curator.",
|
||||
"Return JSON only. Shape: {\"signals\": [...]}. Do not use markdown.",
|
||||
"Use ASCII punctuation.",
|
||||
].join("\n"),
|
||||
prompt: JSON.stringify({ date: input.date, events, messages }).slice(0, 20000),
|
||||
});
|
||||
const parsed = signalsSchema.parse(parseJsonObject(result.text));
|
||||
return parsed.signals.map((signal) => curatorImprovementSignalSchema.parse({ ...signal, userId: input.userId, date: input.date }));
|
||||
} catch {
|
||||
return [curatorImprovementSignalSchema.parse({
|
||||
id: `improvement:${input.userId}:${input.date}:streak`,
|
||||
userId: input.userId,
|
||||
date: input.date,
|
||||
priority: 50,
|
||||
reason: "Keep service usage meaningful and preserve streak momentum.",
|
||||
nudgeText: "Pick one task that opens a real service today.",
|
||||
status: "created",
|
||||
})];
|
||||
}
|
||||
},
|
||||
|
||||
async applyImprovementSignals(input: { userId: string; date: string; signals: z.infer<typeof curatorImprovementSignalSchema>[] }) {
|
||||
return curatorService.applyImprovementSignals(input);
|
||||
},
|
||||
|
||||
async runNightly(input: { date: string; userId?: string }) {
|
||||
const userRows = input.userId
|
||||
? [{ id: input.userId }]
|
||||
: await db
|
||||
.selectDistinct({ id: growEvents.userId })
|
||||
.from(growEvents)
|
||||
.where(gte(growEvents.occurredAt, new Date(Date.now() - 7 * 86400000)))
|
||||
.limit(200);
|
||||
let improvementSignalsCreated = 0;
|
||||
for (const user of userRows) {
|
||||
if (!user.id) continue;
|
||||
const signals = await this.generateImprovementSignals({ userId: user.id, date: input.date });
|
||||
improvementSignalsCreated += signals.length;
|
||||
await this.applyImprovementSignals({ userId: user.id, date: input.date, signals });
|
||||
}
|
||||
return { date: input.date, usersProcessed: userRows.length, improvementSignalsCreated };
|
||||
},
|
||||
|
||||
async explain(input: { userId: string; question: string }) {
|
||||
const answer = await generateText({
|
||||
model: getConversationModel(),
|
||||
system: "You are the GrowQR V1 Analytics Actor. Explain analytics and Q-score movement concisely.",
|
||||
prompt: input.question,
|
||||
tools: analyticsTools,
|
||||
});
|
||||
return { answer: answer.text };
|
||||
},
|
||||
};
|
||||
169
src/v1/analytics/analytics-routes.ts
Normal file
169
src/v1/analytics/analytics-routes.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
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);
|
||||
|
||||
app.get("/platform", async (c) => c.json(await v1AnalyticsActor.getPlatform()));
|
||||
|
||||
app.get("/user/qscore", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await v1AnalyticsActor.getUserQscore({ userId }));
|
||||
});
|
||||
|
||||
app.get("/user/activity", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
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({
|
||||
date: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
runForAll: z.boolean().optional(),
|
||||
}).parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await v1AnalyticsActor.runNightly({
|
||||
date: body.date ?? new Date().toISOString().slice(0, 10),
|
||||
userId: body.runForAll ? undefined : (body.userId ?? userId),
|
||||
}));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
30
src/v1/curator/README.md
Normal file
30
src/v1/curator/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# V1 Curator
|
||||
|
||||
V1 replaces the old Daily Mission path with a single Curator layer.
|
||||
|
||||
- Curator owns the 30 day plan JSON, today's tasks, streak state, service direction, and task status.
|
||||
- Conversation Actor still owns chat persistence and long lived conversations.
|
||||
- Analytics Actor owns the nightly loop and writes improvement signals back into Curator events.
|
||||
- Services still own their workflows. Curator tools prepare handoffs and routes.
|
||||
|
||||
Completion is event gated. A checkbox or chat message cannot complete a task unless a matching service or platform event exists.
|
||||
|
||||
## Service Curation Layer
|
||||
|
||||
- `curator-icp-playbooks.ts` defines ICP playbooks and maps each persona goal to registry-backed service actions.
|
||||
- `curator-user-context.ts` assembles deterministic user context from Grow events and QScore projection state.
|
||||
- `curator-prompt-builder.ts` builds the LLM-ready curation prompt and stable prompt hash.
|
||||
- `curator-store.ts` keeps generation idempotent by storing sprint starts in `grow_events` with the plan version, ICP, user context, prompt hash, playbook, plan hash, and 30-day plan days.
|
||||
- `curator-service-links.ts` is the link builder over the Service Registry. Generated tasks use it to produce actionable frontend deep links.
|
||||
- `POST /v1/curator/curation/preview` accepts optional `icpId`, `goals`, and `userContext` overrides and returns the assembled prompt, ICP playbook, idempotency hashes, Sunday-start `calendarWeeks`, `days` (all 30 days), `closeoutDays` (day 29-30), and deep-linked tasks.
|
||||
|
||||
## Curator Onboarding Loop
|
||||
|
||||
- `curator-onboarding-loop.ts` runs once after onboarding completion and creates the user's persisted 30-day streak plan through the curation layer.
|
||||
- Trigger paths:
|
||||
- Grow event ingestion: `onboarding.completed`, `user.onboarding.completed`, `profile.onboarding.completed`, or payloads/preferences with `onboarding.completed_at`.
|
||||
- User profile updates: `PATCH /api/users/me` runs the loop when user-service returns onboarding preferences with `completed_at`.
|
||||
- QA retry: `POST /v1/curator/onboarding/run` accepts optional `completedAt` and returns `ready` or `already_ready`.
|
||||
- Before generation, the loop snapshots onboarding context into `grow_events` so curation sees the user-service profile/preferences. Event-only triggers also attempt an internal user-service fetch via the service-token path.
|
||||
- Idempotency is based on the one-time `curator.onboarding_plan.ready` event. Retries do not duplicate the plan-ready analytics event or in-app notification.
|
||||
- The loop stores the sprint as `curator.sprint.started`, emits `curator.onboarding_plan.ready` with weekly themes and Day 1 task links, and creates a persistent home notification pointing users to their active plan.
|
||||
221
src/v1/curator/curator-actor.ts
Normal file
221
src/v1/curator/curator-actor.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
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";
|
||||
import { runCuratorChat } from "./curator-agent.js";
|
||||
import { prepareHandoffForTask } from "./curator-tools.js";
|
||||
import type { CuratorIcpId } from "./curator-icp-playbooks.js";
|
||||
import { runCuratorOnboardingLoop } from "./curator-onboarding-loop.js";
|
||||
|
||||
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;
|
||||
const plan = curatorPlanSchema.parse(await buildCuratorPlan(input.userId, { startDate, endDate, goals: input.goals }));
|
||||
await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.plan.generated",
|
||||
payload: {
|
||||
startDate,
|
||||
endDate,
|
||||
planId: plan.id,
|
||||
durationDays: plan.durationDays,
|
||||
goals: input.goals ?? plan.goals,
|
||||
weekCount: plan.weeks.length,
|
||||
dayCount: plan.days.length,
|
||||
plan,
|
||||
},
|
||||
});
|
||||
return { plan };
|
||||
},
|
||||
|
||||
async getPlan(input: { userId: string; startDate?: string; endDate?: string }) {
|
||||
return this.generatePlanRange(input);
|
||||
},
|
||||
|
||||
async previewCuration(input: { userId: string; startDate?: string; icpId?: CuratorIcpId; goals?: string[]; userContext?: Record<string, unknown> }) {
|
||||
return buildServiceCurationPreview(input);
|
||||
},
|
||||
|
||||
async runOnboardingLoop(input: { userId: string; completedAt?: string }) {
|
||||
return runCuratorOnboardingLoop({
|
||||
userId: input.userId,
|
||||
completedAt: input.completedAt,
|
||||
source: "curator-api",
|
||||
});
|
||||
},
|
||||
|
||||
async getToday(input: { userId: string; date?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, date));
|
||||
await emitCuratorEvent({ userId: input.userId, type: "curator.day.opened", payload: { date } });
|
||||
return {
|
||||
date,
|
||||
plan: sprint.plan,
|
||||
tasks: sprint.todayTasks,
|
||||
streak: sprint.streak,
|
||||
completedCount: sprint.completedCount,
|
||||
totalCount: sprint.totalCount,
|
||||
sprint,
|
||||
source: "curator-v1" as const,
|
||||
};
|
||||
},
|
||||
|
||||
async getSprint(input: { userId: string; date?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const sprint = curatorSprintResponseSchema.parse(await buildCuratorSprint(input.userId, date));
|
||||
await emitCuratorEvent({ userId: input.userId, type: "curator.day.opened", payload: { date, sprintId: sprint.sprintId } });
|
||||
return sprint;
|
||||
},
|
||||
|
||||
async chat(input: { userId: string; conversationId?: string; date?: string; taskId?: string; subtaskIndex?: number; subtask?: string; messages: Array<{ role: "user" | "assistant"; content: string }> }) {
|
||||
return runCuratorChat(input);
|
||||
},
|
||||
|
||||
async startTask(input: { userId: string; taskId: string; date?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
if (!task) throw new Error("curator_task_not_found");
|
||||
const event = await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.task.started",
|
||||
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
|
||||
payload: { taskId: task.id, date },
|
||||
});
|
||||
return { task: { ...task, status: "started" as const }, eventId: event.id };
|
||||
},
|
||||
|
||||
async prepareTaskHandoff(input: { userId: string; taskId: string; date?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
if (!task) throw new Error("curator_task_not_found");
|
||||
if (task.serviceId) return prepareHandoffForTask(input.userId, task, task.serviceId);
|
||||
throw new Error("curator_task_has_no_handoff");
|
||||
},
|
||||
|
||||
async completeTask(input: { userId: string; taskId: string; date?: string; reason?: string }) {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
if (!task) throw new Error("curator_task_not_found");
|
||||
const reason = input.reason ?? "subtasks_completed";
|
||||
const allowDirectServiceCompletion = task.serviceId === "qscore-service" && reason === "qscore_review_opened";
|
||||
if (task.serviceId && !allowDirectServiceCompletion) {
|
||||
throw new Error("curator_service_task_requires_service_event");
|
||||
}
|
||||
const event = await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.task.completed",
|
||||
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
|
||||
payload: { taskId: task.id, date, reason },
|
||||
});
|
||||
return { task: { ...task, status: "completed" as const }, eventId: event.id };
|
||||
},
|
||||
|
||||
async recordServiceImpact(input: { userId: string; eventId: string }) {
|
||||
const streak = await buildCuratorStreak(input.userId);
|
||||
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
|
||||
return { matched: true, completedTasks: sprint.todayTasks, streak, sprint };
|
||||
},
|
||||
|
||||
async applyImprovementSignals(input: { userId: string; date: string; signals: CuratorImprovementSignal[] }) {
|
||||
for (const signal of input.signals) {
|
||||
await emitCuratorEvent({ userId: input.userId, type: "curator.improvement_signal.applied", payload: { signal } });
|
||||
}
|
||||
const sprint = await buildCuratorSprint(input.userId, input.date);
|
||||
return { applied: input.signals.length, plan: sprint.plan, sprint };
|
||||
},
|
||||
|
||||
async getState(input: { userId: string }) {
|
||||
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
|
||||
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 };
|
||||
},
|
||||
},
|
||||
});
|
||||
684
src/v1/curator/curator-agent.ts
Normal file
684
src/v1/curator/curator-agent.ts
Normal file
@@ -0,0 +1,684 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { generateText } from "ai";
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents } from "../../db/schema.js";
|
||||
import { addMessagePg, createConversationPg, ensureCuratorTaskConversationPg, getConversationMetadataPg, listMessagesPg } from "../../grow/persistence.js";
|
||||
import { generateConversationResponse, getConversationModel } from "../../actors/conversation/agent.js";
|
||||
import { buildCuratorTasks, todayIsoDate } from "./curator-store.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import type { CuratorChatResponse, CuratorSubtaskStatusUpdate } from "./curator-types.js";
|
||||
import { prepareHandoffForTask } from "./curator-tools.js";
|
||||
import { fallbackCuratorRole, resolveCuratorTargetRole } from "./curator-user-context.js";
|
||||
|
||||
const CURATOR_STREAK_CHAT_PROMPT = path.resolve(process.cwd(), "prompts/curator/streak-chat.md");
|
||||
|
||||
const FALLBACK_CURATOR_STREAK_CHAT_PROMPT = [
|
||||
"You are the GrowQR V1 Curator in a daily or weekly streak chat modal.",
|
||||
"Ask at most one clarifying question before a service preview handoff is ready.",
|
||||
"If no target role is known for interview or roleplay, ask exactly: What role are you targeting?",
|
||||
"If the target role is known, do not ask again. Proceed to a short summary and let the dashboard show the CTA.",
|
||||
"Never include internal URLs, setup routes, API paths, JSON, or tool names in chat text.",
|
||||
"Interview defaults: type=behavioral, difficulty=medium, duration=5.",
|
||||
"Use ASCII punctuation only.",
|
||||
].join("\n");
|
||||
|
||||
async function loadCuratorStreakPrompt() {
|
||||
return readFile(CURATOR_STREAK_CHAT_PROMPT, "utf8").catch(() => FALLBACK_CURATOR_STREAK_CHAT_PROMPT);
|
||||
}
|
||||
|
||||
const chatExtractSchema = z.object({
|
||||
summary: z.string(),
|
||||
userGoal: z.string().optional(),
|
||||
serviceIntent: z.string().optional(),
|
||||
shouldPrepareHandoff: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const subtaskStatusUpdateSchema = z.object({
|
||||
status: z.enum(["needs_more_context", "ready_to_capture", "handoff_ready"]),
|
||||
summary: z.string().min(1).max(280),
|
||||
confidence: z.number().min(0).max(1).default(0.5),
|
||||
nextMissingInfo: z.string().max(180).optional(),
|
||||
});
|
||||
|
||||
function parseJsonObject(text: string) {
|
||||
const trimmed = text.trim();
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
const match = trimmed.match(/\{[\s\S]*\}/);
|
||||
if (!match) throw new Error("model_did_not_return_json");
|
||||
return JSON.parse(match[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function buildId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function sanitize(text: string) {
|
||||
const withoutControlLines = text
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return true;
|
||||
if (/^(date|curator task id|focused subtask|curator task title|curator task context|curator task subtasks|curator service|expected completion events|captured task memory|task title|task service|task context|all task subtasks|visible history):/i.test(trimmed)) return false;
|
||||
if (/setup route|mission instance id|curator task id|access the setup at/i.test(trimmed)) return false;
|
||||
if (/\/agents\/(interview|roleplay|resume|qscore)|\/analytics\?|\/social\?|\/pathways\?/i.test(trimmed)) return false;
|
||||
if (/^```/.test(trimmed)) return false;
|
||||
return true;
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
const withoutJsonEnvelope = withoutControlLines.replace(/^\s*\{[\s\S]*"reply"\s*:\s*"([^"]+)"[\s\S]*\}\s*$/i, "$1");
|
||||
const withoutRoutes = withoutJsonEnvelope
|
||||
.replace(/\b(?:Interview|Roleplay|Resume|Q Score)?\s*setup route:\s*\/\S+/gi, "")
|
||||
.replace(/\/agents\/(?:interview|roleplay|resume|qscore)\/?\S*/gi, "")
|
||||
.replace(/\/analytics\?\S*/gi, "")
|
||||
.replace(/\/social\?\S*/gi, "")
|
||||
.replace(/\/pathways\?\S*/gi, "");
|
||||
return withoutRoutes
|
||||
.replace(/[\u2013\u2014]/g, "-")
|
||||
.replace(/[\u2018\u2019]/g, "'")
|
||||
.replace(/[\u201C\u201D]/g, '"')
|
||||
.replace(/\u2026/g, "...")
|
||||
.replace(/^\s*(Perfect|Great|Absolutely|Sure)[.!,:;-]*\s*/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function pushField(lines: string[], label: string, value?: string | number | null) {
|
||||
if (value === undefined || value === null) return;
|
||||
const stringValue = String(value).trim();
|
||||
if (!stringValue) return;
|
||||
lines.push(`${label}: ${stringValue}`);
|
||||
}
|
||||
|
||||
function pushList(lines: string[], label: string, values?: string[]) {
|
||||
const cleanValues = values?.map((value) => value.trim()).filter(Boolean) ?? [];
|
||||
if (cleanValues.length === 0) return;
|
||||
lines.push(`${label}: ${cleanValues.join(" | ")}`);
|
||||
}
|
||||
|
||||
type CuratorMessage = Awaited<ReturnType<typeof listMessagesPg>>[number];
|
||||
|
||||
async function capturedSubtaskMemory(userId: string, taskId?: string) {
|
||||
if (!taskId) return [];
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
eq(growEvents.type, "curator.subtask.captured"),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(80);
|
||||
|
||||
return rows
|
||||
.map((row) => row.payload ?? {})
|
||||
.filter((payload) => payload.taskId === taskId)
|
||||
.map((payload) => ({
|
||||
subtaskIndex: typeof payload.subtaskIndex === "number" ? payload.subtaskIndex : undefined,
|
||||
subtask: typeof payload.subtask === "string" ? payload.subtask : undefined,
|
||||
summary: typeof (payload.statusUpdate as any)?.summary === "string" ? (payload.statusUpdate as any).summary : undefined,
|
||||
}))
|
||||
.filter((item) => item.summary)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
function visibleCuratorMessages(messages: CuratorMessage[]) {
|
||||
const filtered = messages.filter((message) => {
|
||||
const content = message.content.trim();
|
||||
if (message.role === "user") {
|
||||
if (/^start$/i.test(content)) return false;
|
||||
if (/^i opened /i.test(content)) return false;
|
||||
return true;
|
||||
}
|
||||
return !/what should i capture/i.test(content);
|
||||
});
|
||||
return filtered.filter((message, index) => {
|
||||
const previous = filtered[index - 1];
|
||||
return !previous || previous.role !== message.role || previous.content.trim() !== message.content.trim();
|
||||
});
|
||||
}
|
||||
|
||||
function usefulUserMessages(messages: CuratorMessage[]) {
|
||||
return messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.content.trim())
|
||||
.filter((content) => content && !/^start$/i.test(content) && !content.toLowerCase().includes("i opened "));
|
||||
}
|
||||
|
||||
function targetRoleState(messages: CuratorMessage[], latest: string) {
|
||||
const userMessages = usefulUserMessages(messages);
|
||||
const all = [...userMessages, latest.trim()].filter(Boolean);
|
||||
const lowerAll = all.join("\n").toLowerCase();
|
||||
const shortAnswers = all.filter((content) => content.length <= 80);
|
||||
const targetRole = shortAnswers.find((content) => {
|
||||
const lower = content.toLowerCase();
|
||||
return /manager|engineer|designer|analyst|developer|product|marketing|sales|founder|consultant|operator|lead|head|director/.test(lower);
|
||||
});
|
||||
const currentBackground = all.find((content) => {
|
||||
const lower = content.toLowerCase();
|
||||
return lower.includes("currently") || lower.includes("right now") || lower.includes("i am ") || lower.includes("i'm ") || lower.includes("my background") || lower.includes("experience");
|
||||
});
|
||||
const constraints = all.find((content) => {
|
||||
const lower = content.toLowerCase();
|
||||
return lower.includes("month") || lower.includes("week") || lower.includes("salary") || lower.includes("remote") || lower.includes("location") || lower.includes("visa") || lower.includes("timeline");
|
||||
});
|
||||
return {
|
||||
targetRole,
|
||||
currentBackground,
|
||||
constraints,
|
||||
hasAskedCurrent: lowerAll.includes("current background") || lowerAll.includes("current role") || lowerAll.includes("where you are starting"),
|
||||
hasAskedConstraints: lowerAll.includes("constraint") || lowerAll.includes("timeline"),
|
||||
};
|
||||
}
|
||||
|
||||
const CURATOR_PROMPT_FILE = path.resolve(process.cwd(), "prompts", "curator-v1.md");
|
||||
|
||||
const DEFAULT_CURATOR_PROMPT = `You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.
|
||||
Own 30 day direction, streak continuity, and service handoff decisions.
|
||||
Do not ask the same question twice.
|
||||
Use captured task memory and keep the user on the focused subtask.
|
||||
When the user has answered enough, summarize what was captured and stop.
|
||||
If more detail is needed, ask exactly one follow-up question.
|
||||
For service work, prepare preview-oriented handoffs once enough context exists.`;
|
||||
|
||||
async function loadCuratorPromptTemplate() {
|
||||
try {
|
||||
return await readFile(CURATOR_PROMPT_FILE, "utf8");
|
||||
} catch {
|
||||
return DEFAULT_CURATOR_PROMPT;
|
||||
}
|
||||
}
|
||||
|
||||
async function curatorSystemAddendum(input: {
|
||||
date: string;
|
||||
taskId?: string;
|
||||
subtaskIndex?: number;
|
||||
subtask?: string;
|
||||
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
|
||||
taskMemory?: Array<{ subtaskIndex?: number; subtask?: string; summary?: string }>;
|
||||
promptText: string;
|
||||
targetRole?: string;
|
||||
}) {
|
||||
const template = await loadCuratorPromptTemplate();
|
||||
const lines = [
|
||||
input.promptText,
|
||||
"",
|
||||
...template
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean),
|
||||
];
|
||||
pushField(lines, "Known target role", input.targetRole);
|
||||
pushField(lines, "Date", input.date);
|
||||
pushField(lines, "Curator task id", input.taskId);
|
||||
pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined);
|
||||
pushField(lines, "Focused subtask title", input.subtask);
|
||||
pushField(lines, "Curator task title", input.task?.title);
|
||||
pushField(lines, "Curator task context", input.task?.contextNarrative);
|
||||
pushList(lines, "Curator task subtasks", input.task?.subtasks);
|
||||
pushField(lines, "Curator service", input.task?.serviceName);
|
||||
pushList(lines, "Expected completion events", input.task?.completionEvents);
|
||||
const memory = input.taskMemory
|
||||
?.map((item) => {
|
||||
if (!item.summary) return "";
|
||||
const subtask = item.subtask?.trim() || "Subtask";
|
||||
const index = Number.isInteger(item.subtaskIndex) ? `[${item.subtaskIndex}] ` : "";
|
||||
return `${index}${subtask}: ${item.summary}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
pushList(lines, "Captured task memory", memory);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function curatorTaskKey(taskId?: string, subtaskIndex?: number) {
|
||||
if (!taskId) return undefined;
|
||||
return `${taskId}:${subtaskIndex ?? "task"}`;
|
||||
}
|
||||
|
||||
function firstTurnPrompt(input: {
|
||||
subtask?: string;
|
||||
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
|
||||
targetRole?: string;
|
||||
}) {
|
||||
return [
|
||||
`The user opened this focused subtask: ${input.subtask ?? input.task?.title ?? "curator task"}.`,
|
||||
"Generate the first live conversational question for this exact subtask.",
|
||||
input.targetRole ? `Known target role: ${input.targetRole}. Do not ask for the role again.` : "If this is an interview or roleplay task and no target role is known, ask exactly: What role are you targeting?",
|
||||
"Ask only one question. Do not use canned wording. Do not prepare any service handoff yet.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function isExplicitHandoffRequest(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (/^start$/i.test(trimmed)) return false;
|
||||
return /\b(start|open|launch|begin|set up|setup|create|generate|room|ready|go|give)\b/i.test(trimmed);
|
||||
}
|
||||
|
||||
function shouldPrepareServiceHandoff(status: CuratorSubtaskStatusUpdate, task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number]) {
|
||||
if (!task?.serviceId) return false;
|
||||
return status.status === "ready_to_capture" || status.status === "handoff_ready";
|
||||
}
|
||||
|
||||
function isPreviewHandoffService(task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number]) {
|
||||
return task?.serviceId === "interview-service" || task?.serviceId === "roleplay-service";
|
||||
}
|
||||
|
||||
function servicePreviewSummary(task: Awaited<ReturnType<typeof buildCuratorTasks>>[number], targetRole?: string) {
|
||||
const role = fallbackCuratorRole(targetRole);
|
||||
if (task.serviceId === "interview-service") {
|
||||
return `Prepared a 5-minute behavioral interview preview for ${role}.`;
|
||||
}
|
||||
if (task.serviceId === "roleplay-service") {
|
||||
return `Prepared a 5-minute roleplay preview for ${role}.`;
|
||||
}
|
||||
return `${task.serviceName} handoff is ready.`;
|
||||
}
|
||||
|
||||
function fallbackCuratorReply(input: {
|
||||
latest: string;
|
||||
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
|
||||
subtask?: string;
|
||||
targetRole?: string;
|
||||
}) {
|
||||
const latest = input.latest.trim();
|
||||
const lowerTitle = input.task?.title.toLowerCase() ?? "";
|
||||
const lowerSubtask = input.subtask?.toLowerCase() ?? "";
|
||||
const role = fallbackCuratorRole(input.targetRole);
|
||||
|
||||
if ((input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") && !input.targetRole) {
|
||||
return "What role are you targeting?";
|
||||
}
|
||||
|
||||
if (/^start$/i.test(latest)) {
|
||||
if (input.task?.serviceId === "qscore-service") {
|
||||
return "Open your current Q Score and tell me which readiness signal looks weakest today.";
|
||||
}
|
||||
if (input.task?.serviceId === "resume-service") {
|
||||
return "Upload your current resume or paste three recent wins so I can anchor this proof task.";
|
||||
}
|
||||
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
|
||||
return `I have your target role as ${role}. Say start when you want the preview opened.`;
|
||||
}
|
||||
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
|
||||
return "Which role family do you want this sprint to optimize toward?";
|
||||
}
|
||||
if (input.task?.taskType === "measurement") {
|
||||
return "Open the current view and tell me the one gap or signal that stands out most.";
|
||||
}
|
||||
if (input.task?.taskType === "proof") {
|
||||
return "Share the strongest proof you already have so we can build from something real.";
|
||||
}
|
||||
return "What is the single outcome you want from this task today?";
|
||||
}
|
||||
|
||||
if (input.task?.serviceId === "interview-service" || input.task?.serviceId === "roleplay-service") {
|
||||
if (isExplicitHandoffRequest(latest)) {
|
||||
return servicePreviewSummary(input.task, input.targetRole);
|
||||
}
|
||||
return `Captured ${role} as the target role. Say start when you want the preview opened.`;
|
||||
}
|
||||
|
||||
if (lowerTitle.includes("role direction") || lowerSubtask.includes("role direction")) {
|
||||
return `Captured ${latest}. I will use that as the role direction for this sprint.`;
|
||||
}
|
||||
|
||||
if (input.task?.serviceId === "resume-service") {
|
||||
return "Captured. Open the resume flow when you are ready to turn this into proof.";
|
||||
}
|
||||
|
||||
if (input.task?.serviceId === "qscore-service") {
|
||||
return "Captured. Open the Q Score view and save the main readiness gap you want to work on.";
|
||||
}
|
||||
|
||||
if (input.task?.taskType === "measurement") {
|
||||
return "Captured the baseline signal for today.";
|
||||
}
|
||||
|
||||
if (input.task?.taskType === "proof") {
|
||||
return "Captured the proof point for today.";
|
||||
}
|
||||
|
||||
if (input.task?.taskType === "practice") {
|
||||
return "Captured the practice focus for today.";
|
||||
}
|
||||
|
||||
return "Captured. We can use this to move the task forward.";
|
||||
}
|
||||
|
||||
async function evaluateSubtaskStatus(input: {
|
||||
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
|
||||
subtask?: string;
|
||||
subtaskIndex?: number;
|
||||
latest: string;
|
||||
reply: string;
|
||||
history: CuratorMessage[];
|
||||
}): Promise<CuratorSubtaskStatusUpdate> {
|
||||
if (!input.subtask || /^start$/i.test(input.latest.trim())) {
|
||||
return { status: "needs_more_context", summary: "Subtask opened.", confidence: 0.2 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: getConversationModel(),
|
||||
system: [
|
||||
"You are the GrowQR V1 Curator Actor state evaluator.",
|
||||
"Return JSON only. Do not wrap it in markdown.",
|
||||
"Shape: {\"status\": \"needs_more_context\" | \"ready_to_capture\" | \"handoff_ready\", \"summary\": string, \"confidence\": number, \"nextMissingInfo\"?: string}.",
|
||||
"Evaluate only the focused subtask. Ignore other missions, other subtasks, and later checklist items.",
|
||||
"Use ready_to_capture only when the latest user answer directly satisfies the focused subtask.",
|
||||
"Use needs_more_context if the assistant reply asks another question or if the answer is too vague for this exact subtask.",
|
||||
"Use handoff_ready only when the focused subtask explicitly asks to open or preview a service and the service setup details are present.",
|
||||
"Use handoff_ready when the user explicitly says to start, open, launch, set up, or begin the service and the necessary setup context is already present.",
|
||||
"Never mark ready just because one message exists.",
|
||||
"Use ASCII punctuation only.",
|
||||
].join("\n"),
|
||||
prompt: (() => {
|
||||
const lines: string[] = [];
|
||||
pushField(lines, "Task title", input.task?.title);
|
||||
pushField(lines, "Task service", input.task?.serviceName);
|
||||
pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined);
|
||||
pushField(lines, "Focused subtask", input.subtask);
|
||||
pushField(lines, "Task context", input.task?.contextNarrative);
|
||||
pushList(lines, "All task subtasks", input.task?.subtasks);
|
||||
pushField(lines, "Latest user answer", input.latest);
|
||||
pushField(lines, "Assistant reply", input.reply);
|
||||
pushField(lines, "Visible history", input.history.map((message) => `${message.role}: ${message.content}`).join("\n"));
|
||||
return lines.join("\n");
|
||||
})(),
|
||||
});
|
||||
return subtaskStatusUpdateSchema.parse(parseJsonObject(result.text));
|
||||
} catch (error) {
|
||||
console.warn("curator status evaluation failed; keeping subtask open", {
|
||||
taskId: input.task?.id,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { status: "needs_more_context", summary: "The curator needs one more answer before updating this subtask.", confidence: 0.1 };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCuratorConversation(input: { userId: string; taskId?: string; date: string; subtaskIndex?: number; subtask?: string }) {
|
||||
if (!input.taskId) return createConversationPg(input.userId, "V1 Curator chat");
|
||||
const task = (await buildCuratorTasks(input.userId, input.date)).find((item) => item.id === input.taskId);
|
||||
if (task) {
|
||||
return ensureCuratorTaskConversationPg({
|
||||
userId: input.userId,
|
||||
curatorTaskId: task.id,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
subtask: input.subtask,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
missionId: task.missionId,
|
||||
stageId: task.stageId,
|
||||
title: input.subtask ?? task.title,
|
||||
});
|
||||
}
|
||||
return createConversationPg(input.userId, "V1 Curator chat");
|
||||
}
|
||||
|
||||
export async function runCuratorChat(input: {
|
||||
userId: string;
|
||||
conversationId?: string;
|
||||
taskId?: string;
|
||||
subtaskIndex?: number;
|
||||
subtask?: string;
|
||||
date?: string;
|
||||
messages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
}): Promise<CuratorChatResponse> {
|
||||
const date = input.date ?? todayIsoDate();
|
||||
const expectedTaskKey = curatorTaskKey(input.taskId, input.subtaskIndex);
|
||||
let conversation = input.conversationId ? { id: input.conversationId } : undefined;
|
||||
if (conversation?.id && expectedTaskKey) {
|
||||
const metadata = await getConversationMetadataPg(input.userId, conversation.id);
|
||||
if (metadata?.curatorTaskKey !== expectedTaskKey) {
|
||||
conversation = undefined;
|
||||
}
|
||||
}
|
||||
conversation ??= await ensureCuratorConversation({
|
||||
userId: input.userId,
|
||||
taskId: input.taskId,
|
||||
date,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
subtask: input.subtask,
|
||||
});
|
||||
const latest = [...input.messages].reverse().find((message) => message.role === "user")?.content?.trim() ?? "start";
|
||||
const tasks = await buildCuratorTasks(input.userId, date);
|
||||
const task = input.taskId ? tasks.find((item) => item.id === input.taskId) : undefined;
|
||||
const taskMemory = await capturedSubtaskMemory(input.userId, input.taskId);
|
||||
const promptText = await loadCuratorStreakPrompt();
|
||||
|
||||
await addMessagePg(input.userId, {
|
||||
id: buildId("user"),
|
||||
conversationId: conversation.id,
|
||||
role: "user",
|
||||
sender: "User",
|
||||
content: latest,
|
||||
});
|
||||
const conversationHistory = visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id));
|
||||
const targetRole = await resolveCuratorTargetRole({
|
||||
userId: input.userId,
|
||||
task,
|
||||
latest,
|
||||
history: conversationHistory,
|
||||
});
|
||||
|
||||
const isInitialOpen = /^start$/i.test(latest);
|
||||
if (isInitialOpen && isPreviewHandoffService(task) && targetRole) {
|
||||
const statusUpdate: CuratorSubtaskStatusUpdate = {
|
||||
status: "handoff_ready",
|
||||
summary: servicePreviewSummary(task!, targetRole),
|
||||
confidence: 0.95,
|
||||
};
|
||||
const handoff = await prepareHandoffForTask(input.userId, task!, task!.serviceId, targetRole);
|
||||
const reply = sanitize(statusUpdate.summary);
|
||||
|
||||
await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.subtask.captured",
|
||||
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
|
||||
payload: {
|
||||
taskId: input.taskId,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
subtask: input.subtask,
|
||||
statusUpdate,
|
||||
},
|
||||
});
|
||||
|
||||
await addMessagePg(input.userId, {
|
||||
id: buildId("assistant"),
|
||||
conversationId: conversation.id,
|
||||
role: "assistant",
|
||||
sender: "V1 Curator",
|
||||
content: reply,
|
||||
});
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
taskId: input.taskId,
|
||||
reply,
|
||||
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
|
||||
statusUpdate,
|
||||
handoff,
|
||||
};
|
||||
}
|
||||
|
||||
if (isInitialOpen && isPreviewHandoffService(task) && !targetRole) {
|
||||
const statusUpdate: CuratorSubtaskStatusUpdate = {
|
||||
status: "needs_more_context",
|
||||
summary: "Target role needed before preparing the preview.",
|
||||
confidence: 0.4,
|
||||
nextMissingInfo: "target role",
|
||||
};
|
||||
const reply = "What role are you targeting?";
|
||||
|
||||
await addMessagePg(input.userId, {
|
||||
id: buildId("assistant"),
|
||||
conversationId: conversation.id,
|
||||
role: "assistant",
|
||||
sender: "V1 Curator",
|
||||
content: reply,
|
||||
});
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
taskId: input.taskId,
|
||||
reply,
|
||||
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
|
||||
statusUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
let reply = "";
|
||||
let usedFallbackReply = false;
|
||||
try {
|
||||
try {
|
||||
const extract = await generateText({
|
||||
model: getConversationModel(),
|
||||
system: [
|
||||
"Extract compact curator memory from the user's latest message.",
|
||||
"Return JSON only: {\"summary\": string, \"userGoal\"?: string, \"serviceIntent\"?: string, \"shouldPrepareHandoff\": boolean}.",
|
||||
"Use ASCII punctuation only.",
|
||||
].join("\n"),
|
||||
prompt: (() => {
|
||||
const lines: string[] = [];
|
||||
pushField(lines, "Task", task?.title);
|
||||
pushField(lines, "Subtask", input.subtask);
|
||||
pushField(lines, "Service", task?.serviceName);
|
||||
pushField(lines, "Message", latest);
|
||||
return lines.join("\n");
|
||||
})(),
|
||||
});
|
||||
const parsedExtract = chatExtractSchema.parse(parseJsonObject(extract.text));
|
||||
await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.chat.context_extracted",
|
||||
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
|
||||
payload: { taskId: input.taskId, extract: parsedExtract },
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("curator memory extraction failed; continuing chat", {
|
||||
taskId: input.taskId,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
const modelMessages = conversationHistory.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
}));
|
||||
if (/^start$/i.test(latest) && modelMessages.length === 0) {
|
||||
modelMessages.push({ role: "user", content: firstTurnPrompt({ subtask: input.subtask, task, targetRole }) });
|
||||
}
|
||||
|
||||
const result = await generateConversationResponse(modelMessages, {
|
||||
userId: input.userId,
|
||||
conversationId: conversation.id,
|
||||
missionInstanceId: task?.missionInstanceId,
|
||||
missionId: task?.missionId,
|
||||
stageId: task?.stageId,
|
||||
source: "curator-v1",
|
||||
systemAddendum: await curatorSystemAddendum({ date, taskId: input.taskId, subtaskIndex: input.subtaskIndex, subtask: input.subtask, task, taskMemory, promptText, targetRole }),
|
||||
});
|
||||
reply = sanitize(result.text);
|
||||
if (/what should i capture next/i.test(reply) || !reply) {
|
||||
throw new Error("curator_generation_failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("curator chat generation failed", {
|
||||
taskId: input.taskId,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
subtask: input.subtask,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
reply = sanitize(fallbackCuratorReply({
|
||||
latest,
|
||||
task,
|
||||
subtask: input.subtask,
|
||||
targetRole,
|
||||
}));
|
||||
usedFallbackReply = true;
|
||||
}
|
||||
|
||||
if (!reply) {
|
||||
reply = sanitize(fallbackCuratorReply({
|
||||
latest,
|
||||
task,
|
||||
subtask: input.subtask,
|
||||
targetRole,
|
||||
}));
|
||||
usedFallbackReply = true;
|
||||
}
|
||||
|
||||
let statusUpdate = await evaluateSubtaskStatus({
|
||||
task,
|
||||
subtask: input.subtask,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
latest,
|
||||
reply,
|
||||
history: conversationHistory,
|
||||
});
|
||||
if (task?.serviceId && (isExplicitHandoffRequest(latest) || statusUpdate.status === "ready_to_capture")) {
|
||||
statusUpdate = {
|
||||
status: "handoff_ready",
|
||||
summary: servicePreviewSummary(task, targetRole),
|
||||
confidence: Math.max(statusUpdate.confidence, 0.9),
|
||||
};
|
||||
}
|
||||
if (usedFallbackReply && statusUpdate.status === "needs_more_context" && !statusUpdate.nextMissingInfo) {
|
||||
statusUpdate = {
|
||||
...statusUpdate,
|
||||
summary: reply,
|
||||
};
|
||||
}
|
||||
if (isPreviewHandoffService(task) && !isInitialOpen && usefulUserMessages(conversationHistory).length >= 1) {
|
||||
statusUpdate = {
|
||||
status: "handoff_ready",
|
||||
summary: servicePreviewSummary(task!, targetRole),
|
||||
confidence: Math.max(statusUpdate.confidence, 0.9),
|
||||
};
|
||||
}
|
||||
|
||||
const handoff = shouldPrepareServiceHandoff(statusUpdate, task)
|
||||
? await prepareHandoffForTask(input.userId, task!, task!.serviceId, targetRole)
|
||||
: undefined;
|
||||
|
||||
if (statusUpdate.status !== "needs_more_context") {
|
||||
if (reply.includes("?") || handoff) {
|
||||
reply = sanitize(statusUpdate.summary);
|
||||
}
|
||||
|
||||
await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.subtask.captured",
|
||||
mission: task ? { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId } : undefined,
|
||||
payload: {
|
||||
taskId: input.taskId,
|
||||
subtaskIndex: input.subtaskIndex,
|
||||
subtask: input.subtask,
|
||||
statusUpdate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await addMessagePg(input.userId, {
|
||||
id: buildId("assistant"),
|
||||
conversationId: conversation.id,
|
||||
role: "assistant",
|
||||
sender: "V1 Curator",
|
||||
content: reply,
|
||||
});
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
taskId: input.taskId,
|
||||
reply,
|
||||
messages: visibleCuratorMessages(await listMessagesPg(input.userId, conversation.id)),
|
||||
statusUpdate,
|
||||
handoff,
|
||||
};
|
||||
}
|
||||
36
src/v1/curator/curator-events.ts
Normal file
36
src/v1/curator/curator-events.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { recordGrowEvent } from "../../events/record-grow-event.js";
|
||||
|
||||
function curatorDedupeKey(input: {
|
||||
userId: string;
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}) {
|
||||
const payload = input.payload ?? {};
|
||||
const stableId =
|
||||
payload.taskId ??
|
||||
payload.sprintId ??
|
||||
payload.startDate ??
|
||||
payload.sourceEventId ??
|
||||
payload.eventId ??
|
||||
payload.date;
|
||||
|
||||
return `${input.userId}:${input.type}:${stableId ?? Date.now()}`;
|
||||
}
|
||||
|
||||
export async function emitCuratorEvent(input: {
|
||||
userId: string;
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
mission?: Record<string, unknown>;
|
||||
}) {
|
||||
return recordGrowEvent({
|
||||
source: "curator-v1",
|
||||
type: input.type,
|
||||
category: "mission",
|
||||
userId: input.userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
mission: input.mission,
|
||||
payload: input.payload ?? {},
|
||||
dedupeKey: curatorDedupeKey(input),
|
||||
}, { userId: input.userId, source: "curator-v1" });
|
||||
}
|
||||
103
src/v1/curator/curator-icp-playbooks.ts
Normal file
103
src/v1/curator/curator-icp-playbooks.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { CuratorServiceId, CuratorTaskType } from "./curator-types.js";
|
||||
|
||||
export type CuratorIcpId =
|
||||
| "student_recent_grad"
|
||||
| "intern"
|
||||
| "fresher_early_professional"
|
||||
| "experienced_professional";
|
||||
|
||||
export type CuratorPlaybookAction = {
|
||||
taskType: CuratorTaskType;
|
||||
serviceId: CuratorServiceId;
|
||||
goal: string;
|
||||
action: string;
|
||||
deepLinkIntent: string;
|
||||
expectedSignals: string[];
|
||||
};
|
||||
|
||||
export type CuratorIcpPlaybook = {
|
||||
id: CuratorIcpId;
|
||||
label: string;
|
||||
sprintTheme: string;
|
||||
goal: string;
|
||||
stageLabels: [string, string, string, string, string];
|
||||
serviceActions: CuratorPlaybookAction[];
|
||||
};
|
||||
|
||||
export const CURATOR_ICP_PLAYBOOKS: Record<CuratorIcpId, CuratorIcpPlaybook> = {
|
||||
student_recent_grad: {
|
||||
id: "student_recent_grad",
|
||||
label: "Student / Recent Grad",
|
||||
sprintTheme: "First Role Readiness Sprint",
|
||||
goal: "Have a credible resume, practiced interviews, visible proof, and a clear target role by the end of the sprint.",
|
||||
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
|
||||
serviceActions: [
|
||||
play("measurement", "qscore-service", "baseline", "Establish readiness baseline and weakest drivers.", "analytics", ["qscore baseline", "weakest driver"]),
|
||||
play("proof", "resume-service", "first proof", "Import resume and convert projects into proof bullets.", "resume workspace", ["resume import", "project proof"]),
|
||||
play("proof", "social-branding-service", "visible credibility", "Turn proof into public-safe profile and post artifacts.", "social profile flow", ["headline", "public proof"]),
|
||||
play("practice", "interview-service", "interview confidence", "Run behavioral and project interview reps.", "interview preview", ["mock interview", "feedback"]),
|
||||
play("practice", "matchmaking-service", "role direction", "Shortlist realistic first-role opportunities.", "pathways", ["target roles", "opportunity shortlist"]),
|
||||
],
|
||||
},
|
||||
intern: {
|
||||
id: "intern",
|
||||
label: "Intern",
|
||||
sprintTheme: "Intern-to-Offer Sprint",
|
||||
goal: "Convert internship work into stronger impact proof, return-offer readiness, and external backup options.",
|
||||
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
|
||||
serviceActions: [
|
||||
play("measurement", "qscore-service", "return-offer baseline", "Measure return-offer proof gaps and readiness.", "analytics", ["return offer", "readiness"]),
|
||||
play("proof", "resume-service", "internship proof", "Document project decisions, metrics, and impact bullets.", "resume workspace", ["internship proof", "impact log"]),
|
||||
play("proof", "social-branding-service", "manager visibility", "Prepare manager updates, feedback asks, and visibility notes.", "social profile flow", ["manager update", "feedback ask"]),
|
||||
play("practice", "roleplay-service", "conversion conversations", "Practice mentor, manager, and return-offer asks.", "roleplay builder", ["conversion ask", "stakeholder conversation"]),
|
||||
play("practice", "matchmaking-service", "backup options", "Maintain credible external backup opportunities.", "pathways", ["backup roles", "pipeline"]),
|
||||
],
|
||||
},
|
||||
fresher_early_professional: {
|
||||
id: "fresher_early_professional",
|
||||
label: "Fresher / Early Professional",
|
||||
sprintTheme: "Callback-to-Offer Sprint",
|
||||
goal: "Improve callback conversion, sharpen proof, and build stronger interview confidence across the sprint.",
|
||||
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
|
||||
serviceActions: [
|
||||
play("measurement", "qscore-service", "readiness baseline", "Anchor the sprint in current QScore and missing signals.", "analytics", ["qscore", "readiness"]),
|
||||
play("proof", "resume-service", "role-fit proof", "Tailor resume proof to target roles and outcomes.", "resume workspace", ["resume proof", "role fit"]),
|
||||
play("proof", "social-branding-service", "credibility signal", "Create visible credibility updates from real work.", "social profile flow", ["credibility", "visibility"]),
|
||||
play("practice", "interview-service", "callback conversion", "Run focused interview reps for weak question types.", "interview preview", ["interview practice", "callback"]),
|
||||
play("practice", "roleplay-service", "confidence conversations", "Practice recruiter intros, objections, and pitch clarity.", "roleplay builder", ["recruiter intro", "confidence"]),
|
||||
],
|
||||
},
|
||||
experienced_professional: {
|
||||
id: "experienced_professional",
|
||||
label: "Experienced Professional",
|
||||
sprintTheme: "Leadership Readiness Sprint",
|
||||
goal: "Strengthen leadership proof, senior interview readiness, and authority positioning for the next move.",
|
||||
stageLabels: ["Leadership Baseline + Strategic Proof", "Strategic Positioning + Authority", "Negotiation + Market Action", "Conversion + Closeout", "Momentum + Carry Forward"],
|
||||
serviceActions: [
|
||||
play("measurement", "qscore-service", "senior readiness baseline", "Identify leadership readiness and authority gaps.", "analytics", ["leadership baseline", "authority"]),
|
||||
play("proof", "resume-service", "leadership proof", "Translate execution into scope, team, and business impact.", "resume workspace", ["leadership proof", "business impact"]),
|
||||
play("proof", "social-branding-service", "authority positioning", "Turn strategic lessons into public-safe authority signals.", "social profile flow", ["authority post", "positioning"]),
|
||||
play("practice", "interview-service", "senior interviews", "Practice stakeholder, strategy, and leadership interview reps.", "interview preview", ["senior interview", "strategy"]),
|
||||
play("practice", "roleplay-service", "negotiation and pushback", "Practice compensation, scope, promotion, and objection conversations.", "roleplay builder", ["negotiation", "pushback"]),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function isCuratorIcpId(value: string): value is CuratorIcpId {
|
||||
return value in CURATOR_ICP_PLAYBOOKS;
|
||||
}
|
||||
|
||||
export function curatorPlaybookFor(id: CuratorIcpId) {
|
||||
return CURATOR_ICP_PLAYBOOKS[id] ?? CURATOR_ICP_PLAYBOOKS.fresher_early_professional;
|
||||
}
|
||||
|
||||
function play(
|
||||
taskType: CuratorTaskType,
|
||||
serviceId: CuratorServiceId,
|
||||
goal: string,
|
||||
action: string,
|
||||
deepLinkIntent: string,
|
||||
expectedSignals: string[],
|
||||
): CuratorPlaybookAction {
|
||||
return { taskType, serviceId, goal, action, deepLinkIntent, expectedSignals };
|
||||
}
|
||||
352
src/v1/curator/curator-onboarding-loop.ts
Normal file
352
src/v1/curator/curator-onboarding-loop.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
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";
|
||||
import { recordGrowEvent } from "../../events/record-grow-event.js";
|
||||
import { log } from "../../log.js";
|
||||
import { config } from "../../config.js";
|
||||
import { buildCuratorSprint, todayIsoDate } from "./curator-store.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import type { CuratorSprintResponse } from "./curator-types.js";
|
||||
|
||||
const CURATOR_SOURCE = "curator-v1";
|
||||
const ONBOARDING_READY_EVENT = "curator.onboarding_plan.ready";
|
||||
const ONBOARDING_SKIPPED_EVENT = "curator.onboarding_plan.skipped";
|
||||
const ONBOARDING_CONTEXT_EVENT_TYPES = [
|
||||
"onboarding.snapshot.saved",
|
||||
"onboarding.completed",
|
||||
"user.onboarding.completed",
|
||||
"profile.onboarding.completed",
|
||||
"onboarding_snapshot_saved",
|
||||
"onboarding_completed",
|
||||
"user_onboarding_completed",
|
||||
"profile_onboarding_completed",
|
||||
] as const;
|
||||
|
||||
type OnboardingLoopInput = {
|
||||
userId: string;
|
||||
completedAt?: string | Date | null;
|
||||
sourceEventId?: string;
|
||||
source?: string;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type OnboardingLoopResult =
|
||||
| { status: "ready"; sprint: CuratorSprintResponse; eventId: string }
|
||||
| { status: "already_ready"; readyEventId: string; sprint?: CuratorSprintResponse }
|
||||
| { status: "skipped"; reason: string };
|
||||
|
||||
function isoDateFrom(value: string | Date | null | undefined) {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? todayIsoDate() : value.toISOString().slice(0, 10);
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? todayIsoDate() : parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
return todayIsoDate();
|
||||
}
|
||||
|
||||
function parseCompletedAt(value: unknown): string | undefined {
|
||||
const raw = getString(value);
|
||||
if (!raw) return undefined;
|
||||
const parsed = new Date(raw);
|
||||
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);
|
||||
}
|
||||
|
||||
export function onboardingCompletedAtFromEvent(event: Pick<GrowEventRow, "type" | "payload" | "occurredAt">) {
|
||||
const payload = asRecord(event.payload);
|
||||
const preferences = asRecord(payload.preferences);
|
||||
const onboarding = asRecord(payload.onboarding);
|
||||
return (
|
||||
parseCompletedAt(payload.completedAt ?? payload.completed_at) ??
|
||||
onboardingCompletedAtFromPreferences(preferences) ??
|
||||
parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt) ??
|
||||
(isOnboardingCompletionEvent(event) ? event.occurredAt.toISOString() : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function isOnboardingCompletionEvent(event: Pick<GrowEventRow, "type" | "payload" | "occurredAt">) {
|
||||
const normalizedType = event.type.toLowerCase().replaceAll("_", ".");
|
||||
if (
|
||||
normalizedType === "onboarding.completed" ||
|
||||
normalizedType === "user.onboarding.completed" ||
|
||||
normalizedType === "profile.onboarding.completed"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = asRecord(event.payload);
|
||||
const preferences = asRecord(payload.preferences);
|
||||
const onboarding = asRecord(payload.onboarding);
|
||||
return Boolean(
|
||||
onboardingCompletedAtFromPreferences(preferences) ??
|
||||
parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt) ??
|
||||
parseCompletedAt(payload.onboarding_completed_at ?? payload.onboardingCompletedAt),
|
||||
);
|
||||
}
|
||||
|
||||
async function findExistingReadyEvent(userId: string) {
|
||||
const [existing] = await db
|
||||
.select({ id: growEvents.id, payload: growEvents.payload })
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
eq(growEvents.source, CURATOR_SOURCE),
|
||||
eq(growEvents.type, ONBOARDING_READY_EVENT),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(1);
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function recordOnboardingContextSnapshot(input: {
|
||||
userId: string;
|
||||
startDate: string;
|
||||
completedAt?: string | Date | null;
|
||||
source?: string;
|
||||
sourceEventId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}) {
|
||||
if (!input.context || !Object.keys(input.context).length) return;
|
||||
await recordGrowEvent({
|
||||
source: input.source ?? "onboarding",
|
||||
type: "onboarding.completed",
|
||||
category: "usage",
|
||||
userId: input.userId,
|
||||
occurredAt: input.completedAt instanceof Date
|
||||
? input.completedAt.toISOString()
|
||||
: typeof input.completedAt === "string" && input.completedAt.trim()
|
||||
? input.completedAt
|
||||
: new Date().toISOString(),
|
||||
correlation: { sourceEventId: input.sourceEventId },
|
||||
payload: {
|
||||
completedAt: input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt,
|
||||
...input.context,
|
||||
},
|
||||
dedupeKey: `curator:onboarding-context:${input.userId}:${input.startDate}`,
|
||||
}, { 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_CONTEXT_EVENT_TYPES]),
|
||||
))
|
||||
.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;
|
||||
|
||||
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
|
||||
const res = await fetch(target, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"x-growqr-user": userId,
|
||||
},
|
||||
}).catch((err) => {
|
||||
log.warn({ err, userId }, "curator onboarding could not fetch user-service profile");
|
||||
return null;
|
||||
});
|
||||
if (!res?.ok) return undefined;
|
||||
|
||||
const profile = await res.json().catch(() => null) as Record<string, unknown> | null;
|
||||
if (!profile) return undefined;
|
||||
const preferences = asRecord(profile.preferences);
|
||||
return { profile, preferences };
|
||||
}
|
||||
|
||||
function dayOneSubtitle(sprint: CuratorSprintResponse) {
|
||||
const task = sprint.plan.days[0]?.tasks[0] ?? sprint.todayTasks[0];
|
||||
if (!task) return "Your personalized Day 1 tasks are ready on the home dashboard.";
|
||||
return `Day 1 starts with ${task.title.toLowerCase()}.`;
|
||||
}
|
||||
|
||||
async function upsertPlanReadyNotification(userId: string, sprint: CuratorSprintResponse) {
|
||||
const notificationId = `curator:onboarding-plan-ready:${userId}`;
|
||||
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 14);
|
||||
await db
|
||||
.insert(growHomeNotifications)
|
||||
.values({
|
||||
id: notificationId,
|
||||
userId,
|
||||
moduleId: "missions",
|
||||
title: "Your 30-day streak plan is ready",
|
||||
subtitle: dayOneSubtitle(sprint),
|
||||
tag: "Day 1 ready",
|
||||
urgency: "today",
|
||||
href: "/missions/active",
|
||||
source: "system",
|
||||
sourceRef: {
|
||||
sprintId: sprint.sprintId,
|
||||
planId: sprint.plan.id,
|
||||
activeDayIndex: sprint.activeDayIndex,
|
||||
source: CURATOR_SOURCE,
|
||||
},
|
||||
priority: 95,
|
||||
generatedBy: "manual",
|
||||
reason: "Created by the curator onboarding loop after onboarding completion.",
|
||||
status: "active",
|
||||
expiresAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: growHomeNotifications.id,
|
||||
set: {
|
||||
subtitle: dayOneSubtitle(sprint),
|
||||
sourceRef: {
|
||||
sprintId: sprint.sprintId,
|
||||
planId: sprint.plan.id,
|
||||
activeDayIndex: sprint.activeDayIndex,
|
||||
source: CURATOR_SOURCE,
|
||||
},
|
||||
status: "active",
|
||||
expiresAt,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function weeklyThemes(sprint: CuratorSprintResponse) {
|
||||
return sprint.plan.weeks.map((week) => ({
|
||||
weekIndex: week.weekIndex,
|
||||
theme: week.theme,
|
||||
summary: week.summary,
|
||||
startDayIndex: week.startDayIndex,
|
||||
endDayIndex: week.endDayIndex,
|
||||
}));
|
||||
}
|
||||
|
||||
function dayOneTasks(sprint: CuratorSprintResponse) {
|
||||
return (sprint.plan.days[0]?.tasks ?? sprint.todayTasks).map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
serviceId: task.serviceId,
|
||||
route: task.route,
|
||||
cta: task.cta,
|
||||
rewardCoins: task.rewardCoins,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Promise<OnboardingLoopResult> {
|
||||
const userId = input.userId.trim();
|
||||
if (!userId) return { status: "skipped", reason: "missing_user_id" };
|
||||
|
||||
const existing = await findExistingReadyEvent(userId);
|
||||
if (existing) {
|
||||
return { status: "already_ready", readyEventId: existing.id };
|
||||
}
|
||||
|
||||
const startDate = isoDateFrom(input.completedAt);
|
||||
const context = input.context ?? await findLatestOnboardingContext(userId) ?? await fetchUserServiceContext(userId);
|
||||
await recordOnboardingContextSnapshot({
|
||||
userId,
|
||||
startDate,
|
||||
completedAt: input.completedAt,
|
||||
source: input.source,
|
||||
sourceEventId: input.sourceEventId,
|
||||
context,
|
||||
});
|
||||
const sprint = await buildCuratorSprint(userId, startDate);
|
||||
await upsertPlanReadyNotification(userId, sprint);
|
||||
|
||||
const event = await emitCuratorEvent({
|
||||
userId,
|
||||
type: ONBOARDING_READY_EVENT,
|
||||
payload: {
|
||||
source: input.source ?? "onboarding",
|
||||
sourceEventId: input.sourceEventId,
|
||||
completedAt: input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt,
|
||||
startDate: sprint.plan.startDate,
|
||||
endDate: sprint.plan.endDate,
|
||||
sprintId: sprint.sprintId,
|
||||
planId: sprint.plan.id,
|
||||
durationDays: sprint.plan.durationDays,
|
||||
weekCount: sprint.plan.weeks.length,
|
||||
dayCount: sprint.plan.days.length,
|
||||
activeDayIndex: sprint.activeDayIndex,
|
||||
weeklyThemes: weeklyThemes(sprint),
|
||||
dayOneTasks: dayOneTasks(sprint),
|
||||
notificationId: `curator:onboarding-plan-ready:${userId}`,
|
||||
},
|
||||
});
|
||||
|
||||
return { status: "ready", sprint, eventId: event.id };
|
||||
}
|
||||
|
||||
export async function runCuratorOnboardingLoopForEvent(event: GrowEventRow): Promise<OnboardingLoopResult> {
|
||||
if (!event.userId) return { status: "skipped", reason: "missing_user_id" };
|
||||
if (!isOnboardingCompletionEvent(event)) return { status: "skipped", reason: "not_onboarding_completion" };
|
||||
return runCuratorOnboardingLoop({
|
||||
userId: event.userId,
|
||||
completedAt: onboardingCompletedAtFromEvent(event),
|
||||
sourceEventId: event.id,
|
||||
source: event.source,
|
||||
context: onboardingContextFromPayload(event.payload ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCuratorOnboardingLoopSafely(input: OnboardingLoopInput): Promise<OnboardingLoopResult> {
|
||||
try {
|
||||
return await runCuratorOnboardingLoop(input);
|
||||
} catch (err) {
|
||||
log.error({ err, userId: input.userId }, "curator onboarding loop failed");
|
||||
await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: ONBOARDING_SKIPPED_EVENT,
|
||||
payload: {
|
||||
reason: "loop_failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
sourceEventId: input.sourceEventId,
|
||||
},
|
||||
}).catch((emitErr) => log.warn({ emitErr, userId: input.userId }, "failed to emit curator onboarding failure event"));
|
||||
return { status: "skipped", reason: "loop_failed" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCuratorOnboardingLoopForEventSafely(event: GrowEventRow): Promise<OnboardingLoopResult> {
|
||||
try {
|
||||
return await runCuratorOnboardingLoopForEvent(event);
|
||||
} catch (err) {
|
||||
log.error({ err, eventId: event.id, userId: event.userId }, "curator onboarding event loop failed");
|
||||
if (event.userId) {
|
||||
await emitCuratorEvent({
|
||||
userId: event.userId,
|
||||
type: ONBOARDING_SKIPPED_EVENT,
|
||||
payload: {
|
||||
reason: "event_loop_failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
sourceEventId: event.id,
|
||||
},
|
||||
}).catch((emitErr) => log.warn({ emitErr, userId: event.userId }, "failed to emit curator onboarding event failure"));
|
||||
}
|
||||
return { status: "skipped", reason: "loop_failed" };
|
||||
}
|
||||
}
|
||||
101
src/v1/curator/curator-prompt-builder.ts
Normal file
101
src/v1/curator/curator-prompt-builder.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { CuratorIcpPlaybook } from "./curator-icp-playbooks.js";
|
||||
import type { CuratorUserContext } from "./curator-user-context.js";
|
||||
|
||||
export const CURATOR_PROMPT_VERSION = "service-curation-v1";
|
||||
|
||||
export type CuratorPromptAssembly = {
|
||||
version: typeof CURATOR_PROMPT_VERSION;
|
||||
hash: string;
|
||||
prompt: string;
|
||||
inputs: {
|
||||
startDate: string;
|
||||
durationDays: number;
|
||||
userContext: CuratorUserContext;
|
||||
playbook: CuratorIcpPlaybook;
|
||||
goals: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export function buildCuratorPlanPrompt(input: {
|
||||
startDate: string;
|
||||
durationDays: number;
|
||||
userContext: CuratorUserContext;
|
||||
playbook: CuratorIcpPlaybook;
|
||||
goals?: string[];
|
||||
}): CuratorPromptAssembly {
|
||||
const goals = input.goals?.filter(Boolean) ?? [input.playbook.sprintTheme, input.playbook.goal];
|
||||
const inputs = {
|
||||
startDate: input.startDate,
|
||||
durationDays: input.durationDays,
|
||||
userContext: input.userContext,
|
||||
playbook: input.playbook,
|
||||
goals,
|
||||
};
|
||||
const prompt = [
|
||||
"# GrowQR Service Curation Layer",
|
||||
"",
|
||||
"You generate deterministic 30-day streak plans from user context and an ICP playbook.",
|
||||
"Do not invent services. Use only service ids present in the playbook and Service Registry.",
|
||||
"Do not handcraft frontend URLs. Emit linkBuilder inputs; the backend Service Registry builds final deep links.",
|
||||
"No randomness, no vague tasks, no duplicate same-day service tasks.",
|
||||
"",
|
||||
"## Output Contract",
|
||||
"Return structured JSON only with:",
|
||||
"- durationDays: 30",
|
||||
"- calendarWeeks: Sunday-start calendar weeks covering all 30 days",
|
||||
"- days: exactly 30 days, where Day 1 is the subscription/start date",
|
||||
"- closeoutDays: day 29 and day 30",
|
||||
"- each day has exactly 3 tasks: measurement, proof, practice",
|
||||
"- every task includes taskType, serviceId, title, subtitle, qxImpact, effort, cta, expectedSignals, and linkBuilder input",
|
||||
"- weekly themes must follow the ICP stage labels",
|
||||
"",
|
||||
"## Staging Rules",
|
||||
"Start weekly grouping on Sunday. If the user subscribes on Monday, Day 1 is Monday inside a Sunday-start Week 1.",
|
||||
"The sprint is always exactly 30 days. Do not extend or shorten it to fit a calendar week.",
|
||||
"Use the first calendar week for Baseline + First Proof, then progress through the ICP stage labels.",
|
||||
"Use Day 29 and Day 30 for next-sprint planning and strongest-proof packaging.",
|
||||
"",
|
||||
"## Personalization Rules",
|
||||
"- Use targetRole for interview and roleplay links.",
|
||||
"- Use resume/profile context when available; if missing, day 1 proof should collect it.",
|
||||
"- Use QScore to prioritize measurement tasks.",
|
||||
"- Use past activity to avoid repeating completed or recently-used actions.",
|
||||
"- Map every goal to one of the ICP playbook service actions.",
|
||||
"",
|
||||
`Start date: ${input.startDate}`,
|
||||
`Duration days: ${input.durationDays}`,
|
||||
`Goals: ${goals.join(" | ")}`,
|
||||
"",
|
||||
"User context:",
|
||||
stableStringify(input.userContext),
|
||||
"",
|
||||
"ICP playbook:",
|
||||
stableStringify(input.playbook),
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
version: CURATOR_PROMPT_VERSION,
|
||||
hash: stableHash({ version: CURATOR_PROMPT_VERSION, inputs }),
|
||||
prompt,
|
||||
inputs,
|
||||
};
|
||||
}
|
||||
|
||||
export function stableHash(value: unknown) {
|
||||
return createHash("sha256").update(stableStringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
return JSON.stringify(sortKeys(value), null, 2);
|
||||
}
|
||||
|
||||
function sortKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortKeys);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => [key, sortKeys(item)]),
|
||||
);
|
||||
}
|
||||
115
src/v1/curator/curator-routes.ts
Normal file
115
src/v1/curator/curator-routes.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { requireUser, type AuthContext } from "../../auth/clerk.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(),
|
||||
taskId: z.string().optional(),
|
||||
subtaskIndex: z.number().int().min(0).optional(),
|
||||
subtask: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
messages: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string() })).min(1).max(50),
|
||||
});
|
||||
|
||||
const curationPreviewSchema = z.object({
|
||||
startDate: z.string().optional(),
|
||||
icpId: z.enum(["student_recent_grad", "intern", "fresher_early_professional", "experienced_professional"]).optional(),
|
||||
goals: z.array(z.string()).optional(),
|
||||
userContext: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export function v1CuratorRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.post("/plan/generate", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
goals: z.array(z.string()).optional(),
|
||||
forceRegenerate: z.boolean().optional(),
|
||||
}).parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await getCuratorActor(userId).generatePlanRange({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.get("/plan", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await getCuratorActor(userId).getPlan({
|
||||
userId,
|
||||
startDate: c.req.query("startDate"),
|
||||
endDate: c.req.query("endDate"),
|
||||
}));
|
||||
});
|
||||
|
||||
app.get("/today", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
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 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 getCuratorActor(userId).previewCuration({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.post("/onboarding/run", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({
|
||||
completedAt: z.string().optional(),
|
||||
}).parse(await c.req.json().catch(() => ({})));
|
||||
return c.json(await getCuratorActor(userId).runOnboardingLoop({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.post("/chat", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = chatSchema.parse(await c.req.json());
|
||||
return c.json(await getCuratorActor(userId).chat({ userId, ...body }));
|
||||
});
|
||||
|
||||
app.post("/tasks/:taskId/start", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await getCuratorActor(userId).startTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
|
||||
});
|
||||
|
||||
app.post("/tasks/:taskId/handoff", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await getCuratorActor(userId).prepareTaskHandoff({ userId, taskId: c.req.param("taskId"), date: c.req.query("date") }));
|
||||
});
|
||||
|
||||
app.post("/tasks/:taskId/complete", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({ reason: z.string().optional() }).parse(await c.req.json().catch(() => ({})));
|
||||
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 getCuratorActor(userId).recordServiceImpact({ userId, eventId: body.eventId }));
|
||||
});
|
||||
|
||||
app.get("/state", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
return c.json(await getCuratorActor(userId).getState({ userId }));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
53
src/v1/curator/curator-service-links.ts
Normal file
53
src/v1/curator/curator-service-links.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
buildCuratorServiceRoute,
|
||||
getServiceActionLabel,
|
||||
getServiceCompletionEvents,
|
||||
getServiceDisplayName,
|
||||
getServiceToolName,
|
||||
} from "../../services/service-registry.js";
|
||||
import type { CuratorServiceId, CuratorTask } from "./curator-types.js";
|
||||
|
||||
type ServiceRouteInput = {
|
||||
serviceId?: CuratorServiceId;
|
||||
missionInstanceId?: string;
|
||||
missionId?: string;
|
||||
stageId?: string;
|
||||
taskId?: string;
|
||||
targetRole?: string;
|
||||
durationMinutes?: number;
|
||||
difficulty?: string;
|
||||
personaId?: string;
|
||||
requestedMode?: string;
|
||||
roleplayBrief?: string;
|
||||
};
|
||||
|
||||
export function serviceRoute(input: ServiceRouteInput) {
|
||||
return buildCuratorServiceRoute(input);
|
||||
}
|
||||
|
||||
export function buildCuratorTaskDeepLink(task: Pick<CuratorTask, "serviceId" | "missionId" | "missionInstanceId" | "stageId" | "id">, targetRole?: string) {
|
||||
return buildCuratorServiceRoute({
|
||||
serviceId: task.serviceId,
|
||||
missionId: task.missionId,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
stageId: task.stageId,
|
||||
taskId: task.id,
|
||||
targetRole,
|
||||
});
|
||||
}
|
||||
|
||||
export function serviceName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
|
||||
return getServiceDisplayName(serviceId, fallback);
|
||||
}
|
||||
|
||||
export function serviceToolName(serviceId?: CuratorServiceId) {
|
||||
return getServiceToolName(serviceId);
|
||||
}
|
||||
|
||||
export function completionEventsForService(serviceId?: CuratorServiceId) {
|
||||
return getServiceCompletionEvents(serviceId);
|
||||
}
|
||||
|
||||
export function actionLabel(task: CuratorTask) {
|
||||
return getServiceActionLabel(task);
|
||||
}
|
||||
1867
src/v1/curator/curator-store.ts
Normal file
1867
src/v1/curator/curator-store.ts
Normal file
File diff suppressed because it is too large
Load Diff
552
src/v1/curator/curator-tools.ts
Normal file
552
src/v1/curator/curator-tools.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { eq, desc, and, inArray } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, growQscoreLatest, growQscoreProjectionState } from "../../db/schema.js";
|
||||
import { interviewService, resumeService, roleplayService } from "../../services/product-service-clients.js";
|
||||
import { listServices } from "../../services/service-registry.js";
|
||||
import { createMissionAction, listMissionActions } from "../../missions/actions.js";
|
||||
import { listActiveMissionsPg, listMessagesPg } from "../../grow/persistence.js";
|
||||
import { buildCuratorStreak, buildCuratorTasks, listCuratorRegistryCapabilities } from "./curator-store.js";
|
||||
import { actionLabel, serviceRoute } from "./curator-service-links.js";
|
||||
import { curatorServiceIdSchema, type CuratorServiceHandoff, type CuratorTask } from "./curator-types.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import { fallbackCuratorRole, resolveCuratorTargetRole } from "./curator-user-context.js";
|
||||
|
||||
async function findTask(userId: string, taskId: string, date: string) {
|
||||
const tasks = await buildCuratorTasks(userId, date);
|
||||
return tasks.find((task) => task.id === taskId) ?? null;
|
||||
}
|
||||
|
||||
function conciseRoleHint(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.length > 80 ? `${trimmed.slice(0, 77).trimEnd()}...` : trimmed;
|
||||
}
|
||||
|
||||
function buildRoleplayBrief(task: CuratorTask, targetRole: string) {
|
||||
return `Practice a realistic ${task.title.toLowerCase()} conversation for ${targetRole}. Include one pushback moment, concise answers, and a clear next step.`;
|
||||
}
|
||||
|
||||
async function missionGoalHint(userId: string, task: CuratorTask) {
|
||||
if (!task.missionInstanceId) return undefined;
|
||||
const active = await listActiveMissionsPg(userId);
|
||||
const match = active.find((item) => item.mission.instanceId === task.missionInstanceId);
|
||||
const goal = typeof match?.mission.goal === "string" ? match.mission.goal : undefined;
|
||||
return conciseRoleHint(goal);
|
||||
}
|
||||
|
||||
function asText(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reviewField(payload: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const direct = asText(payload[key]);
|
||||
if (direct) return direct;
|
||||
}
|
||||
const review = payload.review && typeof payload.review === "object" ? payload.review as Record<string, unknown> : undefined;
|
||||
if (!review) return undefined;
|
||||
for (const key of keys) {
|
||||
const nested = asText(review[key]);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function latestInterviewResumeEvidence(userId: string) {
|
||||
const rows = await db.select({
|
||||
id: growEvents.id,
|
||||
type: growEvents.type,
|
||||
source: growEvents.source,
|
||||
payload: growEvents.payload,
|
||||
occurredAt: growEvents.occurredAt,
|
||||
}).from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type as any, [
|
||||
"interview.feedback.generated",
|
||||
"interview.completed",
|
||||
"roleplay.feedback.generated",
|
||||
"roleplay.completed",
|
||||
]),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(5);
|
||||
|
||||
const latest = rows[0];
|
||||
if (!latest) return null;
|
||||
|
||||
const payload = latest.payload ?? {};
|
||||
const strongestAnswer = reviewField(payload, [
|
||||
"strongest_answer",
|
||||
"strongestAnswer",
|
||||
"best_answer",
|
||||
"bestAnswer",
|
||||
"top_answer",
|
||||
"topAnswer",
|
||||
]);
|
||||
const improvementArea = reviewField(payload, [
|
||||
"improvement_area",
|
||||
"improvementArea",
|
||||
"biggest_gap",
|
||||
"biggestGap",
|
||||
"coaching_note",
|
||||
"coachingNote",
|
||||
]);
|
||||
const summary = reviewField(payload, [
|
||||
"summary",
|
||||
"feedback_summary",
|
||||
"feedbackSummary",
|
||||
"overall_feedback",
|
||||
"overallFeedback",
|
||||
]);
|
||||
|
||||
const carryForward = [
|
||||
summary ? `Review summary: ${summary}` : undefined,
|
||||
strongestAnswer ? `Strongest answer to convert into proof: ${strongestAnswer}` : undefined,
|
||||
improvementArea ? `Weakest area to repair in resume positioning: ${improvementArea}` : undefined,
|
||||
].filter((item): item is string => Boolean(item));
|
||||
|
||||
return {
|
||||
eventId: latest.id,
|
||||
source: latest.source,
|
||||
type: latest.type,
|
||||
occurredAt: latest.occurredAt,
|
||||
carryForward,
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareHandoffForTask(
|
||||
userId: string,
|
||||
task: CuratorTask,
|
||||
serviceId = task.serviceId,
|
||||
targetRoleOverride?: string,
|
||||
): Promise<CuratorServiceHandoff> {
|
||||
if (!serviceId) throw new Error("Task has no service handoff.");
|
||||
const resolvedTargetRole =
|
||||
targetRoleOverride ??
|
||||
(await missionGoalHint(userId, task)) ??
|
||||
(await resolveCuratorTargetRole({ userId, task }));
|
||||
const targetRole = fallbackCuratorRole(resolvedTargetRole);
|
||||
const route = serviceRoute({
|
||||
serviceId,
|
||||
missionId: task.missionId,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
stageId: task.stageId,
|
||||
taskId: task.id,
|
||||
targetRole,
|
||||
durationMinutes: 5,
|
||||
difficulty: "medium",
|
||||
personaId: serviceId === "roleplay-service" ? "emma" : "payal",
|
||||
requestedMode: "video",
|
||||
roleplayBrief: serviceId === "roleplay-service" ? buildRoleplayBrief(task, targetRole) : undefined,
|
||||
});
|
||||
|
||||
let actionId: string | undefined;
|
||||
if (task.missionInstanceId && task.missionId !== "curator-sprint") {
|
||||
const action = await createMissionAction({
|
||||
userId,
|
||||
missionInstanceId: task.missionInstanceId,
|
||||
missionId: task.missionId,
|
||||
stageId: task.stageId,
|
||||
agentId: "curator-v1",
|
||||
agentName: "V1 Curator Actor",
|
||||
baseAgent: "Curator Agent",
|
||||
serviceId,
|
||||
toolName: task.toolName,
|
||||
mode: "suggestion",
|
||||
status: "queued",
|
||||
title: task.title,
|
||||
body: task.subtitle,
|
||||
prompt: `Prepare ${task.serviceName} handoff for ${task.title}.`,
|
||||
payload: { href: route, route, taskId: task.id, source: "curator-v1" },
|
||||
idempotencyKey: `curator-v1:${task.id}:${serviceId}`,
|
||||
priority: 50,
|
||||
urgency: "today",
|
||||
});
|
||||
actionId = action?.id;
|
||||
}
|
||||
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "task.opened",
|
||||
mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId },
|
||||
payload: {
|
||||
taskId: task.id,
|
||||
curatorTaskId: task.id,
|
||||
serviceId,
|
||||
route,
|
||||
actionId,
|
||||
expectedCompletionEvents: task.completionEvents,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
serviceId,
|
||||
route,
|
||||
actionId,
|
||||
actionRoute: route,
|
||||
actionLabel: actionLabel({ ...task, serviceId }),
|
||||
status: "prepared",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCuratorTools(ctx: { userId: string; date: string; conversationId?: string; taskId?: string }) {
|
||||
return {
|
||||
get_onboarding_context: tool({
|
||||
description: "Read available onboarding and profile context from recent platform events.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const events = await db.select().from(growEvents)
|
||||
.where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.category, "usage")))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(20);
|
||||
return { events };
|
||||
},
|
||||
}),
|
||||
|
||||
get_user_goals: tool({
|
||||
description: "Infer currently known goals from active missions and mission goals.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const active = await listActiveMissionsPg(ctx.userId);
|
||||
return { goals: active.map((item) => ({ missionId: item.mission.missionId, title: item.mission.title, goal: item.mission.goal })) };
|
||||
},
|
||||
}),
|
||||
|
||||
get_curator_plan: tool({
|
||||
description: "Read today's curator tasks and streak state.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ date: ctx.date, tasks: await buildCuratorTasks(ctx.userId, ctx.date), streak: await buildCuratorStreak(ctx.userId) }),
|
||||
}),
|
||||
|
||||
get_today_tasks: tool({
|
||||
description: "List today's V1 curator tasks.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ tasks: await buildCuratorTasks(ctx.userId, ctx.date) }),
|
||||
}),
|
||||
|
||||
get_curator_streak: tool({
|
||||
description: "Read the user's curator streak from allowed completion events.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ streak: await buildCuratorStreak(ctx.userId) }),
|
||||
}),
|
||||
|
||||
read_curator_memory: tool({
|
||||
description: "Read recent curator memory from existing grow events.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(10) }),
|
||||
execute: async ({ limit }) => {
|
||||
const events = await db.select().from(growEvents)
|
||||
.where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.source, "curator-v1")))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(limit);
|
||||
return { events };
|
||||
},
|
||||
}),
|
||||
|
||||
write_curator_memory: tool({
|
||||
description: "Write a durable curator memory event. Use this for chat extracts and status updates.",
|
||||
inputSchema: z.object({ summary: z.string(), tags: z.array(z.string()).default([]) }),
|
||||
execute: async ({ summary, tags }) => emitCuratorEvent({ userId: ctx.userId, type: "curator.memory.updated", payload: { summary, tags } }),
|
||||
}),
|
||||
|
||||
read_conversation_context: tool({
|
||||
description: "Read the current conversation history from existing conversation storage.",
|
||||
inputSchema: z.object({ conversationId: z.string().optional() }),
|
||||
execute: async ({ conversationId }) => ({ messages: conversationId || ctx.conversationId ? await listMessagesPg(ctx.userId, conversationId ?? ctx.conversationId!) : [] }),
|
||||
}),
|
||||
|
||||
list_service_capabilities: tool({
|
||||
description: "List deterministic service capabilities from existing service registries.",
|
||||
inputSchema: z.object({}),
|
||||
execute: listCuratorRegistryCapabilities,
|
||||
}),
|
||||
|
||||
list_available_service_routes: tool({
|
||||
description: "Return known handoff routes for registered services.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = taskId ? await findTask(ctx.userId, taskId, ctx.date) : null;
|
||||
return {
|
||||
routes: listServices().map((service) => ({
|
||||
serviceId: service.id,
|
||||
route: serviceRoute({ serviceId: service.id, missionId: task?.missionId, missionInstanceId: task?.missionInstanceId, stageId: task?.stageId, taskId: task?.id }),
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
validate_service_handoff: tool({
|
||||
description: "Validate whether a requested service handoff exists in the registry.",
|
||||
inputSchema: z.object({ serviceId: curatorServiceIdSchema }),
|
||||
execute: async ({ serviceId }) => {
|
||||
const capabilities = await listCuratorRegistryCapabilities();
|
||||
return { valid: capabilities.services.some((service) => service.id === serviceId), serviceId };
|
||||
},
|
||||
}),
|
||||
|
||||
map_task_to_service: tool({
|
||||
description: "Map a curator task to its service capability and handoff route.",
|
||||
inputSchema: z.object({ taskId: z.string() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
return { task, route: task ? serviceRoute({ serviceId: task.serviceId, missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId, taskId }) : null };
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_interview_setup: tool({
|
||||
description: "Prepare an interview setup handoff. This creates a mission action and route, not the full interview workflow.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "interview-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_interview_preview: tool({
|
||||
description: "Prepare the interview preview route after setup context exists.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "interview-service");
|
||||
},
|
||||
}),
|
||||
|
||||
read_interview_report: tool({
|
||||
description: "Read available interview page/report state from the existing interview service.",
|
||||
inputSchema: z.object({ sessionId: z.string().optional() }),
|
||||
execute: async ({ sessionId }) => sessionId ? interviewService.review(sessionId) : interviewService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
list_interview_sessions: tool({
|
||||
description: "List interview service page state for the user.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => interviewService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
get_interview_latest_status: tool({
|
||||
description: "Read the latest interview-related events.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(20).default(5) }),
|
||||
execute: async ({ limit }) => db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.source, "interview"))).orderBy(desc(growEvents.occurredAt)).limit(limit),
|
||||
}),
|
||||
|
||||
prepare_resume_upload: tool({
|
||||
description: "Prepare resume upload or resume builder handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
extract_resume_context: tool({
|
||||
description: "Extract basic context from pasted resume text for curator reasoning.",
|
||||
inputSchema: z.object({ text: z.string().min(1) }),
|
||||
execute: async ({ text }) => ({
|
||||
length: text.length,
|
||||
hasExperience: /experience|work|employment/i.test(text),
|
||||
hasEducation: /education|degree|university|college/i.test(text),
|
||||
hasSkills: /skills|tools|technologies/i.test(text),
|
||||
preview: text.slice(0, 500),
|
||||
}),
|
||||
}),
|
||||
|
||||
read_resume_report: tool({
|
||||
description: "Read existing resume service state.",
|
||||
inputSchema: z.object({ resumeId: z.string().optional() }),
|
||||
execute: async ({ resumeId }) => resumeId ? resumeService.getResume(resumeId) : resumeService.state(ctx.userId),
|
||||
}),
|
||||
|
||||
prepare_resume_rewrite: tool({
|
||||
description: "Prepare a resume rewrite handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_resume_talking_points: tool({
|
||||
description: "Prepare resume talking point handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_resume_gap_scan: tool({
|
||||
description: "Prepare resume gap scan handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_resume_from_interview_evidence: tool({
|
||||
description: "Prepare a resume handoff that carries forward recent interview or roleplay review evidence into the proof task.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
const handoff = await prepareHandoffForTask(ctx.userId, task, "resume-service");
|
||||
const evidence = await latestInterviewResumeEvidence(ctx.userId);
|
||||
if (!evidence?.carryForward?.length) {
|
||||
const interviewFallback = await prepareHandoffForTask(ctx.userId, task, "interview-service");
|
||||
return {
|
||||
handoff,
|
||||
carryForward: [],
|
||||
requiresInterviewEvidence: true,
|
||||
recommendedNextAction: "No recent interview or roleplay review evidence is available yet. Run an interview rep first so the resume proof can be generated from real conversation evidence.",
|
||||
fallbackHandoff: interviewFallback,
|
||||
};
|
||||
}
|
||||
return {
|
||||
handoff,
|
||||
carryForward: evidence?.carryForward ?? [],
|
||||
sourceEventId: evidence?.eventId,
|
||||
sourceEventType: evidence?.type,
|
||||
sourceService: evidence?.source,
|
||||
sourceOccurredAt: evidence?.occurredAt,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_roleplay_setup: tool({
|
||||
description: "Prepare roleplay setup handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "roleplay-service");
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_roleplay_preview: tool({
|
||||
description: "Prepare roleplay preview handoff.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
return prepareHandoffForTask(ctx.userId, task, "roleplay-service");
|
||||
},
|
||||
}),
|
||||
|
||||
suggest_roleplay_scenario: tool({
|
||||
description: "Suggest a roleplay scenario from current task context.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId ?? ctx.taskId ?? "", ctx.date);
|
||||
return { scenario: task?.title ?? "Practice a high-stakes workplace conversation", outcome: "Clear next step and confident response" };
|
||||
},
|
||||
}),
|
||||
|
||||
read_roleplay_report: tool({
|
||||
description: "Read roleplay service report or page state.",
|
||||
inputSchema: z.object({ sessionId: z.string().optional() }),
|
||||
execute: async ({ sessionId }) => sessionId ? roleplayService.review(sessionId) : roleplayService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
list_roleplay_sessions: tool({
|
||||
description: "List roleplay service page state for the user.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => roleplayService.pageState(ctx.userId),
|
||||
}),
|
||||
|
||||
read_qscore_state: tool({
|
||||
description: "Read current Q-score projection state.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, ctx.userId)).limit(1),
|
||||
}),
|
||||
|
||||
read_qscore_signals: tool({
|
||||
description: "Read latest Q-score signals.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(20) }),
|
||||
execute: async ({ limit }) => db.select().from(growQscoreLatest).where(eq(growQscoreLatest.userId, ctx.userId)).orderBy(desc(growQscoreLatest.updatedAt)).limit(limit),
|
||||
}),
|
||||
|
||||
explain_qscore_movement: tool({
|
||||
description: "Explain recent Q-score movement from available signals.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ state: await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, ctx.userId)).limit(1), signals: await db.select().from(growQscoreLatest).where(eq(growQscoreLatest.userId, ctx.userId)).orderBy(desc(growQscoreLatest.updatedAt)).limit(10) }),
|
||||
}),
|
||||
|
||||
map_task_to_qscore_signals: tool({
|
||||
description: "Map a curator task to the Q-score signals it can affect.",
|
||||
inputSchema: z.object({ taskId: z.string() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
return { taskId, signals: task?.signals ?? [] };
|
||||
},
|
||||
}),
|
||||
|
||||
prepare_qscore_review: tool({
|
||||
description: "Disabled for curator task handoffs; QScore is read-only for dashboard scoring and should not be assigned as a curator task.",
|
||||
inputSchema: z.object({ taskId: z.string().optional() }),
|
||||
execute: async () => ({ error: "qscore_curator_handoff_disabled", replacementServices: ["assessment-service", "interview-service", "roleplay-service", "resume-service", "matchmaking-service", "courses-service", "social-branding-service"] }),
|
||||
}),
|
||||
|
||||
emit_curator_event: tool({
|
||||
description: "Emit a curator event through existing Grow event ingestion.",
|
||||
inputSchema: z.object({ type: z.string(), payload: z.record(z.string(), z.unknown()).default({}) }),
|
||||
execute: async ({ type, payload }) => emitCuratorEvent({ userId: ctx.userId, type, payload }),
|
||||
}),
|
||||
|
||||
read_recent_grow_events: tool({
|
||||
description: "Read recent Grow events for this user.",
|
||||
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(20) }),
|
||||
execute: async ({ limit }) => db.select().from(growEvents).where(eq(growEvents.userId, ctx.userId)).orderBy(desc(growEvents.occurredAt)).limit(limit),
|
||||
}),
|
||||
|
||||
find_matching_service_event: tool({
|
||||
description: "Find service events that can complete a task.",
|
||||
inputSchema: z.object({ taskId: z.string() }),
|
||||
execute: async ({ taskId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
if (!task) return { task: null, events: [] };
|
||||
const events = await db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), inArray(growEvents.type as any, task.completionEvents))).orderBy(desc(growEvents.occurredAt)).limit(20);
|
||||
return { task, events };
|
||||
},
|
||||
}),
|
||||
|
||||
complete_task_from_event: tool({
|
||||
description: "Complete a task only when a valid service or platform event exists.",
|
||||
inputSchema: z.object({ taskId: z.string(), eventId: z.string() }),
|
||||
execute: async ({ taskId, eventId }) => {
|
||||
const task = await findTask(ctx.userId, taskId, ctx.date);
|
||||
if (!task) return { error: "task_not_found" };
|
||||
const [event] = await db.select().from(growEvents).where(and(eq(growEvents.userId, ctx.userId), eq(growEvents.id, eventId))).limit(1);
|
||||
if (!event || !task.completionEvents.includes(event.type)) return { completed: false, reason: "event_not_allowed" };
|
||||
return emitCuratorEvent({ userId: ctx.userId, type: "curator.task.completed", mission: { missionId: task.missionId, missionInstanceId: task.missionInstanceId, stageId: task.stageId }, payload: { taskId, sourceEventId: eventId } });
|
||||
},
|
||||
}),
|
||||
|
||||
update_streak_from_completion: tool({
|
||||
description: "Read streak after completion events.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => ({ streak: await buildCuratorStreak(ctx.userId) }),
|
||||
}),
|
||||
|
||||
list_mission_actions: tool({
|
||||
description: "List existing mission actions so the curator does not duplicate handoffs.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => listMissionActions(ctx.userId, { openOnly: false }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
187
src/v1/curator/curator-types.ts
Normal file
187
src/v1/curator/curator-types.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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",
|
||||
]);
|
||||
|
||||
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;
|
||||
|
||||
export const curatorTaskTypeSchema = z.enum(["measurement", "proof", "practice", "recovery"]);
|
||||
export type CuratorTaskType = z.infer<typeof curatorTaskTypeSchema>;
|
||||
|
||||
export const curatorTaskStatusSchema = z.enum([
|
||||
"ready",
|
||||
"started",
|
||||
"handoff_prepared",
|
||||
"completed",
|
||||
"blocked",
|
||||
"partial",
|
||||
"skipped",
|
||||
"abandoned",
|
||||
]);
|
||||
|
||||
export const curatorWeekLifecycleSchema = z.enum(["done", "active", "upcoming"]);
|
||||
export const curatorWeekPerformanceSchema = z.enum(["Missed", "Okayish", "Avg", "Excelling"]);
|
||||
|
||||
export const curatorTaskSchema = z.object({
|
||||
id: z.string(),
|
||||
date: z.string(),
|
||||
dayIndex: z.number().int().min(1).max(30),
|
||||
dayIndexInWeek: z.number().int().min(1).max(7),
|
||||
weekIndex: z.number().int().min(1).max(6),
|
||||
taskType: curatorTaskTypeSchema,
|
||||
title: z.string(),
|
||||
subtitle: z.string(),
|
||||
missionId: z.string(),
|
||||
missionInstanceId: z.string().optional(),
|
||||
stageId: z.string().optional(),
|
||||
serviceId: curatorServiceIdSchema.optional(),
|
||||
serviceName: z.string(),
|
||||
actorName: z.string(),
|
||||
toolName: z.string(),
|
||||
status: curatorTaskStatusSchema,
|
||||
rewardCoins: z.number().int().min(0),
|
||||
qxImpact: z.string(),
|
||||
effort: z.string(),
|
||||
route: z.string(),
|
||||
cta: z.string(),
|
||||
context: z.array(z.object({ label: z.string(), value: z.string() })),
|
||||
contextNarrative: z.string(),
|
||||
subtasks: z.array(z.string()).length(3),
|
||||
signals: z.array(z.string()),
|
||||
completionEvents: z.array(z.string()),
|
||||
source: z.enum(["curator-v1", "mission-registry", "service-registry"]),
|
||||
});
|
||||
|
||||
export const curatorStreakSchema = z.object({
|
||||
current: z.number().int().min(0),
|
||||
longest: z.number().int().min(0),
|
||||
lastCompletedDate: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const curatorPlanDaySchema = z.object({
|
||||
date: z.string(),
|
||||
dayIndex: z.number().int().min(1).max(30),
|
||||
dayIndexInWeek: z.number().int().min(1).max(7),
|
||||
weekIndex: z.number().int().min(1).max(6),
|
||||
weekTheme: z.string(),
|
||||
weekSummary: z.string(),
|
||||
focus: z.string().optional(),
|
||||
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),
|
||||
totalCount: z.number().int().min(0),
|
||||
unlockState: z.enum(["completed", "active", "upcoming"]),
|
||||
tasks: z.array(curatorTaskSchema),
|
||||
});
|
||||
|
||||
export const curatorWeekSchema = z.object({
|
||||
weekIndex: z.number().int().min(1).max(6),
|
||||
title: z.string(),
|
||||
theme: z.string(),
|
||||
summary: z.string(),
|
||||
startDayIndex: z.number().int().min(1).max(30),
|
||||
endDayIndex: z.number().int().min(1).max(30),
|
||||
lifecycle: curatorWeekLifecycleSchema,
|
||||
performance: curatorWeekPerformanceSchema,
|
||||
completedTaskCount: z.number().int().min(0),
|
||||
totalTaskCount: z.number().int().min(0),
|
||||
completionPercent: z.number().min(0).max(100),
|
||||
days: z.array(curatorPlanDaySchema).min(1).max(7),
|
||||
});
|
||||
|
||||
export const curatorPlanSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
goals: z.array(z.string()),
|
||||
generatedAt: z.string(),
|
||||
durationDays: z.literal(30),
|
||||
weeks: z.array(curatorWeekSchema).min(5).max(6),
|
||||
days: z.array(curatorPlanDaySchema).length(30),
|
||||
streak: curatorStreakSchema,
|
||||
source: z.literal("curator-v1"),
|
||||
});
|
||||
|
||||
export const curatorImprovementSignalSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
date: z.string(),
|
||||
priority: z.number().int().min(0).max(100),
|
||||
reason: z.string(),
|
||||
recommendedServiceId: curatorServiceIdSchema.optional(),
|
||||
recommendedMissionId: z.string().optional(),
|
||||
memoryPatch: z.string().optional(),
|
||||
nudgeText: z.string().optional(),
|
||||
status: z.enum(["created", "applied", "skipped"]).default("created"),
|
||||
});
|
||||
|
||||
export const curatorSprintResponseSchema = z.object({
|
||||
date: z.string(),
|
||||
sprintId: z.string(),
|
||||
plan: curatorPlanSchema,
|
||||
activeWeek: curatorWeekSchema,
|
||||
activeWeekIndex: z.number().int().min(1).max(6),
|
||||
activeDay: curatorPlanDaySchema,
|
||||
activeDayIndex: z.number().int().min(1).max(30),
|
||||
todayTasks: z.array(curatorTaskSchema).min(3).max(4),
|
||||
streak: curatorStreakSchema,
|
||||
completedCount: z.number().int().min(0),
|
||||
totalCount: z.number().int().min(0),
|
||||
overallProgressPercent: z.number().min(0).max(100),
|
||||
source: z.literal("curator-v1"),
|
||||
});
|
||||
|
||||
export type CuratorTask = z.infer<typeof curatorTaskSchema>;
|
||||
export type CuratorPlanDay = z.infer<typeof curatorPlanDaySchema>;
|
||||
export type CuratorWeek = z.infer<typeof curatorWeekSchema>;
|
||||
export type CuratorPlan = z.infer<typeof curatorPlanSchema>;
|
||||
export type CuratorStreak = z.infer<typeof curatorStreakSchema>;
|
||||
export type CuratorImprovementSignal = z.infer<typeof curatorImprovementSignalSchema>;
|
||||
export type CuratorSprintResponse = z.infer<typeof curatorSprintResponseSchema>;
|
||||
|
||||
export type CuratorTodayResponse = {
|
||||
date: string;
|
||||
plan: CuratorPlan;
|
||||
tasks: CuratorTask[];
|
||||
streak: CuratorStreak;
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
source: "curator-v1";
|
||||
};
|
||||
|
||||
export type CuratorSubtaskStatusUpdate = {
|
||||
status: "needs_more_context" | "ready_to_capture" | "handoff_ready";
|
||||
summary: string;
|
||||
confidence: number;
|
||||
nextMissingInfo?: string;
|
||||
};
|
||||
|
||||
export type CuratorChatResponse = {
|
||||
conversationId: string;
|
||||
taskId?: string;
|
||||
reply: string;
|
||||
messages: Array<{ id: string; role: "user" | "assistant"; sender: string; content: string; createdAt: number }>;
|
||||
statusUpdate?: CuratorSubtaskStatusUpdate;
|
||||
handoff?: CuratorServiceHandoff;
|
||||
};
|
||||
|
||||
export type CuratorServiceHandoff = {
|
||||
taskId: string;
|
||||
serviceId: CuratorServiceId;
|
||||
route: string;
|
||||
actionId?: string;
|
||||
actionRoute: string;
|
||||
actionLabel: string;
|
||||
status: "prepared";
|
||||
};
|
||||
255
src/v1/curator/curator-user-context.ts
Normal file
255
src/v1/curator/curator-user-context.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, growQscoreProjectionState } from "../../db/schema.js";
|
||||
import { asRecord, getString } from "../../events/envelope.js";
|
||||
import type { CuratorTask } from "./curator-types.js";
|
||||
|
||||
const ROLE_PATTERN = /\b(pm|swe|manager|engineer|designer|analyst|developer|product|marketing|sales|founder|consultant|operator|lead|head|director|recruiter|student|intern|data scientist|software)\b/i;
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
export type CuratorUserContext = {
|
||||
userId: string;
|
||||
targetRole: string;
|
||||
experienceLevel: "student" | "intern" | "early" | "experienced" | "unknown";
|
||||
resume: {
|
||||
available: boolean;
|
||||
latestSummary?: string;
|
||||
skills: string[];
|
||||
};
|
||||
goals: string[];
|
||||
pastActivity: {
|
||||
recentEventCount: number;
|
||||
serviceSources: string[];
|
||||
latestEvents: Array<{ type: string; source: string; occurredAt: string; summary?: string }>;
|
||||
};
|
||||
qscore: {
|
||||
score: number | null;
|
||||
signalCount: number;
|
||||
summary: string | null;
|
||||
dimensions: Record<string, unknown> | null;
|
||||
};
|
||||
};
|
||||
|
||||
function firstRoleFromValue(value: unknown): string | undefined {
|
||||
const direct = getString(value);
|
||||
if (direct) return direct;
|
||||
const record = asRecord(value);
|
||||
return getString(
|
||||
record.target_role ??
|
||||
record.targetRole ??
|
||||
record.current_role ??
|
||||
record.currentRole ??
|
||||
record.role ??
|
||||
record.title ??
|
||||
record.position,
|
||||
);
|
||||
}
|
||||
|
||||
function roleFromPreferences(preferences: Record<string, unknown>) {
|
||||
const targetRoles = stringArray(preferences.target_roles ?? preferences.targetRoles);
|
||||
if (targetRoles[0]) return targetRoles[0];
|
||||
const onboarding = asRecord(preferences.onboarding);
|
||||
return firstRoleFromValue(
|
||||
onboarding.target_role ??
|
||||
onboarding.targetRole ??
|
||||
onboarding.role ??
|
||||
onboarding.goal ??
|
||||
onboarding.current_role,
|
||||
);
|
||||
}
|
||||
|
||||
function roleFromPayload(payload: Record<string, unknown>) {
|
||||
const preferences = asRecord(payload.preferences);
|
||||
const fromPreferences = roleFromPreferences(preferences);
|
||||
if (fromPreferences) return fromPreferences;
|
||||
const explicitRole = firstRoleFromValue(
|
||||
payload.target_role ??
|
||||
payload.targetRole ??
|
||||
payload.current_role ??
|
||||
payload.currentRole ??
|
||||
payload.role,
|
||||
);
|
||||
if (explicitRole) return explicitRole;
|
||||
return inferRoleFromText(getString(payload.goal ?? payload.userGoal ?? payload.serviceIntent));
|
||||
}
|
||||
|
||||
function roleFromTask(task?: CuratorTask) {
|
||||
for (const item of task?.context ?? []) {
|
||||
if (/role|target|focus/i.test(item.label)) {
|
||||
const role = firstRoleFromValue(item.value);
|
||||
if (role && ROLE_PATTERN.test(role)) return role;
|
||||
}
|
||||
}
|
||||
const raw = `${task?.title ?? ""} ${task?.subtitle ?? ""}`;
|
||||
const productManager = raw.match(/\bproduct manager\b/i)?.[0];
|
||||
if (productManager) return "Product Manager";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function inferRoleFromText(text?: string) {
|
||||
const value = text?.trim();
|
||||
if (!value) return undefined;
|
||||
const cleanRole = (raw?: string) => {
|
||||
if (!raw) return undefined;
|
||||
const normalized = raw
|
||||
.replace(/^(?:i am |i'm |im )/i, "")
|
||||
.replace(/^(?:targeting|aiming for|looking for)\s+/i, "")
|
||||
.replace(/[.?!,]+$/, "")
|
||||
.replace(/\broles?\b/gi, "")
|
||||
.replace(/\b(this|next)\s+(week|month|quarter|year)\b/gi, "")
|
||||
.replace(/\b(right now|currently)\b/gi, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!normalized) return undefined;
|
||||
if (/^pm$/i.test(normalized)) return "Product Manager";
|
||||
if (/^swe$/i.test(normalized)) return "Software Engineer";
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const explicit = value.match(/(?:targeting|for|as|toward|towards|role is|role:)\s+([A-Za-z][A-Za-z0-9 +/&.-]{2,60}?)(?=\s+roles?\b|\s+(?:this|next)\s+(?:week|month|quarter|year)\b|[.?!,]|$)/i)?.[1];
|
||||
if (explicit) return cleanRole(explicit);
|
||||
if (value.length <= 80 && ROLE_PATTERN.test(value)) return cleanRole(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveCuratorTargetRole(input: {
|
||||
userId: string;
|
||||
task?: CuratorTask;
|
||||
latest?: string;
|
||||
history?: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
}) {
|
||||
const latestRole = inferRoleFromText(input.latest);
|
||||
if (latestRole) return latestRole;
|
||||
|
||||
const historyRole = [...(input.history ?? [])]
|
||||
.reverse()
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => inferRoleFromText(message.content))
|
||||
.find(Boolean);
|
||||
if (historyRole) return historyRole;
|
||||
|
||||
const taskRole = roleFromTask(input.task);
|
||||
if (taskRole) return taskRole;
|
||||
|
||||
const rows = await db
|
||||
.select({ payload: growEvents.payload })
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.userId, input.userId), eq(growEvents.category, "usage")))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(30);
|
||||
|
||||
for (const row of rows) {
|
||||
const role = roleFromPayload(row.payload ?? {});
|
||||
if (role) return role;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function fallbackCuratorRole(role?: string) {
|
||||
return role?.trim() || "Product Manager";
|
||||
}
|
||||
|
||||
export async function buildCuratorUserContext(userId: string): Promise<CuratorUserContext> {
|
||||
const rows = await db
|
||||
.select({ type: growEvents.type, source: growEvents.source, payload: growEvents.payload, occurredAt: growEvents.occurredAt })
|
||||
.from(growEvents)
|
||||
.where(eq(growEvents.userId, userId))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(80);
|
||||
|
||||
const [qscore] = await db
|
||||
.select({
|
||||
score: growQscoreProjectionState.score,
|
||||
signalCount: growQscoreProjectionState.signalCount,
|
||||
summary: growQscoreProjectionState.summary,
|
||||
dimensions: growQscoreProjectionState.dimensions,
|
||||
})
|
||||
.from(growQscoreProjectionState)
|
||||
.where(eq(growQscoreProjectionState.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId }));
|
||||
const corpus = rows.map((row) => `${row.type} ${row.source} ${payloadText(row.payload)}`).join(" ").toLowerCase();
|
||||
const goals = uniqueStrings(rows.flatMap((row) => goalsFromPayload(row.payload)));
|
||||
const skills = uniqueStrings(rows.flatMap((row) => stringArray(asRecord(row.payload).skills))).slice(0, 12);
|
||||
const latestResume = rows
|
||||
.map((row) => resumeSummaryFromPayload(row.payload))
|
||||
.find(Boolean);
|
||||
|
||||
return {
|
||||
userId,
|
||||
targetRole,
|
||||
experienceLevel: inferExperienceLevel(corpus),
|
||||
resume: {
|
||||
available: Boolean(latestResume || /\bresume|cv|linkedin\b/i.test(corpus)),
|
||||
latestSummary: latestResume,
|
||||
skills,
|
||||
},
|
||||
goals: goals.length ? goals.slice(0, 8) : [targetRole],
|
||||
pastActivity: {
|
||||
recentEventCount: rows.length,
|
||||
serviceSources: uniqueStrings(rows.map((row) => row.source)).slice(0, 12),
|
||||
latestEvents: rows.slice(0, 12).map((row) => ({
|
||||
type: row.type,
|
||||
source: row.source,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
summary: eventSummary(row.payload),
|
||||
})),
|
||||
},
|
||||
qscore: {
|
||||
score: typeof qscore?.score === "number" ? qscore.score : null,
|
||||
signalCount: typeof qscore?.signalCount === "number" ? qscore.signalCount : 0,
|
||||
summary: qscore?.summary ?? null,
|
||||
dimensions: qscore?.dimensions ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function goalsFromPayload(payload: Record<string, unknown>) {
|
||||
const preferences = asRecord(payload.preferences);
|
||||
return [
|
||||
...stringArray(preferences.target_roles ?? preferences.targetRoles),
|
||||
...stringArray(payload.goals),
|
||||
getString(payload.goal),
|
||||
getString(payload.userGoal),
|
||||
getString(payload.target_role ?? payload.targetRole),
|
||||
].filter((item): item is string => Boolean(item));
|
||||
}
|
||||
|
||||
function resumeSummaryFromPayload(payload: Record<string, unknown>) {
|
||||
return getString(
|
||||
payload.resumeSummary ??
|
||||
payload.summary ??
|
||||
payload.resume_text ??
|
||||
payload.resumeText ??
|
||||
asRecord(payload.resume).summary,
|
||||
)?.slice(0, 900);
|
||||
}
|
||||
|
||||
function eventSummary(payload: Record<string, unknown>) {
|
||||
return getString(payload.summary ?? payload.title ?? payload.goal ?? payload.serviceIntent)?.slice(0, 220);
|
||||
}
|
||||
|
||||
function payloadText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value.map(payloadText).join(" ");
|
||||
if (value && typeof value === "object") return Object.values(value as Record<string, unknown>).map(payloadText).join(" ");
|
||||
return "";
|
||||
}
|
||||
|
||||
function inferExperienceLevel(corpus: string): CuratorUserContext["experienceLevel"] {
|
||||
if (/\b(intern|internship|return offer|return-offer)\b/.test(corpus)) return "intern";
|
||||
if (/\b(student|recent grad|recent graduate|campus|college|university)\b/.test(corpus)) return "student";
|
||||
if (/\b(staff|principal|director|vp|head of|leadership|executive|10\+ years|5\+ years)\b/.test(corpus)) return "experienced";
|
||||
if (/\b(fresher|junior|entry level|entry-level|early career|0-2 years|1 year|2 years)\b/.test(corpus)) return "early";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function uniqueStrings(values: Array<string | undefined>) {
|
||||
return [...new Set(values.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))];
|
||||
}
|
||||
119
src/v1/events/events-routes.ts
Normal file
119
src/v1/events/events-routes.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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 { ensureOnboardingSideEffectsForEvent } from "../../events/onboarding-ledger.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 onboarding = await ensureOnboardingSideEffectsForEvent(event);
|
||||
if (
|
||||
onboarding.curatorOnboarding.status === "skipped" &&
|
||||
onboarding.curatorOnboarding.reason === "loop_failed"
|
||||
) {
|
||||
throw new Error("curator_onboarding_loop_failed");
|
||||
}
|
||||
await markGrowEventProcessed(event.id);
|
||||
return c.json({
|
||||
eventId: event.id,
|
||||
processingStatus: "processed",
|
||||
serviceSession,
|
||||
qscore,
|
||||
onboarding,
|
||||
}, 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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user