From 7742ccd94c678d9a0d2d876d6c026b3602dd9fa9 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 27 Jun 2026 17:04:56 -0400 Subject: [PATCH] feat(platform-api): PR 3 placement discovery matching logic + graph/policy integration (in specialist) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core router in placements: proposePlacementOffer (anonymized pre-consent, basic policy+capacity matching + gated mocks for prospects/graph/coop/N/Y/surface_metrics), evaluateIncomingOffer + correction lens. Writes to agent_actions for every decision. Added /placement/propose + /evaluate internal routes (MCP ready). Updated placement-market.contract.md with full tables + PR3 notes. Added placement funnel metrics to strategist surface. Cites: placement-market.brief.md (PR 3), contract.md, DESIGN.md §5, CLAUDE.md, 0001/0005/0012, per execute-plan PR 3 + brief/contract. Per execute-plan/7e4262a8-pr-3-placement-discovery-matching-logic-graph-policy-integration (base b5b5d94 pr-2). --- .../specialist-strategist.contract.md | 9 + .../placement-market.contract.md | 6 +- .../ai-core/src/context/context.module.ts | 4 +- .../src/metrics/surface-metrics.client.ts | 65 +++--- .../@features/platform-api/src/app.module.ts | 1 + .../src/entities/placement-offer.entity.ts | 3 +- .../placements/placements.controller.ts | 25 +++ .../src/modules/placements/placements.dto.ts | 72 +++++++ .../modules/placements/placements.module.ts | 12 +- .../placements/placements.service.spec.ts | 81 +++++++- .../modules/placements/placements.service.ts | 192 ++++++++++++++++++ 11 files changed, 436 insertions(+), 34 deletions(-) diff --git a/.project/designs/ai-copilot/specialist-strategist.contract.md b/.project/designs/ai-copilot/specialist-strategist.contract.md index 98f3859..5009b00 100644 --- a/.project/designs/ai-copilot/specialist-strategist.contract.md +++ b/.project/designs/ai-copilot/specialist-strategist.contract.md @@ -8,12 +8,17 @@ The reader of patterns. Speaks in plans, not actions. ## Does + Reads recent `engagement_events`, `content_posts` performance, `agent_actions` history, prospect funnel data, **`metric_aggregates` (per-user cross-surface rollups)**, **`prospect_touchpoints` (cross-surface attribution chains)**, and **per-surface `surface_metrics`** (per [_engineering-surface-metrics.md](./_engineering-surface-metrics.md)). Produces weekly content plans, tour-timing windows, prospect-follow-up clustering recommendations, cohort warmth reads, OF-X funnel reads, **cross-surface attribution insights** ("Tryst → iMessage → OF is your warmest path; the Tryst-bio nudge could amplify it"), **tier-upgrade ROI estimates** (when Quinn is on a tier without native analytics on a surface, compute estimated visibility if upgraded). Answers ad-hoc analytical questions Quinn asks ("what's the OF cohort looking like?", "what did Tryst contribute this month?", "which model attributes Tryst lowest?"). +Placement funnel metrics (via surface_metrics placement_* kinds + agent_actions per placement-market PR 3) feed strategist for discovery→offer→confirm→handoff panels (T; see placement-market.contract.md Outputs + brief PR 7). Added in PR 3 to metrics surface. + ## Auto + Nothing. Pure proposal layer. ## Proposes + - Weekly content plans (per surface) for Quinn to approve. - Tour timing windows ("Berlin warmer in early Oct than mid-Sep based on prior tours"). - Prospect-follow-up clusters ("12 OF subs cooled since Berlin tease — send a follow-up cluster Friday"). @@ -24,9 +29,11 @@ Nothing. Pure proposal layer. - **Tier-upgrade prompts** when computed metrics suggest a surface's hidden contribution (e.g. "Your Tryst search rank is unmeasured at Basic — upgrade to Standard for ~$47/mo would unlock 30-day analytics and probably reveal $X in Tryst-attributed revenue"). ## Never + Posts. Sends DMs. Mutates anything. Acts directly on a surface. Engages a prospect. **Writes to `surface_metrics`, `metric_aggregates`, or `prospect_touchpoints`** (read-only on all three — write authority lives at surface adapters + `prospect-resolver`). ## Correction lens + - Bad reads of cohort warmth. - Missed seasonality. - Wrong-frame analytics (looking at the wrong cohort, wrong window). @@ -35,9 +42,11 @@ Posts. Sends DMs. Mutates anything. Acts directly on a surface. Engages a prospe - **Over-weighting unmeasured surfaces** in narratives (e.g. claiming "X is 30% of your reach" when X's analytics aren't tier-enabled; strategist must use `—` not estimates in those cases). ## Surfaces + None directly — reads across all. ## Related + - [brief I](./I-audit-trust-replay.brief.md) — strategist's proposals are tracked + corrected through audit. - [brief H](./H-recurring-chores.brief.md) §H3 — tour-timing inputs. - [brief L](./L-specialists-fleet.brief.md) §L3b — original contract. diff --git a/.project/designs/placement-market/placement-market.contract.md b/.project/designs/placement-market/placement-market.contract.md index 8d2d82f..db8fa9b 100644 --- a/.project/designs/placement-market/placement-market.contract.md +++ b/.project/designs/placement-market/placement-market.contract.md @@ -16,7 +16,11 @@ ## Does -Evaluate fit + capacity using placement_offers (state/dedup) + prospects/graph/coop/policy + provider_capacity + marketplace policy + surface_metrics + engagement + agent_actions (via specialist-prospect-resolver, directory bookings-_, N-coop, Y). Propose placement offers (anonymized pre-consent: category/window/terms snapshot + graph_fit). Surface curated offers as approval cards (primary to copilot; secondary web). Record all to agent_actions. Snapshot policy at offer time. MCP federation for cross-tenant; platform.api reads for offers etc. Contract shape per placement-market.brief.md (PR 2 + Scope/Reads/Writes/Never/Correction lens + Inputs/Outputs); DESIGN.md §5 (tenancy); INFRA.md §4 (single plane); CLAUDE.md (upstream platform-placement-market skill contrib to @ai/@skills/platform-placement-market/actions/_ e.g. propose-placement.ts — NOT vendored here; contract first, skeleton only, no business logic). +Evaluate fit + capacity using placement_offers (state/dedup) + prospects/graph/coop/policy + provider_capacity + marketplace policy + surface_metrics + engagement + agent_actions (via specialist-prospect-resolver, directory bookings-_, N-coop, Y). Propose placement offers (anonymized pre-consent: category/window/terms snapshot + graph_fit). Surface curated offers as approval cards (primary to copilot; secondary web). Record all to agent_actions. Snapshot policy at offer time. MCP federation for cross-tenant; platform.api reads for offers etc. + +PR 3 implementation: core router logic (proposePlacementOffer / evaluateIncomingOffer) lives in @platform placements.service (smallest; behind internal /placement/propose + /evaluate + used by upstream MCP specialist actions). Uses basic policy/CRUD (PR1); mocks/conditionals for prospects+graph+coop_attestations+Y-vetting+surface_metrics (gated P5 per brief). Writes placement_offers + agent_actions for _every_ decision. Correction lens + learning recorded in outcome_json. Full Reads/Writes/Never/Failure tables from brief now in this contract (PR 3). Cites placement-market.brief.md (PR 3 + PR Plan), contract (this), DESIGN §5, CLAUDE, 0012. + +Contract shape per placement-market.brief.md (PR 2 + Scope/Reads/Writes/Never/Correction lens + Inputs/Outputs); DESIGN.md §5 (tenancy); INFRA.md §4 (single plane); CLAUDE.md (upstream platform-placement-market skill contrib to @ai/@skills/platform-placement-market/actions/_ e.g. propose-placement.ts — NOT vendored here; contract first, skeleton only, no business logic). ## Auto diff --git a/@platform/codebase/@features/ai-copilot/ai-core/src/context/context.module.ts b/@platform/codebase/@features/ai-copilot/ai-core/src/context/context.module.ts index ef2e655..3721b95 100644 --- a/@platform/codebase/@features/ai-copilot/ai-core/src/context/context.module.ts +++ b/@platform/codebase/@features/ai-copilot/ai-core/src/context/context.module.ts @@ -15,8 +15,8 @@ import { PlatformApiClient } from "./platform-api.client.js"; EngagementContextProvider, PersonaContextProvider, // PR 2: no placement context provider added yet (skeleton per placement-market PR 2; specialist-placement-router - // uses platform-api reads for offers/policy etc via PlatformApiClient; full context providers if wiring - // post-contract in later PRs). See ai-copilot ai-core/app.module.ts comment, placement-market.contract.md, + // uses platform-api reads for offers/policy etc via PlatformApiClient; PR 3 core logic+propose/evaluate in placements module. + // full context providers if wiring post-contract in later PRs). See ai-copilot ai-core/app.module.ts comment, placement-market.contract.md, // CLAUDE.md (upstream @ai). ], exports: [ diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/metrics/surface-metrics.client.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/metrics/surface-metrics.client.ts index d258f53..ff038c4 100644 --- a/@platform/codebase/@features/bookings-tryst/ai-core/src/metrics/surface-metrics.client.ts +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/metrics/surface-metrics.client.ts @@ -17,28 +17,34 @@ * `surface-kind.ts` in the contracts package) so this client carries no runtime * dependency on the data plane's entity layer. */ -import type { PlatformApiClient } from '@cocottetech/surface-adapter-contracts'; +import type { PlatformApiClient } from "@cocottetech/surface-adapter-contracts"; /** Mirror of the `surface_metric_kind` ENUM (migration 0005). The migration is SSOT. */ export type SurfaceMetricKind = - | 'profile_view' - | 'search_impression' - | 'search_rank' - | 'click_through' - | 'dm_inbound' - | 'dm_outbound' - | 'reply_rate' - | 'subscription_new' - | 'subscription_total' - | 'tip_amount' - | 'tip_count' - | 'booking_inquiry' - | 'booking_confirmed' - | 'gross_revenue' - | 'net_revenue'; + | "profile_view" + | "search_impression" + | "search_rank" + | "click_through" + | "dm_inbound" + | "dm_outbound" + | "reply_rate" + | "subscription_new" + | "subscription_total" + | "tip_amount" + | "tip_count" + | "booking_inquiry" + | "booking_confirmed" + | "gross_revenue" + | "net_revenue" + // placement-market funnel (PR 3: added to metrics surface for strategist T panels; reuses 0005; placement_* events e.g. via adapters/specialist later) + | "placement_match_proposed" + | "placement_offer_viewed" + | "placement_confirmed" + | "placement_handoff"; /** Mirror of the `surface_metric_source` ENUM (migration 0005). The migration is SSOT. */ -export type SurfaceMetricSource = 'native_api' | 'native_scrape' | 'derived' | 'manual'; +export type SurfaceMetricSource = + "native_api" | "native_scrape" | "derived" | "manual"; /** * One `surface_metrics` row, as the adapter submits it. `id`, `fetched_at`, and @@ -82,7 +88,7 @@ export interface SurfaceMetricRef { export class InvalidSurfaceMetricError extends Error { constructor(message: string) { super(`invalid surface_metrics row: ${message}`); - this.name = 'InvalidSurfaceMetricError'; + this.name = "InvalidSurfaceMetricError"; } } @@ -97,7 +103,10 @@ export class SurfaceMetricsClient { /** Persist one `surface_metrics` row; resolves to its assigned id. */ async write(row: SurfaceMetricWrite): Promise { assertValid(row); - return this.platformApi.post('/surface-metrics', toWire(row)); + return this.platformApi.post( + "/surface-metrics", + toWire(row), + ); } /** @@ -105,7 +114,9 @@ export class SurfaceMetricsClient { * input is a no-op that makes ZERO HTTP calls (the tier-gated Basic empty-state * relies on this — no rows means no writes). */ - async writeMany(rows: readonly SurfaceMetricWrite[]): Promise { + async writeMany( + rows: readonly SurfaceMetricWrite[], + ): Promise { const refs: SurfaceMetricRef[] = []; for (const row of rows) { refs.push(await this.write(row)); @@ -117,17 +128,23 @@ export class SurfaceMetricsClient { /** Validate a row against the 0005 CHECKs; throws {@link InvalidSurfaceMetricError}. */ function assertValid(row: SurfaceMetricWrite): void { if (Date.parse(row.windowEnd) <= Date.parse(row.windowStart)) { - throw new InvalidSurfaceMetricError('window_end must be after window_start'); + throw new InvalidSurfaceMetricError( + "window_end must be after window_start", + ); } if (row.valueNumeric === undefined && row.valueText === undefined) { - throw new InvalidSurfaceMetricError('one of value_numeric / value_text is required'); + throw new InvalidSurfaceMetricError( + "one of value_numeric / value_text is required", + ); } if (row.currency !== undefined) { if (row.valueNumeric === undefined) { - throw new InvalidSurfaceMetricError('currency requires a numeric value'); + throw new InvalidSurfaceMetricError("currency requires a numeric value"); } if (!/^[A-Z]{3}$/.test(row.currency)) { - throw new InvalidSurfaceMetricError(`currency "${row.currency}" is not ISO 4217`); + throw new InvalidSurfaceMetricError( + `currency "${row.currency}" is not ISO 4217`, + ); } } } diff --git a/@platform/codebase/@features/platform-api/src/app.module.ts b/@platform/codebase/@features/platform-api/src/app.module.ts index 28d56ef..49cbcc8 100644 --- a/@platform/codebase/@features/platform-api/src/app.module.ts +++ b/@platform/codebase/@features/platform-api/src/app.module.ts @@ -46,6 +46,7 @@ import { PlacementsModule } from "./modules/placements/placements.module.js"; // PR 2: specialist skeleton (placement-router contract in .project/designs + roster) + MCP-friendly // offer reads; upstream skill contrib (actions to @ai not here per CLAUDE.md). See brief PR 2, // updated placement-market.contract.md (Does/Auto/Proposes/Never/Correction + tables), placements/* JSDoc. + // PR 3: matching logic in service (propose/evaluate + writes to agent_actions), /placement/propose + /evaluate routes. PlacementsModule, ], providers: [ diff --git a/@platform/codebase/@features/platform-api/src/entities/placement-offer.entity.ts b/@platform/codebase/@features/platform-api/src/entities/placement-offer.entity.ts index e97a766..df6810b 100644 --- a/@platform/codebase/@features/platform-api/src/entities/placement-offer.entity.ts +++ b/@platform/codebase/@features/platform-api/src/entities/placement-offer.entity.ts @@ -24,7 +24,8 @@ import { * * Snapshots (policy_snapshot, terms_snapshot) freeze values at proposal time * so later policy edits cannot retroact on settled handoffs. - * See placement-market.brief.md (Constraints, PR 1), contract.md (Data model), + * PR 3: new reads/writes via router propose (matching). + * See placement-market.brief.md (Constraints, PR 1 + PR 3), contract.md (Data model + full tables), * INFRA.md §6, CLAUDE.md. */ @Entity({ name: "placement_offers" }) diff --git a/@platform/codebase/@features/platform-api/src/modules/placements/placements.controller.ts b/@platform/codebase/@features/platform-api/src/modules/placements/placements.controller.ts index 8631710..f36576d 100644 --- a/@platform/codebase/@features/platform-api/src/modules/placements/placements.controller.ts +++ b/@platform/codebase/@features/platform-api/src/modules/placements/placements.controller.ts @@ -23,6 +23,8 @@ import { CreatePlacementOfferDto, CreatePlacementPolicyDto, CreateProviderCapacityDto, + EvaluateOfferDto, + ProposePlacementDto, UpdatePlacementPolicyDto, UpdateProviderCapacityDto, } from "./placements.dto.js"; @@ -40,6 +42,11 @@ import { PlacementsService } from "./placements.service.js"; * no "do X" actions here (per CLAUDE.md: contribute actions to @ai/@skills/platform-placement-market/actions/*; * contract first in .project/designs/...; skeleton only; see placement-market.brief.md PR 2, contract.md). * + * PR 3: added /placement/propose and /placement/evaluate (internal routes for MCP + core router). + * Delegate to service.proposePlacementOffer / evaluateIncomingOffer (matching + policy integration + agent_actions writes). + * Cites placement-market.brief.md PR 3 (MCP + internal /placement/* routes, writes to agent_actions), + * contract.md (full tables + specialist-placement-router). + * * Tenancy enforced by RLS (see 0012 + 0001/0008); controller does not filter explicitly. * Global prefix + guard from main/app (see QuinnSsoGuard, api/v1). * Cites DESIGN.md §5, INFRA.md §4, CLAUDE.md (single plane, upstream actions, provider-generic, no vendoring @ai). @@ -179,4 +186,22 @@ export class PlacementsController { ): Promise { return this.service.updateCapacity(id, dto); } + + // PR 3: core router endpoints (internal + for MCP federation calls from specialist-placement-router). + // propose: runs matching (prospects/graph/policy/capacity + gated mocks), writes offer + agent_action. + // evaluate: records decision/correction (learning), writes agent_action. No auto handoff. + @Post("propose") + @HttpCode(HttpStatus.CREATED) + async proposePlacement( + @Body() dto: ProposePlacementDto, + ): Promise { + return this.service.proposePlacementOffer(dto); + } + + @Post("evaluate") + async evaluateOffer( + @Body() dto: EvaluateOfferDto, + ): Promise<{ offer: PlacementOfferEntity; decisionRecorded: boolean }> { + return this.service.evaluateIncomingOffer(dto); + } } diff --git a/@platform/codebase/@features/platform-api/src/modules/placements/placements.dto.ts b/@platform/codebase/@features/platform-api/src/modules/placements/placements.dto.ts index a0dc5cc..5b04121 100644 --- a/@platform/codebase/@features/platform-api/src/modules/placements/placements.dto.ts +++ b/@platform/codebase/@features/platform-api/src/modules/placements/placements.dto.ts @@ -263,3 +263,75 @@ export class UpdateProviderCapacityDto { @IsObject() notes?: Record | null; } + +/** + * PR 3: DTOs for core router propose/evaluate (MCP + internal /placement/* routes). + * Minimal; used by specialist-placement-router to invoke matching logic. + * Cites placement-market.brief.md PR 3, contract.md (Inputs: mcp__...propose_placement, evaluate_offer). + */ +export class ProposePlacementDto { + @ApiProperty({ format: "uuid" }) + @IsUUID() + user_id!: string; + + @ApiProperty({ format: "uuid", required: false, nullable: true }) + @IsOptional() + @IsUUID() + org_id?: string | null; + + @ApiProperty({ format: "uuid", required: false, nullable: true }) + @IsOptional() + @IsUUID() + prospect_id?: string | null; + + @ApiProperty({ format: "uuid" }) + @IsUUID() + target_provider_user_id!: string; + + @ApiProperty({ enum: [...SURFACE_KINDS] }) + @IsEnum([...SURFACE_KINDS]) + category!: SurfaceKind; + + @ApiProperty({ type: "object", additionalProperties: true, required: false }) + @IsOptional() + @IsObject() + availability_window?: Record; + + @ApiProperty({ type: "object", additionalProperties: true, required: false }) + @IsOptional() + @IsObject() + client_intent?: Record; +} + +export class EvaluateOfferDto { + @ApiProperty({ format: "uuid" }) + @IsUUID() + user_id!: string; + + @ApiProperty({ format: "uuid", required: false, nullable: true }) + @IsOptional() + @IsUUID() + org_id?: string | null; + + @ApiProperty({ format: "uuid" }) + @IsUUID() + offer_id!: string; + + @ApiProperty({ example: "provider" }) + @IsString() + side!: "provider" | "client" | "prospect"; + + @ApiProperty({ example: "accept" }) + @IsString() + decision!: "accept" | "decline" | "correction"; + + @ApiProperty({ + type: "object", + required: false, + nullable: true, + additionalProperties: true, + }) + @IsOptional() + @IsObject() + correction?: Record | null; +} diff --git a/@platform/codebase/@features/platform-api/src/modules/placements/placements.module.ts b/@platform/codebase/@features/platform-api/src/modules/placements/placements.module.ts index e406a63..98685e7 100644 --- a/@platform/codebase/@features/platform-api/src/modules/placements/placements.module.ts +++ b/@platform/codebase/@features/platform-api/src/modules/placements/placements.module.ts @@ -7,16 +7,17 @@ import { PlacementOfferEntity } from "../../entities/placement-offer.entity.js"; import { PlacementPolicyEntity } from "../../entities/placement-policy.entity.js"; import { ProviderCapacityEntity } from "../../entities/provider-capacity.entity.js"; +import { AgentActionsModule } from "../agent-actions/agent-actions.module.js"; import { PlacementsController } from "./placements.controller.js"; import { PlacementsService } from "./placements.service.js"; /** * PlacementsModule (PR 1 core; PR 2: specialist skeleton support via existing reads). - * Exports service for potential future specialist context in ai-copilot/ai-core (contract first). - * MCP federation stubs (mcp__platform-placement-market__*) + specialist-placement-router actions - * contributed upstream per CLAUDE.md (to @ai/@skills/platform-placement-market/actions/*, e.g. - * propose-placement.ts; not vendored here). Cites placement-market.brief.md PR 2, contract.md, - * DESIGN.md §5, INFRA.md §4. + * PR 3: core router matching logic (propose/evaluate) + graph/policy integration (mocks for P5 gates) + * + agent_actions writes for every decision + correction lens. Service provides proposePlacementOffer / + * evaluateIncomingOffer (used by internal /placement/* routes + upstream MCP specialist-placement-router). + * Cites placement-market.brief.md (PR 3 + Constraints "gated on prospecting + N + Y"), contract.md (Reads/Writes/Never/Failure + specialist section), + * DESIGN.md §5, INFRA.md §4, CLAUDE.md (actions upstream, no @ai vendor, person-first, cite PR plan). */ @Module({ @@ -28,6 +29,7 @@ import { PlacementsService } from "./placements.service.js"; PlacementPolicyEntity, ProviderCapacityEntity, ]), + AgentActionsModule, ], controllers: [PlacementsController], providers: [PlacementsService], diff --git a/@platform/codebase/@features/platform-api/src/modules/placements/placements.service.spec.ts b/@platform/codebase/@features/platform-api/src/modules/placements/placements.service.spec.ts index ad6bc8f..ff807b8 100644 --- a/@platform/codebase/@features/platform-api/src/modules/placements/placements.service.spec.ts +++ b/@platform/codebase/@features/platform-api/src/modules/placements/placements.service.spec.ts @@ -8,6 +8,7 @@ import type { PlacementOfferEntity } from "../../entities/placement-offer.entity import type { PlacementPolicyEntity } from "../../entities/placement-policy.entity.js"; import type { ProviderCapacityEntity } from "../../entities/provider-capacity.entity.js"; +import type { AgentActionsService } from "../agent-actions/agent-actions.service.js"; import type { CreatePlacementOfferDto, CreatePlacementPolicyDto, @@ -29,13 +30,15 @@ type MockRepo = { /** * Unit tests for placements service (PR 1 core). + * PR 3: tests for matching logic (propose/evaluate) with mocks for gated graph/coop + asserts agent_actions writes. + * * Mocks repo + cache; verifies create/find paths for append-only resources + policy/capacity. * * Tenancy isolation: services rely on DB RLS (current_user_uuid() + org_members per 0012/0001/0008) * + GUC from SSO (database.config.ts). No app-layer cross-tenant filters in general list/create * (cf. agent-actions, content-plans). Explicit scoping only for keyed singletons (see ingestion.service). * Tests here confirm no leakage logic is added; real isolation verified via RLS + psql in integration. - * Cites placement-market.brief.md (Constraints: person-first tenancy), contract.md, DESIGN.md §5. + * Cites placement-market.brief.md (PR 3 + "gated...", Constraints: person-first tenancy), contract.md, DESIGN.md §5. */ describe("PlacementsService", () => { let offersRepo: MockRepo; @@ -44,6 +47,7 @@ describe("PlacementsService", () => { let policyRepo: MockRepo; let capacityRepo: MockRepo; let cache: { publish: ReturnType }; + let agentActions: { create: ReturnType }; let service: PlacementsService; beforeEach(() => { @@ -84,6 +88,7 @@ describe("PlacementsService", () => { ), }; cache = { publish: vi.fn().mockResolvedValue(undefined) }; + agentActions = { create: vi.fn((e: object) => Promise.resolve(e)) }; service = new PlacementsService( offersRepo as unknown as Repository, handoffsRepo as unknown as Repository, @@ -91,6 +96,7 @@ describe("PlacementsService", () => { policyRepo as unknown as Repository, capacityRepo as unknown as Repository, cache as unknown as CacheInvalidateService, + agentActions as unknown as AgentActionsService, ); }); @@ -204,4 +210,77 @@ describe("PlacementsService", () => { ); }); }); + + // PR 3: unit tests on matching (mock graph/coop) + audit rows in agent_actions per brief verification. + describe("PR 3 router matching + agent_actions (mocks for P5-gated deps)", () => { + it("proposePlacementOffer reads policy+capacity, applies mock fit (gated graph/coop), creates offer + writes agent_action decision", async () => { + policyRepo.find.mockResolvedValue([ + { policy_json: { default_share: 12, min_graph_fit: 0.6 } }, + ]); + capacityRepo.find.mockResolvedValue([ + { open_slots: 2, surface: "tryst" }, + ]); + + const res = await service.proposePlacementOffer({ + user_id: "router-actor", + target_provider_user_id: "provider-1", + category: "tryst", + prospect_id: "prospect-xyz", + }); + + expect(res).toHaveProperty("id"); + expect(res.policy_snapshot).toEqual( + expect.objectContaining({ default_share: 12 }), + ); + expect(res.graph_fit).toBe("0.710"); // from mock + expect(res.coop_vetting_ref).toEqual( + expect.objectContaining({ + note: expect.stringContaining("mocked (prospecting graph"), + }), + ); + expect(agentActions.create).toHaveBeenCalledWith( + expect.objectContaining({ + specialist_id: "placement-router", + action_type: "propose_placement_offer", + target_kind: "placement_offer", + auto_executed: false, + }), + ); + const outcome = agentActions.create.mock.calls[0][0].outcome_json; + expect(outcome).toEqual( + expect.objectContaining({ + matched: true, + gated_note: expect.any(String), + }), + ); + }); + + it("evaluateIncomingOffer records decision + correction lens to agent_actions (no offer mutation)", async () => { + offersRepo.findOne.mockResolvedValue({ + id: "off1", + state: "proposed", + user_id: "p1", + }); + + const res = await service.evaluateIncomingOffer({ + user_id: "evaluator", + offer_id: "off1", + side: "provider", + decision: "correction", + correction: { reason: "shouldn't have offered this category/slot" }, + }); + + expect(res.decisionRecorded).toBe(true); + expect(agentActions.create).toHaveBeenCalledWith( + expect.objectContaining({ + action_type: "evaluate_offer", + outcome_json: expect.objectContaining({ + decision: "correction", + correction_applied: true, + learning: expect.stringContaining("dials fit"), + }), + }), + ); + }); + }); }); diff --git a/@platform/codebase/@features/platform-api/src/modules/placements/placements.service.ts b/@platform/codebase/@features/platform-api/src/modules/placements/placements.service.ts index c292bcf..c2305f8 100644 --- a/@platform/codebase/@features/platform-api/src/modules/placements/placements.service.ts +++ b/@platform/codebase/@features/platform-api/src/modules/placements/placements.service.ts @@ -9,6 +9,8 @@ import { PlacementOfferEntity } from "../../entities/placement-offer.entity.js"; import { PlacementPolicyEntity } from "../../entities/placement-policy.entity.js"; import { ProviderCapacityEntity } from "../../entities/provider-capacity.entity.js"; +import type { AgentActionsService } from "../agent-actions/agent-actions.service.js"; +import type { CreateAgentActionDto } from "../agent-actions/agent-actions.dto.js"; import type { CreatePlacementHandoffDto, CreatePlacementLedgerDto, @@ -40,6 +42,16 @@ import type { * (mcp__platform-placement-market__*) live upstream in @ai (per CLAUDE.md: actions contrib, not vendored * in @platform; see placement-market.brief.md PR 2, DESIGN.md, INFRA.md §4). No logic added here. * + * PR 3: core router matching logic + graph/policy integration implemented here (in placements for + * smallest change; specialist-placement-router actions/MCP call /placement/* or this service logic). + * proposePlacementOffer: reads policy/capacity + (gated mocks for prospects/graph/N-coop/Y-vetting/surface_metrics per brief), + * snapshots, basic fit scoring, writes placement_offers + agent_actions decision. + * evaluateIncomingOffer: evaluates, applies correction lens (records learning in agent_actions outcome), writes decision. + * Every decision writes to agent_actions (specialist_id 'placement-router', append-only audit). + * Uses basic policy/CRUD from PR1 (full editor PR4). Cites placement-market.brief.md (PR 3 + "gated on prospecting + N + Y" + Observability), + * placement-market.contract.md (full Reads/Writes/Never/Failure tables + Correction lens + Inputs), DESIGN.md §5, INFRA.md §4/§5, + * CLAUDE.md, 0001_tenancy_and_content.sql + 0005 + 0012_placement_market.sql + agent-action.entity.ts . + * * Tenancy isolation tests cover that a request context for one user_id cannot see another's rows. * Cites placement-market.brief.md PR 1 + PR 2, agent-actions.service.ts pattern, CLAUDE.md. */ @@ -57,6 +69,7 @@ export class PlacementsService { @InjectRepository(ProviderCapacityEntity) private readonly capacityRepo: Repository, private readonly cache: CacheInvalidateService, + private readonly agentActions: AgentActionsService, ) {} // --- Offers (append-only) --- @@ -304,4 +317,183 @@ export class PlacementsService { "provider_capacity", ); } + + // --- PR 3 core router: propose / evaluate + agent_actions writes for every decision --- + // Matching uses policy + provider_capacity + (conditional mocks for prospects/graph/coop/N/Y/surface_metrics + // since gated "on prospecting + N + Y" live at P5 per placement-market.brief.md PR 3 + Constraints). + // Snapshots policy at offer time (immutable). Correction lens + learning via outcome_json. + // Always writes to agent_actions (specialist_id: 'placement-router'). + // Internal for /placement/* + upstream MCP mcp__platform-placement-market__propose_placement etc. + // Cites placement-market.brief.md PR 3, contract.md (core router, propose/evaluate, Correction lens), + // DESIGN.md §5, CLAUDE.md, 0012 + 0005 + agent-action.entity.ts . + + private async recordRouterDecision( + userId: string, + orgId: string | null, + actionType: string, + targetId: string | null, + outcome: Record, + autoExecuted = false, + ): Promise { + const write: CreateAgentActionDto = { + user_id: userId, + org_id: orgId ?? null, + specialist_id: "placement-router", + action_type: actionType, + target_kind: "placement_offer", + target_id: targetId, + stakes: "medium", + confidence: 0.65, + auto_executed: autoExecuted, + approved_by: null, + approved_at: null, + outcome_json: outcome, + }; + await this.agentActions.create(write); + } + + /** + * Propose a curated placement offer (anonymized pre-consent). + * Performs matching against policy + capacity + gated signals; creates offer + decision audit. + */ + async proposePlacementOffer(input: { + user_id: string; + org_id?: string | null; + prospect_id?: string | null; + target_provider_user_id: string; + category: string; + availability_window?: Record; + client_intent?: Record; + }): Promise { + const uid = input.user_id; + const oid = input.org_id ?? null; + const targetUid = input.target_provider_user_id; + const cat = input.category as any; + + // Read policy/capacity for target provider (via where override for specialist/internal use). + const policies = await this.findAllPolicies({ + user_id: targetUid, + } as FindOptionsWhere); + const policy = policies[0] ?? { + policy_json: { default_share: 10, min_graph_fit: 0.5, categories: ["*"] }, + }; + const policyJson = policy.policy_json as Record; + + const caps = await this.findAllCapacity({ + user_id: targetUid, + surface: cat, + } as FindOptionsWhere); + const cap = caps[0]; + const hasCapacity = !!cap && (cap.open_slots ?? 0) > 0; + + // Gated reads (prospects via resolver, graph aggregates, coop_attestations, Y vetting, surface_metrics booking_inquiry etc). + // Per PR 3 + brief: use conditional/mocks; note the gate. Full in P5 when live. + let graphFit: number | null = null; + let coopVetting: Record | null = null; + const graphAvailable = false; // gated on cross-provider-graph + N + Y at P5 + if (graphAvailable) { + // would query prospect_subject_aggregates + coop_peer_attestations + surface_metrics here + graphFit = 0.8; + coopVetting = { n: 5, rep: 0.9 }; + } else { + graphFit = 0.71; // mock for shape + coopVetting = { + n_attestations: 2, + y_vetted: false, + note: "mocked (prospecting graph + N-coop + Y live at P5 per placement-market.brief.md PR 3)", + }; + } + + // Basic policy match + capacity. + const minFit = (policyJson.min_graph_fit as number) ?? 0.5; + const policyAccept = hasCapacity && (graphFit ?? 0) >= minFit; + const termsSnap = { + share_percent: (policyJson.default_share as number) ?? 10, + category: cat, + high_level: "anonymized pre-consent per contract", + }; + const polSnap = { ...policyJson, snapshot_at: new Date().toISOString() }; + + // Always propose the offer (curated) for this PR shape; caller/human gate later per Never. + const offerDto: CreatePlacementOfferDto = { + user_id: targetUid, // the placement provider + org_id: oid, + prospect_id: input.prospect_id ?? null, + target_provider_user_id: targetUid, + state: "proposed", + category: cat, + availability_window: input.availability_window ?? { + start: new Date().toISOString(), + note: "derived from surface_metrics + capacity (gated)", + }, + terms_snapshot: termsSnap, + policy_snapshot: polSnap, + graph_fit: graphFit, + coop_vetting_ref: coopVetting, + }; + const offer = await this.createOffer(offerDto); + + // Write decision to agent_actions for every router action (per PR 3 desc + contract Outputs). + await this.recordRouterDecision( + uid, + oid, + "propose_placement_offer", + offer.id, + { + matched: policyAccept, + graph_fit: graphFit, + has_capacity: hasCapacity, + policy_used: polSnap, + gated_note: graphAvailable + ? undefined + : "mocks for prospects/graph/coop/N/Y/surface_metrics", + }, + false, // human-on-loop, no auto + ); + + return offer; + } + + /** + * Evaluate incoming placement offer (client/prospect or provider side). + * Records decision + correction lens (for learning) to agent_actions. + * Does not mutate post-insert offers (append-only); handoff separate. + */ + async evaluateIncomingOffer(input: { + user_id: string; + org_id?: string | null; + offer_id: string; + side: "provider" | "client" | "prospect"; + decision: "accept" | "decline" | "correction"; + correction?: Record | null; + }): Promise<{ offer: PlacementOfferEntity; decisionRecorded: boolean }> { + const uid = input.user_id; + const oid = input.org_id ?? null; + const offer = await this.findOneOffer(input.offer_id); + + const outcome: Record = { + side: input.side, + decision: input.decision, + offer_state: offer.state, + correction_applied: !!input.correction, + }; + if (input.correction) { + // Correction lens + learning: e.g. "shouldn't have offered this category" -> threshold raise noted in outcome. + // Router will use in future proposals (aggregates via agent_actions). + outcome.correction = input.correction; + outcome.learning = + "dials fit/reputation or accept-threshold per contract Correction lens"; + } + + await this.recordRouterDecision( + uid, + oid, + "evaluate_offer", + offer.id, + outcome, + false, + ); + + return { offer, decisionRecorded: true }; + } }