feat(platform-api): PR 3 placement discovery matching logic + graph/policy integration (in specialist)
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).
This commit is contained in:
parent
d10815deb1
commit
7742ccd94c
11 changed files with 436 additions and 34 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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<SurfaceMetricRef> {
|
||||
assertValid(row);
|
||||
return this.platformApi.post<SurfaceMetricRef>('/surface-metrics', toWire(row));
|
||||
return this.platformApi.post<SurfaceMetricRef>(
|
||||
"/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<SurfaceMetricRef[]> {
|
||||
async writeMany(
|
||||
rows: readonly SurfaceMetricWrite[],
|
||||
): Promise<SurfaceMetricRef[]> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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<ProviderCapacityEntity> {
|
||||
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<PlacementOfferEntity> {
|
||||
return this.service.proposePlacementOffer(dto);
|
||||
}
|
||||
|
||||
@Post("evaluate")
|
||||
async evaluateOffer(
|
||||
@Body() dto: EvaluateOfferDto,
|
||||
): Promise<{ offer: PlacementOfferEntity; decisionRecorded: boolean }> {
|
||||
return this.service.evaluateIncomingOffer(dto);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,3 +263,75 @@ export class UpdateProviderCapacityDto {
|
|||
@IsObject()
|
||||
notes?: Record<string, unknown> | 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<string, unknown>;
|
||||
|
||||
@ApiProperty({ type: "object", additionalProperties: true, required: false })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
client_intent?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn> };
|
||||
let agentActions: { create: ReturnType<typeof vi.fn> };
|
||||
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<PlacementOfferEntity>,
|
||||
handoffsRepo as unknown as Repository<PlacementHandoffEntity>,
|
||||
|
|
@ -91,6 +96,7 @@ describe("PlacementsService", () => {
|
|||
policyRepo as unknown as Repository<PlacementPolicyEntity>,
|
||||
capacityRepo as unknown as Repository<ProviderCapacityEntity>,
|
||||
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"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ProviderCapacityEntity>,
|
||||
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<string, unknown>,
|
||||
autoExecuted = false,
|
||||
): Promise<void> {
|
||||
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<string, unknown>;
|
||||
client_intent?: Record<string, unknown>;
|
||||
}): Promise<PlacementOfferEntity> {
|
||||
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<PlacementPolicyEntity>);
|
||||
const policy = policies[0] ?? {
|
||||
policy_json: { default_share: 10, min_graph_fit: 0.5, categories: ["*"] },
|
||||
};
|
||||
const policyJson = policy.policy_json as Record<string, unknown>;
|
||||
|
||||
const caps = await this.findAllCapacity({
|
||||
user_id: targetUid,
|
||||
surface: cat,
|
||||
} as FindOptionsWhere<ProviderCapacityEntity>);
|
||||
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<string, unknown> | 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<string, unknown> | 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<string, unknown> = {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue