Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f19276ab3 | |||
| c8648088cb | |||
| 5838bdeb51 | |||
| 0a091c2e50 | |||
| 73b493915f | |||
| baa9ef5d2d |
@@ -8,6 +8,21 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
|
||||
|
||||
**v0.4 additions (Distribution & Bootstrap CLI, founder directive D-016):** a new `apps/cli` package — the `nextcraft` bootstrap CLI (`doctor`/`bootstrap`/`verify`/`dev`) compiled to a self-contained linux x64 binary via **Node SEA** (probe-verified: Go/Rust absent, node v24.15.0 SEA-capable), installed by a repo-served one-liner script that resolves the latest Gitea release, downloads binary + sha256 sidecar, verifies, and installs to `~/.local/bin`. Every release from v0.4 onward attaches the binary + checksum as release assets (the "ongoing binaries" requirement). The CLI is a thin wrapper: all orchestration logic stays in `apps/ai-service/scripts/` (bootstrap.sh/dev.sh) — the CLI composes them via subprocess (A-202), duplicating nothing. Previously-planned v0.4 seams (real STT/TTS, KYC, design/sim envs, seq-lease) move to v0.5.
|
||||
|
||||
### v0.6 Research Conclusions (Phase 0 RESEARCH)
|
||||
|
||||
32. **D-048 Marketplace module layout mirrors D-031** — one new engine module `ai_service/marketplace/` inside apps/ai-service (no new apps, no new ports): `base.py` (pydantic contracts: `Posting`, `Employer`, `PostingTags`, `IngestPosting`), `store.py` (6th D-027 store family, D-049), `ingest.py` (PostingSource adapters, D-050), `tagging.py` (D-052), `embedding/` (protocol + mock + openai_compat + factory, D-051), `search.py` (hybrid facet+cosine ranking, A-405), `enrichment.py` (D-053), `resume.py` (D-054), with the HTTP surface in `ai_service/api/marketplace.py` (`/v1/marketplace/*` router) + `api/marketplace_admin.py` (`/v1/marketplace/admin/*`, D-057) composed via `deps.py` app.state DI (`get_marketplace_store`, `get_embedding_provider`). Boundary rules identical to identity/voice: `marketplace/` never imports `agents/` or `api/`; settings in `config.py` (backend-engineer territory); the G-18 stub MOVES from `api/identity.py` into the new router and becomes real (D-058) — `identity/` keeps only the `require_verified_adult` gate dependency it exports today.
|
||||
33. **D-049 Sixth store = one SQLite DB, five tables, DefenseStore conventions** — the marketplace store joins the shared `~/.nextcraft/data/nextcraft.db` (all 5 prior stores already share it; `Settings().db_path`). Tables: `posting` (id PK, source, external_id, employer_id, title, description, skills/seniority/category/location/remote/salary JSON+portable columns, status `draft|published|closed`, posted_at, unique constraint on (source, external_id) for idempotent upsert per A-402); `employer` (id PK, ingested raw fields, unique per source); `embedding` (posting_id PK, `vector` BLOB of struct-packed little-endian float32, `model`, `dim` — A-403/A-404 per-row provenance so lazy re-embed on provider change works); `application` (id PK, posting_id, learner_id, status `submitted`, created_at, unique (posting_id, learner_id) for A-408 idempotent apply); `employer_enrichment` (append-only: id PK, employer_id, field, value, source `llm|manual`, model, created_at — A-409). All tables mirror the probe-verified identity/store.py mechanics: WAL/synchronous=NORMAL/busy_timeout/foreign_keys pragmas, `sa_type=String/JSON` portable columns, `_validate()` hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz normalization. `VectorStore` is a protocol method-group on the marketplace store (pgvector-swap-ready, D-019/D-027 pattern).
|
||||
34. **D-050 PostingSource = protocol + two adapters, no scheduler** — `PostingSource` protocol: `fetch() -> Iterable[IngestPosting]` (pydantic, A-402 shape: source, external_id, employer + posting fields). Two v0.6 implementations: `seeded_corpus` (in-repo JSON seed ported ONCE from `packages/mock-data/jobs.ts` (20 jobs) + `employers.ts` (10 employers) per D-021 convention — shared IDs, mock `matchScore` DROPPED since D-051 computes real ones) and `admin_api` (the founder-authored path; the admin router calls ingest directly rather than an HTTP hop). Ingest upserts idempotent on (source, external_id); a `POST /v1/marketplace/admin/ingest` route triggers a source run explicitly (founder-invoked — no cron/crawler, A-401/A-402). The 3M+ external corpus lands later as a third adapter with zero store/route changes.
|
||||
35. **D-051 EmbeddingProvider = D-030 mirror with a corrected real path** — protocol `embed(texts: list[str]) -> list[EmbeddingVector]` (frozen pydantic: `values: list[float]`, `model`, `dim`) in `marketplace/embedding/base.py`. Deterministic mock: 256-dim feature-hash of normalized tokens (pure stdlib, similarity deterministic — tests never call the cloud). Real: `OpenAIEmbedProvider` on the shared httpx pool posting `{"model", "input": [texts]}` to `{AI_EMBED_BASE_URL}/embeddings` (OpenAI-compatible; `_sanitize` key redaction mirroring `openai_audio.py`). **Probe correction to A-404:** ollama-cloud does NOT expose `/v1/embeddings` (404 path-not-found; chat/completions auth-gates 401) — so `AI_EMBED_BASE_URL` must point at a different OpenAI-compatible endpoint (local ollama `:11434/v1` supports it, or any embeddings cloud); provider selection `AI_EMBED_PROVIDER=mock|openai-embed` (default mock, G-11 boot-safe fallback with loud log — exact `voice/factory.py` pattern, `AI_EMBED_MODEL`/`AI_EMBED_API_KEY` env-only). Storage per A-403: struct-packed `<{dim}f` float32 BLOB + model + dim per row; cosine in pure Python (sum of products / norm product, ≤10⁴ postings); candidate side embeds competency-profile text on demand, never stored. Confidence in A-404's parenthetical drops it to ~0.5 but the protocol design is unaffected (endpoint-agnostic by config is the D-014 pattern anyway).
|
||||
36. **D-052 Tagging = D-020 structured_completion, no new agent** — `marketplace/tagging.py` composes the EXISTING `structured_completion(provider, messages, model, schema, schema_hint)` (agents/structured.py, all 4 layers) with a `PostingTags` pydantic schema (skills: list[str] ≤10, seniority enum, category enum) + a versioned prompt constant in `prompts/` (or module-local, matching variants' pattern); no BaseAgent needed — tagging is a one-shot extraction like grading's rubric call. Mock-first for free: `settings.provider` defaults to `mock` and `structured_completion` runs against `MockLLMProvider.chat`, so tests stay cloud-free. Tags persisted as a JSON column on the posting row (D-049); tag facets feed the search filter sidebar (A-405 hard filters); re-tagging on posting update overwrites the tags column (single source of truth, no append-only need — unlike enrichment D-053, tags are derived data).
|
||||
37. **D-053 Enrichment = append-only provenance table + merge-on-read** — per A-409: `employer_enrichment` rows are append-only `(employer_id, field, value, source: llm|manual, model, created_at)`; read precedence **manual > llm > ingested-raw** computed at API-response time (a small `merge_employer()` over rows, raw fields last); every enriched field carries `{value, source}` provenance markers in the API response (A-304 honesty — LLM copy visibly marked). Trigger: a new employer's first published posting fires a one-shot LLM structured extraction (D-020, same layering as D-052, schema: description, social_links, logo_ref) — founder override = upsert a `manual`-source row, never a mutation of `llm` rows. No employer self-serve anywhere (A-407).
|
||||
38. **D-054 Resume parse = pypdf (probe-verified) → D-020 draft extraction** — pypdf 6.19.0 pip-reachable through the existing venv channel (probe 1; the ONLY new dependency, pinned `pypdf>=6.19,<7` in pyproject.toml). Pipeline: upload guard (PDF/TXT/MD only, ≤2 MB, A-410) → text extraction (`pypdf.PdfReader` page-text join for PDFs; raw decode for TXT/MD) → empty-extract on a PDF = honest 422 "text layer required" (no OCR) → cap ~100K chars → D-020 structured extraction into a `ParsedProfile` pydantic schema mapped to the competency-profile shape (competencies, skills, experience bands) → response returns as **DRAFT** (`status: "draft"`) — nothing persists to the learner profile until an explicit confirm call (REQ-6-005 review gate). Raw resume bytes never stored, never logged (A-305 pattern; the existing `_scrub_pii_validation` handler precedent applies). Tests use small generated PDFs or TXT fixtures — cloud-free.
|
||||
39. **D-055 Web migration = client shells + engine-client extension; remove 2 generateStaticParams** — board/detail/employer-profile become one static client shell each fetching at runtime per A-406 (the proven build/defend pattern): `marketplace/page.tsx` keeps its server shell but `JobFilters` receives fetch-backed data (it already owns all filter logic client-side — the facet contract survives unchanged); `jobs/[jobId]` and `employers/[employerId]` drop `generateStaticParams` (the ONLY two removals — learner catalog/learn/build/defend keep theirs, their data stays mock) and render a client shell that fetches then renders (or `notFound()` on 404). `engine-client.ts` gains a marketplace section: `listPostings(filters)`, `getPosting(id)`, `getEmployer(id)`, `searchPostings(query)` → typed against new `packages/types` marketplace wire shapes (data-engineer dual-schema sync); 403 discrimination (verify-CTA vs allowlist) already works. `packages/mock-data` jobs.ts/employers.ts go dormant for the marketplace path (still imported by employer/admin mockups — NOT deleted, A-406/A-407); the seed port (D-050) copies values once. No build-time DB reads — D-039 export build stays repo-only.
|
||||
40. **D-056 SEO = build-time metadata on static set + client JSON-LD on runtime set + ai-service sitemap/robots** — per A-411, split: (a) build-time-static pages (landing, catalog/stack detail, marketplace board, pricing) get `export const metadata` (probe 7: the static-export-compatible API the app already uses in layout.tsx/enroll) + OG tags + server-rendered JSON-LD (`Organization` on employer-adjacent static, `ItemList` on the board) rendered into the export; (b) runtime-data pages (job detail, employer profile) inject `JobPosting`/`Organization` JSON-LD + title client-side after fetch — accepted JS-crawler limitation, recorded; (c) `/sitemap.xml` + `/robots.txt` as plain FastAPI GET routes on the api app enumerating the static page list + all `published` postings — **probe 6 verified mount order: API routes beat the `StaticFiles("/")` mount**, so they win over the export even in single-port production; do NOT use Next `app/sitemap.ts` (it enumerates at build time and cannot see runtime postings under `output: 'export'`). robots.txt points at `/sitemap.xml`; sitemap URLs use the deployed origin (env-configured `AI_PUBLIC_BASE_URL`-style setting, backend-engineer territory).
|
||||
41. **D-057 Admin guard = AI_ADMIN_TOKEN header check, env-only, fail-closed** — all `/v1/marketplace/admin/*` routes (postings create + lifecycle transitions draft→published→closed, ingest trigger, enrichment manual-override, applications review) take a FastAPI dependency `require_admin` comparing `X-Admin-Token` (or `Authorization: Bearer`) against `Settings().admin_token` (`AI_ADMIN_TOKEN`, env-only per D-014, probe: no admin token exists in config today — greenfield). Unset token = admin routes 403-fail-closed (never open; a loud boot warning tells the founder to set it, G-11 doctrine). This is the A-401 operator surface; D-043 learner gate composition is untouched — admin routes do NOT pass the learner allowlist/identity gates (different principal class).
|
||||
42. **D-058 Apply route goes real, composition unchanged** — `POST /v1/marketplace/apply` (moved from api/identity.py into api/marketplace.py, D-048) keeps the exact D-043 order (G-5 allowlist → `require_verified_adult` → rate caps), then: posting must exist and be `published` (404/409 otherwise), insert an `application` row (status `submitted`, unique (posting_id, learner_id) → duplicate apply is idempotent 200-with-existing, A-408), return an honest confirmation payload `{application_id, posting_id, status: "submitted"}` (the `stub/mock` markers die). Founder reviews via `GET /v1/marketplace/admin/applications` (D-057-guarded); no notification infra. Body becomes `{posting_id}` + the learner identity from the verified record rather than a client-asserted `job_id`.
|
||||
43. **D-059 v0.6 wave order = ROADMAP order confirmed with two couplings** — P1 postings backend must ship WITH the web client shells in the same phase (a live store behind mock-reading pages proves nothing — REQ-6-001's success criterion is surfaces reading real data); P2 tagging before P3 search because facets drive the sidebar filters AND the posting text embedded in P3 should include the normalized tags; P4's two workstreams (enrichment REQ-6-004, resume REQ-6-005) are independent of each other — parallelizable within the phase; P5 SEO depends only on P1 (routes exist to enumerate) but stays last-before-final per ROADMAP since sitemap needs `published` postings and the board metadata should reflect final facets; P6 final review + ship. Tagging/enrichment/resume all route through the same D-020 call site — one prompt-review pass covers all three.
|
||||
|
||||
### v0.5 Research Conclusions (Phase 0 RESEARCH)
|
||||
|
||||
25. **D-040 Voice real path = `OpenAIAudioProvider` on the shared httpx pool (D-R01)** — implements the D-030 protocol: `transcribe()` = multipart `POST {AI_VOICE_BASE_URL}/audio/transcriptions` (`file` + `model`, response_format=json → `TranscriptSegment`), `synthesize()` = streaming `POST /audio/speech` (JSON body, raw byte stream, `input` ≤4096 chars). Carries `descriptor = VoiceDescriptor(mode="server", ...)` which defense.py:143 already prefers — mode selection drops in with zero API changes. Constructor takes the lifespan `httpx.AsyncClient` (D-017; read=300s already tolerates multi-minute clips). Factory signature becomes `voice_provider_from_settings(settings, http_client)`. Errors sanitized with key redaction (mirror `llm/openai_compat.py:_sanitize`).
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"phase": 4,
|
||||
"stage": "verify",
|
||||
"milestone": "v0.6",
|
||||
"milestone_name": "marketplace-engine",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-22T03:00:00Z"
|
||||
}
|
||||
@@ -1,3 +1,51 @@
|
||||
# Nextcraft v0.6 — GRILL.md (Adversarial Review Verdict)
|
||||
|
||||
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-BINDINGS · **Confidence:** 0.72 · **Date:** 2026-09-21
|
||||
|
||||
## Summary
|
||||
|
||||
The v0.6 Marketplace Engine plan (46 tasks, 6 phases) is well-researched and tightly scoped, but its central P1 web-migration mechanism — removing `generateStaticParams` from the two marketplace dynamic routes — is **provably build-breaking**: Next 15.5.25 (installed `node_modules/next/dist/build/index.js:1244`) hard-throws error E87 for any dynamic route without `generateStaticParams` under `output: 'export'`, and even a function returning `[]` throws (`length > 0` check). The claimed "proven build/defend pattern" precedent does not exist as described — build/defend routes KEPT their `generateStaticParams`. Four quieter gaps (ingest status semantics, pagination-vs-client-filters, learner-profile sidecar persistence, sitemap origin) each ship a broken happy path or an audit flag. All fixed via binding decisions G-101..G-110 + 8 plan edits applied by the orchestrator before execute.
|
||||
|
||||
## Per-Axis Findings
|
||||
|
||||
| Axis | Verdict | Rationale |
|
||||
|------|---------|-----------|
|
||||
| Feasibility | PASS | Personas/territories match proven patterns (voice factory, identity store, D-020 layer); 46 tasks comparable to shipped v0.5. |
|
||||
| Scope discipline | PASS | Pricing/employer-auth/scheduler/crawler all absent (MH-M6, A-407/A-412, D-050); only borderline add is admin posting-edit, defensible as the re-tag trigger within A-401. |
|
||||
| Vertical-slice integrity | FAIL → fixed | P1 backend+shells coupling is right, but the shell mechanism threw E87 at build and runtime-created postings would get no exported page — fixed by G-101 (placeholder param + FastAPI catch-all shell routes). |
|
||||
| Testability | AT-RISK → fixed | MH-1e pinned a build that could not emit; MH-3d's "matchScore exists nowhere" overclaimed (mock-data retains it for employer/admin mockups) — scoped by G-107/G-110. |
|
||||
| Security posture | PASS | Fail-closed admin, D-043 gate order, upload bounds, PII sentinel tests verified against existing code; hardening added by G-105/G-108. |
|
||||
| Performance/capacity | PASS | Brute-force cosine ≤10⁴ honest; SQLite WAL/busy_timeout verified in the identity store; sitemap fine to 50k URLs. |
|
||||
| Operational readiness | AT-RISK → fixed | D-039 deploy fabric intact (mount order verified), pypdf via bootstrap channel sound; founder workflow breaks at ingest-status (G-102) and sitemap-origin (G-105) — now defined. |
|
||||
| Dependency risk | PASS | pypdf verified as the only new dep; JSON-LD/sitemap hand-rolled; embedding mock stdlib-only. A-404's stale "ollama-cloud exposes /embeddings" parenthetical annotated as superseded by D-051. |
|
||||
| Adoption/UX | AT-RISK → fixed | Happy-path steps 2-4 contradicted until ingest status defined (G-102); verify-CTA/404/409 states well-specified; sitemap URL-resolvability pinned (G-109). |
|
||||
|
||||
## Binding Decisions (applied to PLAN.md before execute)
|
||||
|
||||
- **G-101** — Do NOT remove `generateStaticParams` (E87 build break under `output:'export'`). Keep it returning one placeholder param per route; export one client shell; add FastAPI catch-all `GET /marketplace/jobs/{id}` + `/employers/{id}` (trailing-slash variants) serving the shell — same routes-beat-the-mount mechanism as sitemap. [Vertical-slice integrity]
|
||||
- **G-102** — Ingest-status contract: seed-port postings enter `published` with original `postedAt`; admin-authored start `draft`. [Adoption/UX]
|
||||
- **G-103** — Board fetch = paged fetch-all (≤10⁴ documented); API returns `total_published`; "Showing X of Y" over the fetched set with honest truncation notice. [Performance]
|
||||
- **G-104** — Confirmed learner profiles persist in the marketplace store as a 6th table `learner_profile` (learner_id PK, profile JSON, confirmed_at, model) — NOT a filesystem sidecar. D-049 amended five→six tables. [Scope discipline]
|
||||
- **G-105** — Unset `AI_PUBLIC_BASE_URL`: loud G-11 boot warning; sitemap serves empty static set + warning (never malformed/relative URLs); robots omits the Sitemap directive; boot never crashes. [Security]
|
||||
- **G-106** — Match scores appear only in search context; cards without an active query show no score; per-learner profile-driven card scores post-v0.6; REQ-6-003 "scores on job cards" satisfied conditionally — recorded. [Adoption/UX]
|
||||
- **G-107** — "Mock matchScore absent" scoped to marketplace-path API payloads + marketplace surfaces; grep excludes packages/mock-data and employer/admin route groups. [Testability]
|
||||
- **G-108** — `admin_token` as `SecretStr`; compare via `secrets.compare_digest`. [Security]
|
||||
- **G-109** — Every sitemap job/employer URL must serve 200 on the single-port app (G-101 shells); board ItemList JSON-LD enumerates static structure only — never runtime postings. [Operability]
|
||||
- **G-110** — P1 web tests pin the E87 regression class itself (shells emitted; catch-all serves arbitrary ids; `notFound()` at non-placeholder id via full page load); the honest-501 stub pin at tests/identity/test_identity.py:444 rewritten onto the marketplace router in the same phase. [Testability]
|
||||
|
||||
## Plan Edits Applied
|
||||
|
||||
1. Task 1-3-04 + MH-1e rewritten per G-101 (placeholder shells + catch-all routes; E87 test class; stub-pin rewrite)
|
||||
2. Task 1-2-01 + MH-1f ingest-status contract (G-102)
|
||||
3. Task 1-3-02 paged fetch-all + total_published (G-103)
|
||||
4. Task 4-B-03 learner_profile table replaces the JSON sidecar (G-104; D-049 → six tables)
|
||||
5. Task 5-2-01 + MH-5a/5b unset-origin behavior + URL-resolvability (G-105/G-109)
|
||||
6. Task 3-3-01 + MH-3d matchScore scope + no-query-no-score (G-106/G-107)
|
||||
7. Task 1-2-03 SecretStr + compare_digest (G-108)
|
||||
8. PROJECT.md A-404 stale parenthetical annotated as superseded by D-051 probe
|
||||
|
||||
---
|
||||
|
||||
# Nextcraft v0.3 — GRILL.md (Adversarial Review Verdict)
|
||||
|
||||
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-CHANGES · **Confidence:** 0.72
|
||||
|
||||
+115
-95
@@ -2,13 +2,15 @@
|
||||
|
||||
## Persona Roster
|
||||
|
||||
> **v0.6 update (RESEARCH, lead-developer assessment):** milestone = Marketplace Engine (D-047). **Activated:** data-engineer (6th D-027 store family — five tables, seed port, TS wire types dual-schema sync, its core constraint territory), ai-engineer full activation (tagging/enrichment/resume D-020 prompts + EmbeddingProvider protocol — LLM-facing work returns), frontend-engineer (client shells, engine-client marketplace extension, generateStaticParams removal ×2, SEO metadata + client JSON-LD), backend-engineer (module wiring, config surface: AI_EMBED_*/AI_ADMIN_TOKEN/AI_PUBLIC_BASE_URL, sitemap/robots routes). **security-auditor re-activated (phase-specific)** for admin-token fail-closed (P1), resume upload bounds + PII-logs (P4), sitemap origin (P5), final review (P6). **Deactivated:** identity-engineer (v0.5 custom — module shipped; only the `require_verified_adult` gate import is reused), voice-engineer, sandbox-engineer (no voice/sandbox/telemetry work), cli-engineer, design-system-engineer (existing primitives cover the marketplace shells).
|
||||
|
||||
> **v0.5 update (RESEARCH, lead-developer assessment):** milestone = Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease. **Reactivated:** voice-engineer (real server STT/TTS, its v0.3 territory), sandbox-engineer (design/sim environment kinds + the seq-ack protocol on the fabric/agent it owns), frontend-engineer (defense audio upload, identity enrollment flow, kind-aware build surface). **New custom persona: identity-engineer** (KYC/identity domain — 5th store, provider protocol, age-gate dependencies, PII hygiene). **security-auditor re-activated (phase-specific)** for identity PII + age-gate bypass + audio upload attack surface (phases 1-4 review, final phase). ai-engineer light-touch (no LLM-facing work this milestone). backend-engineer retains settings/factory wiring + turbo/root scripts. cli-engineer inactive (v0.3.6 hotfix shipped; no CLI work planned). design-system-engineer/data-engineer inactive (one TS types extension only — data-engineer light-touch for variants/identity types).
|
||||
|
||||
### lead-developer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Coordinates the four v0.5 seams across voice/identity/sandbox/telemetry territories; resolves wave-order (D-046: seq-lease first) and identity-gate composition (D-043) boundaries
|
||||
reason: Coordinates the v0.6 marketplace territory split (store/ingest/embedding/tagging/search/enrichment/resume across data/ai/backend/frontend personas); owns wave-order coupling decisions (D-059: P1 ships backend WITH client shells)
|
||||
domain: coordination
|
||||
frameworks:
|
||||
- next.js
|
||||
@@ -27,77 +29,59 @@ territory:
|
||||
- "apps/ai-service/pyproject.toml"
|
||||
```
|
||||
|
||||
### voice-engineer
|
||||
### data-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.3 territory reactivated — owns REQ-5-001/002: OpenAIAudioProvider (D-040) on the shared httpx pool, factory signature change, defense route fixes (fmt strip, size guard, media_type), descriptor mode=server, web audio POST
|
||||
domain: ai-media
|
||||
reason: v0.6 core activation — owns the 6th D-027 store family (D-049: posting/employer/embedding/application/employer_enrichment tables, WAL/pragmas/portable columns/validate hooks), the seed port from packages/mock-data jobs.ts+employers.ts (D-050, one-time, matchScore dropped), and the TS marketplace wire types (dual-schema sync with pydantic contracts)
|
||||
domain: data
|
||||
frameworks:
|
||||
- httpx
|
||||
- fastapi
|
||||
- pytest
|
||||
- react
|
||||
constraints:
|
||||
- provider-agnostic-protocol (D-030 drop-in; descriptor wins selection)
|
||||
- never-call-cloud-in-tests (MockTransport byte-contract pins)
|
||||
- key-redaction (mirror openai_compat _sanitize)
|
||||
- bounded-audio-in-memory (10MB guard before provider call)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/voice/**"
|
||||
- "apps/ai-service/tests/voice/**"
|
||||
- "apps/web/components/learner/defense-session.tsx"
|
||||
```
|
||||
|
||||
### identity-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.5 custom persona (RESEARCH, D-042/43) — owns the new identity module: IdentityProvider protocol + mock, 5th SQLite store (DefenseStore pattern), /v1/identity router, age-gate dependencies (allowlist → identity → rate caps), derived age_band + document refs (PII minimal), caplog sentinel scrub test
|
||||
domain: identity
|
||||
frameworks:
|
||||
- fastapi
|
||||
- sqlmodel
|
||||
- pytest
|
||||
- pydantic
|
||||
- typescript
|
||||
constraints:
|
||||
- pii-never-stored-raw (refs + derived bands only, A-305)
|
||||
- pii-never-logged (caplog sentinel pin)
|
||||
- mock-verdict-honesty (mock marker rides every response, A-304)
|
||||
- gate-composition-order (G-5 allowlist first, identity second, 429 caps last)
|
||||
- schema-first
|
||||
- type-safe
|
||||
- dual-schema-sync (TS/Python changes made in both places)
|
||||
- idempotent-upserts ((source, external_id) + (posting_id, learner_id) unique constraints)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/identity/**"
|
||||
- "apps/ai-service/tests/identity/**"
|
||||
- "apps/ai-service/ai_service/marketplace/base.py"
|
||||
- "apps/ai-service/ai_service/marketplace/store.py"
|
||||
- "apps/ai-service/ai_service/marketplace/ingest.py"
|
||||
- "packages/types/**"
|
||||
- "packages/mock-data/**"
|
||||
```
|
||||
|
||||
### sandbox-engineer
|
||||
### ai-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.3 territory reactivated — owns REQ-5-005/006 (template-layer env registry, starter contents, exec command policy) AND REQ-5-007 (seq-ack frame in ingest + agent spool trim, mid-burst regression test) — both live on the fabric/agent surfaces it built
|
||||
domain: infra
|
||||
reason: v0.6 full activation — LLM-facing work returns: tagging prompt+schema (D-052), enrichment extraction (D-053), resume parse extraction (D-054) — all through the existing D-020 structured_completion layer; plus the EmbeddingProvider protocol + deterministic feature-hash mock (D-051). Guards the agent/engine boundaries the marketplace surfaces touch
|
||||
domain: ai
|
||||
frameworks:
|
||||
- python
|
||||
- linux-namespaces
|
||||
- pydantic
|
||||
- httpx
|
||||
- pytest
|
||||
constraints:
|
||||
- stdlib-only-agent (AST-pinned sandbox-agent.py imports)
|
||||
- acks-are-advisory (gap detection stays authoritative; G-3/G-4 unchanged)
|
||||
- thread-safe-spool-trim (under _emit_lock, atomic Spool.rewrite)
|
||||
- digest-kind-agnostic (features derive from event kinds — pinned)
|
||||
- provider-agnostic-protocol
|
||||
- never-call-cloud-in-tests
|
||||
- mock-first (structured_completion runs against MockLLMProvider)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/sandbox/**"
|
||||
- "apps/ai-service/ai_service/telemetry/**"
|
||||
- "apps/ai-service/scripts/sandbox-agent.py"
|
||||
- "apps/ai-service/ai_service/variants/templates.py"
|
||||
- "apps/ai-service/tests/sandbox/**"
|
||||
- "apps/ai-service/tests/telemetry/**"
|
||||
- "apps/ai-service/ai_service/marketplace/tagging.py"
|
||||
- "apps/ai-service/ai_service/marketplace/enrichment.py"
|
||||
- "apps/ai-service/ai_service/marketplace/resume.py"
|
||||
- "apps/ai-service/ai_service/marketplace/embedding/**"
|
||||
- "apps/ai-service/ai_service/marketplace/search.py"
|
||||
- "apps/ai-service/ai_service/llm/**"
|
||||
- "apps/ai-service/ai_service/agents/structured.py"
|
||||
- "apps/ai-service/ai_service/prompts/**"
|
||||
```
|
||||
|
||||
### backend-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Owns the settings surface both new providers hang off (voice_base_url/key/models, identity provider selection), factory + main.py lifespan wiring (voice factory gains http_client), .env.example documentation
|
||||
reason: Owns the v0.6 config surface (AI_EMBED_PROVIDER/BASE_URL/MODEL/API_KEY, AI_ADMIN_TOKEN, AI_PUBLIC_BASE_URL — all env-only), main.py lifespan wiring (marketplace store + embedding provider factory on app.state), the /v1/marketplace/* + admin routers' DI, sitemap/robots routes (D-056), .env.example documentation
|
||||
domain: backend
|
||||
frameworks:
|
||||
- fastapi
|
||||
@@ -108,12 +92,16 @@ frameworks:
|
||||
constraints:
|
||||
- secrets-via-env-only (D-014; keys never in code or commits)
|
||||
- idempotent-scripts
|
||||
- fail-closed-admin (unset AI_ADMIN_TOKEN = admin routes 403 + loud boot warning)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/config.py"
|
||||
- "apps/ai-service/ai_service/main.py"
|
||||
- "apps/ai-service/ai_service/voice/factory.py"
|
||||
- "apps/ai-service/ai_service/api/marketplace.py"
|
||||
- "apps/ai-service/ai_service/api/marketplace_admin.py"
|
||||
- "apps/ai-service/ai_service/api/deps.py"
|
||||
- "apps/ai-service/scripts/**"
|
||||
- "apps/ai-service/.env.example"
|
||||
- "apps/ai-service/pyproject.toml"
|
||||
- "package.json"
|
||||
- "turbo.json"
|
||||
```
|
||||
@@ -122,7 +110,7 @@ territory:
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Reactivated for v0.5 UX: defense-session audio POST (recorded blob upload), identity enrollment flow (submit → pending → verified states + verify-CTA surfaces), build-surface kind-awareness (variant environment + test_command), engine-client identity + audio functions
|
||||
reason: v0.6 marketplace surfaces go live: board/detail/employer-profile client shells (D-055, build/defend pattern), engine-client marketplace functions (listPostings/getPosting/getEmployer/searchPostings/apply), generateStaticParams removal on jobs/[jobId]+employers/[employerId] ONLY, apply-flow wiring on the real route, SEO metadata (export const metadata on static set) + client-side JobPosting/Organization JSON-LD on runtime pages
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- react
|
||||
@@ -131,58 +119,106 @@ frameworks:
|
||||
constraints:
|
||||
- component-first
|
||||
- server-components-default
|
||||
- honest-state-surfaces (provider badge: mock vs browser vs server; unverified labels)
|
||||
- honest-state-surfaces (404 on missing posting, verify-CTA on 403, empty boards)
|
||||
- no-build-time-db-reads (D-039 export build stays repo-only)
|
||||
territory:
|
||||
- "apps/web/**"
|
||||
- "packages/ui/**"
|
||||
- "packages/types/**"
|
||||
```
|
||||
|
||||
### security-auditor
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: v0.5 re-activated — identity PII (storage + logs), age-gate bypass review, audio upload attack surface (10MB guard, format confusion), exec command policy (per-kind allowlist), TTS media_type. Phases 1-4 reviews + final phase
|
||||
reason: v0.6 re-activated — admin token fail-closed + route coverage (P1), apply gate composition preserved D-043 (P1), embedding provider key redaction (P3), resume upload bounds + raw-bytes-never-logged (P4), enrichment provenance honesty (P4), sitemap origin config (P5), final review (P6)
|
||||
domain: security
|
||||
frameworks:
|
||||
- pytest
|
||||
- httpx
|
||||
constraints:
|
||||
- STRIDE-classified
|
||||
- pii-never-stored-raw
|
||||
- pii-never-logged
|
||||
- bounded-uploads
|
||||
- fail-closed (unset admin token never opens routes)
|
||||
- bounded-uploads (resume 2MB/format guard)
|
||||
- secrets-never-logged (_sanitize key redaction)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/identity/**"
|
||||
- "apps/ai-service/ai_service/api/defense.py"
|
||||
- "apps/ai-service/ai_service/api/sandboxes.py"
|
||||
- "apps/ai-service/ai_service/voice/**"
|
||||
- "apps/ai-service/ai_service/api/marketplace.py"
|
||||
- "apps/ai-service/ai_service/api/marketplace_admin.py"
|
||||
- "apps/ai-service/ai_service/marketplace/resume.py"
|
||||
- "apps/ai-service/ai_service/marketplace/embedding/openai_compat.py"
|
||||
- "apps/ai-service/ai_service/api/identity.py"
|
||||
```
|
||||
|
||||
### ai-engineer
|
||||
### identity-engineer
|
||||
```yaml
|
||||
active: true
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: Light-touch v0.5 — no LLM-facing work (voice STT/TTS is media plumbing, not model work; examiner agent unchanged); guards the agent/engine boundaries the new surfaces touch
|
||||
domain: ai
|
||||
reason: v0.5 custom persona — module shipped complete (identity/, gates, /v1/identity/*); v0.6 only REUSES the require_verified_adult import on the marketplace apply route (D-058). No identity-domain work.
|
||||
domain: identity
|
||||
frameworks:
|
||||
- pydantic
|
||||
- httpx
|
||||
- fastapi
|
||||
- sqlmodel
|
||||
- pytest
|
||||
constraints:
|
||||
- pii-never-stored-raw
|
||||
- pii-never-logged
|
||||
- mock-verdict-honesty
|
||||
- gate-composition-order
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/identity/**"
|
||||
- "apps/ai-service/tests/identity/**"
|
||||
```
|
||||
|
||||
### voice-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.5 territory — voice shipped complete (openai-audio provider, defense routes, browser SR/TTS client); no v0.6 voice work. The EmbeddingProvider protocol MIRRORS the voice pattern (D-051) but the voice module itself is untouched.
|
||||
domain: ai-media
|
||||
frameworks:
|
||||
- httpx
|
||||
- fastapi
|
||||
- pytest
|
||||
- react
|
||||
constraints:
|
||||
- provider-agnostic-protocol
|
||||
- never-call-cloud-in-tests
|
||||
- key-redaction
|
||||
- bounded-audio-in-memory
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/llm/**"
|
||||
- "apps/ai-service/ai_service/agents/**"
|
||||
- "apps/ai-service/ai_service/prompts/**"
|
||||
- "apps/ai-service/ai_service/voice/**"
|
||||
- "apps/ai-service/tests/voice/**"
|
||||
- "apps/web/components/learner/defense-session.tsx"
|
||||
```
|
||||
|
||||
### sandbox-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.3/v0.5 territory — fabric + env kinds + seq-ack shipped complete; no v0.6 sandbox/telemetry work (marketplace data plane never enters the namespace fabric).
|
||||
domain: infra
|
||||
frameworks:
|
||||
- python
|
||||
- linux-namespaces
|
||||
- pytest
|
||||
constraints:
|
||||
- stdlib-only-agent
|
||||
- acks-are-advisory
|
||||
- thread-safe-spool-trim
|
||||
- digest-kind-agnostic
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/sandbox/**"
|
||||
- "apps/ai-service/ai_service/telemetry/**"
|
||||
- "apps/ai-service/scripts/sandbox-agent.py"
|
||||
- "apps/ai-service/ai_service/variants/templates.py"
|
||||
- "apps/ai-service/tests/sandbox/**"
|
||||
- "apps/ai-service/tests/telemetry/**"
|
||||
```
|
||||
|
||||
### cli-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.3 persona — CLI shipped complete (v0.3.6 daemon surface); no v0.5 CLI work planned. Reactivated if milestone work touches apps/cli.
|
||||
reason: No CLI work in v0.6 (the binary attaches to every release per the ongoing-binaries requirement; the ship workflow handles it — no apps/cli changes).
|
||||
domain: cli
|
||||
frameworks:
|
||||
- node
|
||||
@@ -206,7 +242,7 @@ territory:
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: No design-token or primitive work planned in v0.5 (existing primitives — MicControl, GradeBadge, TranscriptViewer — cover the voice surfaces); roster retained.
|
||||
reason: No design-token or primitive work in v0.6 (existing primitives + JobFilters cover the marketplace shells); roster retained.
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- tailwindcss
|
||||
@@ -218,28 +254,11 @@ territory:
|
||||
- "packages/ui/**"
|
||||
```
|
||||
|
||||
### data-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: Light-touch via frontend-engineer territory (variants/identity TS type extensions); no schema/mock-data work beyond the two typed additions.
|
||||
domain: data
|
||||
frameworks:
|
||||
- typescript
|
||||
constraints:
|
||||
- schema-first
|
||||
- type-safe
|
||||
- dual-schema-sync (TS/Python changes made in both places)
|
||||
territory:
|
||||
- "packages/types/**"
|
||||
- "packages/mock-data/**"
|
||||
```
|
||||
|
||||
## Phase-Specific Personas
|
||||
|
||||
| Persona | Phases | Removed After |
|
||||
|---------|--------|---------------|
|
||||
| security-auditor | 1 (seq-ack/agent), 2 (audio upload), 3 (identity PII), 4 (exec policy), 5 (final review) | milestone complete |
|
||||
| security-auditor | 1 (admin token + apply gates), 3 (embedding key redaction), 4 (resume bounds + PII), 5 (sitemap origin), 6 (final review) | milestone complete |
|
||||
|
||||
All other personas span the milestone. Deactivated personas receive no tasks.
|
||||
|
||||
@@ -247,8 +266,9 @@ All other personas span the milestone. Deactivated personas receive no tasks.
|
||||
|
||||
| Conflict | Resolution |
|
||||
|----------|------------|
|
||||
| voice-engineer vs backend-engineer (factory.py) | backend-engineer owns factory.py settings wiring + signature; voice-engineer owns openai_audio.py + its factory branch (provider construction), consumes config via DI |
|
||||
| sandbox-engineer vs frontend-engineer (build-surface) | sandbox-engineer owns engine-side kinds/templates/policy; frontend-engineer owns the web surface + client session hook; wire contract = VariantResponse TS types |
|
||||
| identity-engineer vs sandbox-engineer (gates) | identity-engineer owns the IdentityGate dependencies; sandbox-engineer owns the sandbox route they mount on (gate order D-043 is a joint review) |
|
||||
| security-auditor vs identity-engineer | identity-engineer implements; security-auditor reviews + may patch security defects directly in identity/ + api/defense.py (its territory) |
|
||||
| data-engineer vs backend-engineer (store.py / api routes) | data-engineer owns base.py/store.py/ingest.py schema + persistence; backend-engineer owns config.py/main.py/api routers/DI wiring — the marketplace store is consumed via deps.py, never imported directly by routes |
|
||||
| data-engineer vs ai-engineer (embedding storage) | data-engineer owns the embedding TABLE + BLOB codec; ai-engineer owns the EmbeddingProvider protocol + mock hash + search cosine math; the store's put/get_vector is the seam |
|
||||
| ai-engineer vs backend-engineer (factory/config) | backend-engineer owns config surface + factory wiring in main.py; ai-engineer owns provider implementations (openai_compat.py + mock.py) |
|
||||
| frontend-engineer vs data-engineer (wire types) | data-engineer owns packages/types marketplace shapes (dual-schema sync); frontend-engineer consumes them in engine-client + shells; wire contract = pydantic contracts in marketplace/base.py |
|
||||
| security-auditor vs backend-engineer (admin routes) | backend-engineer implements require_admin; security-auditor reviews fail-closed behavior + may patch security defects directly in api/marketplace_admin.py + resume.py (its territory) |
|
||||
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
|
||||
+225
-166
@@ -1,182 +1,241 @@
|
||||
# Nextcraft v0.5 — PLAN.md
|
||||
# Nextcraft — PLAN.md (v0.6 Marketplace Engine)
|
||||
|
||||
## Overview
|
||||
|
||||
**Milestone:** v0.5 — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease
|
||||
**Tag line:** v0.4.x patches (P0 → v0.4.0 … P5 → v0.4.5 = milestone release)
|
||||
**Branch:** milestone/v0.5-real-voice-identity-envs
|
||||
**Milestone:** v0.6 — Marketplace Engine (D-047): the real marketplace backend behind the existing surfaces.
|
||||
**Tag line:** v0.5.x patches (P0 → v0.5.0 … P6 → v0.5.6 = milestone release) on the v0.5.x line
|
||||
**Branch:** milestone/v0.6-marketplace-engine
|
||||
|
||||
**Environment facts (probe-verified, apply throughout):** python 3.11.2 (venv at apps/ai-service/.venv), node v24.15.0, pnpm 12.3.4, no docker/podman/sudo, `unshare` userns verified working, SQLite is the only persistence (D-027), tests NEVER call the cloud (mock providers + httpx MockTransport), secrets only in gitignored `.ciagent/.env.secrets` / `.env*` (D-006/D-014). Runtime state lives in `~/.nextcraft/` (D-039) — tests pin to tmp dirs; sandbox workdir fixtures use the bind-mount-safe `sandbox_dir` fixture (overlayfs /tmp breaks userns binds).
|
||||
**Phase list:** P1 Postings backend + aggregation adapter (REQ-6-001) → P2 AI tagging (REQ-6-002) → P3 Semantic vector search (REQ-6-003) → P4 Enrichment + resume parsing (REQ-6-004/005, two parallel workstreams) → P5 SEO programmatic pages (REQ-6-006) → P6 Final review + ship.
|
||||
|
||||
**Wave order (D-046, binding):** P1 seq-lease → P2 voice → P3 identity → P4 environments → P5 final. Seq-lease lands first: it fixes the transport before environment phases add reconnecting telemetry producers; it touches no stores, no web, no settings.
|
||||
**Wave order (D-059, binding):** P1 ships the backend **WITH the web client shells** in the same phase — a live store behind mock-reading pages proves nothing; REQ-6-001's success criterion is surfaces reading real data. P2 before P3 because tag facets drive the sidebar filters AND the P3 posting embedding text includes the normalized tags. P4's two workstreams (enrichment / resume) are independent — parallelizable within the phase. P5 SEO depends only on P1 (routes exist to enumerate) but stays last-before-final: the sitemap needs `published` postings and the board metadata should reflect final facets. P6 final review + ship.
|
||||
|
||||
**Research decisions D-040..D-046 are binding design contracts.** This plan operationalizes them; it does not re-litigate them.
|
||||
**Research decisions D-048..D-059 are binding design contracts.** This plan operationalizes them; it does not re-litigate them. Assumptions A-401..A-412 are resolved fact.
|
||||
|
||||
**Environment facts (unchanged):** python 3.11.2 (venv at apps/ai-service/.venv, pip-only — A-009), node v24.15.0, pnpm 12.3.4, SQLite shared `Settings().db_path` (`~/.nextcraft/data/nextcraft.db`), tests NEVER call the cloud (mock providers + httpx MockTransport), secrets env-only (D-014), runtime state under `~/.nextcraft/` (D-039).
|
||||
|
||||
**Verification hooks (every phase, release-blocking):** `pnpm ai:test` (488 backend tests), web `tsx --test` (22), CLI `node:test` (61), `pnpm typecheck` (8 projects), `pnpm ai:lint` (ruff check), `pnpm build` (static export emits). **Hermeticity rule (v0.5 P04 lesson): every new test pins `Settings(db_path=tmp_path/...)` — never `~/.nextcraft`, never the cloud.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Postings Backend + Aggregation Adapter (REQ-6-001)
|
||||
|
||||
**Goal:** The 6th D-027 store family, the PostingSource adapter contract with the seeded corpus, the `/v1/marketplace/*` + admin API, the real apply route — and the web surfaces reading all of it at runtime.
|
||||
|
||||
### Wave 1-1: backend core
|
||||
|
||||
- **Task 1-1-01** (data-engineer): `ai_service/marketplace/base.py` — pydantic contracts per D-048: `Employer` (id, source, external_id, name + raw ingested fields), `IngestPosting` (A-402 shape: source, external_id, employer + posting fields), `Posting` (full row shape: status `draft|published|closed`, tags JSON column, portable scalar columns, posted_at), `PostingTags` (skills ≤10, seniority, category). Module mirrors D-031 boundary: never imports `agents/` or `api/`.
|
||||
- **Task 1-1-02** (data-engineer): `ai_service/marketplace/store.py` — `MarketplaceStore` protocol + SQLite impl per D-049 **(amended by G-104: six tables)** (`posting`, `employer`, `embedding`, `application`, `employer_enrichment`, `learner_profile`) on the shared `Settings().db_path`; identity/store.py mechanics exactly: WAL/synchronous=NORMAL/busy_timeout/foreign_keys pragmas, `sa_type` portable columns, `_validate()` hooks, surfaced IntegrityError, tz normalization. Unique constraints: `(source, external_id)` on posting + `(posting_id, learner_id)` on application (idempotent upserts A-402/A-408); employer unique per `(source, external_id)`; `embedding` table created now (posting_id PK, `vector` BLOB, `model`, `dim`), its codec/methods land in P3 (the data↔ai seam, per territory table); `learner_profile` (learner_id PK, profile JSON, confirmed_at, model — G-104, consumed in P4).
|
||||
- **Task 1-1-03** (backend-engineer): `config.py` — `admin_token: str = ""` (`AI_ADMIN_TOKEN`), `public_base_url: str = ""` (`AI_PUBLIC_BASE_URL`, placeholder consumed by P5 sitemap); `.env.example` documents both (keys never in code/commits). `api/deps.py` — `get_marketplace_store` (app.state DI; routes NEVER import the store directly — territory rule). `main.py` lifespan — `app.state.marketplace_store`; **loud boot warning when `admin_token` is unset** (G-11; admin routes stay 403-fail-closed regardless).
|
||||
|
||||
### Wave 1-2: ingest + routes
|
||||
|
||||
- **Task 1-2-01** (data-engineer): `ai_service/marketplace/ingest.py` — `PostingSource` protocol `fetch() -> Iterable[IngestPosting]`; `seeded_corpus` adapter over `marketplace/seed_corpus.json` — **a Python fixture file ported ONCE from `packages/mock-data/jobs.ts` (20 jobs) + `employers.ts` (10 employers)**, shared IDs per D-021 convention, mock `matchScore` DROPPED (D-051 computes real ones); `run(source)` upserts idempotently on `(source, external_id)` (employer rows upserted the same way). **Ingest-status contract (G-102): seed-port postings enter as `published` with their original `postedAt`; admin-authored postings start `draft`** — otherwise the board/facets/P1's success criterion are empty after ingest. No scheduler/crawler — the 3M+ corpus lands later as a third adapter with zero store/route changes.
|
||||
- **Task 1-2-02** (backend-engineer): `ai_service/api/marketplace.py` — public router: `GET /v1/marketplace/postings` (list, limit/offset, published only), `GET /v1/marketplace/postings/{id}` (404 honest), `GET /v1/marketplace/employers/{id}` (404 honest; raw fields now, merged+provenance in P4). Mounted in main.py. **The G-18 stub route MOVES here from `api/identity.py` and is deleted there** (identity keeps only its gate exports, D-048).
|
||||
- **Task 1-2-03** (backend-engineer + security-auditor): `ai_service/api/marketplace_admin.py` — `require_admin` FastAPI dependency (X-Admin-Token or Bearer vs `Settings().admin_token` as **`SecretStr`, compared via `secrets.compare_digest` (G-108)**): **unset token → every admin route 403, never open (D-057 fail-closed)**; routes: `POST /admin/postings` (create draft), `POST /admin/postings/{id}/publish` + `/close` (lifecycle `draft→published→closed`, invalid transition → 409), `POST /admin/ingest` (runs the seeded source), `GET /admin/applications` (review list, filterable by posting). Admin routes do NOT pass learner gates (different principal class, D-057).
|
||||
- **Task 1-2-04** (backend-engineer): apply route per D-058 — `POST /v1/marketplace/apply`, body `{posting_id}`: **gate composition binding (D-043, unchanged order): G-5 allowlist → `require_verified_adult` (imported from identity) → rate caps**; then posting must exist and be `published` (404 / 409); insert `application` row (status `submitted`); duplicate `(posting_id, learner_id)` → idempotent 200-with-existing; honest confirmation `{application_id, posting_id, status: "submitted"}`. All `stub`/`mock` markers die.
|
||||
|
||||
### Wave 1-3: web client shells (D-055)
|
||||
|
||||
- **Task 1-3-01** (data-engineer): `packages/types/marketplace.ts` — marketplace wire shapes (`PostingSummary`, `PostingDetail`, `EmployerProfile` with `{value, source}` provenance fields ready for P4, `ApplicationConfirmation`, `PostingFacets`) — **dual-schema sync with `marketplace/base.py`**: field-for-field parity, same change in both places.
|
||||
- **Task 1-3-02** (frontend-engineer): `apps/web/lib/engine-client.ts` marketplace section — `listPostings(filters)` (**paged fetch-all: walks limit/offset pages until exhausted, documented ≤10⁴ bound; response carries `total_published`; "Showing X of Y" computed over the fetched set with an honest truncation notice — G-103**), `getPosting(id)`, `getEmployer(id)`, `applyPosting(posting_id)` typed against the new `packages/types` shapes (`searchPostings` lands P3); 403 discrimination (verify-CTA vs allowlist) already works and is reused.
|
||||
- **Task 1-3-03** (frontend-engineer): board — `(marketplace)/marketplace/page.tsx` keeps its server shell; the grid + `JobFilters` become fetch-backed (client shell over `listPostings`); honest empty state (zero postings → message, never fake cards); filter option lists stay UI constants this phase (P2 derives them from real tags).
|
||||
- **Task 1-3-04** (frontend-engineer + backend-engineer): detail + employer shells **(G-101 — do NOT remove `generateStaticParams`; Next 15 hard-throws E87 under `output:'export'` for any dynamic route without it, even returning `[]`)**: keep `generateStaticParams` on `jobs/[jobId]` + `employers/[employerId]` returning ONE placeholder param (`{jobId: '_'}`) so exactly one client-shell page exports per route; add FastAPI catch-all routes `GET /marketplace/jobs/{id}` + `GET /marketplace/employers/{id}` (trailing-slash variants included — `trailingSlash: true`) serving that exported shell file — the same routes-beat-the-mount mechanism P5 relies on for sitemap; runtime client fetch → render, `notFound()` on 404; apply button wired: 403 verify-CTA renders the enrollment prompt, 409 closed renders honestly, success shows the confirmation payload. `packages/mock-data` jobs.ts/employers.ts go dormant for the marketplace path (still imported by employer/admin mockups — NOT deleted). Rewrite `tests/identity/test_identity.py:444`'s honest-501 pin onto the marketplace router in this phase (G-110).
|
||||
|
||||
### Wave 1-4: tests
|
||||
|
||||
- **Task 1-4-01** (data-engineer): store contract tests (tmp_path db — hermeticity rule): five tables created on the shared file, unique constraints fire on duplicates, pragmas + `_validate` hooks pinned; ingest idempotency (double-run → identical rows, no dupes); **seed-port fidelity check** (20 postings + 10 employers, IDs match the TS fixtures, `matchScore` absent from every API payload).
|
||||
- **Task 1-4-02** (backend-engineer + security-auditor): route tests — admin token fail-closed matrix (unset → 403 + boot warning in caplog; wrong/absent header → 403; correct → 200); lifecycle 409 on invalid transitions; apply gate composition (allowlist 403 fires FIRST for non-pilot ids, verify-CTA 403 for unverified, 429 at cap, 404/409 states, duplicate → 200 idempotent); 404s on missing postings/employers.
|
||||
- **Task 1-4-03** (frontend-engineer): web tests (`tsx --test` pattern) — engine-client marketplace functions; shells render fetched data; `notFound()` on 404; empty-board state; apply-flow states.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: AI Tagging (REQ-6-002)
|
||||
|
||||
**Goal:** Posting text → skills/seniority/category via D-020 structured output (mock-first, cloud-free); tags persisted; facets drive the sidebar.
|
||||
|
||||
### Wave 2-1: tagging + facets
|
||||
|
||||
- **Task 2-1-01** (ai-engineer): `marketplace/tagging.py` — `PostingTags` schema (skills `list[str]` ≤10, seniority enum, category enum) + versioned prompt constant; `tag_posting(text)` composes the EXISTING `structured_completion(provider, messages, model, schema, schema_hint)` (D-052: no new agent — one-shot extraction like grading's rubric call). Mock-first for free: mock provider default keeps tests cloud-free.
|
||||
- **Task 2-1-02** (data-engineer): persistence hooks — ingest pipeline tags every posting after upsert; re-tag on posting update **overwrites** the tags column (derived data, single source of truth — D-052; unlike enrichment, no append-only).
|
||||
- **Task 2-1-03** (backend-engineer): `PUT /v1/marketplace/admin/postings/{id}` (edit title/description/skills → triggers re-tag) + facets in API responses: `GET /v1/marketplace/postings` carries a `facets` payload (skills/seniority/category value counts) computed from stored tags of published postings.
|
||||
- **Task 2-1-04** (frontend-engineer): `JobFilters` facet lists derive from the API facets payload (static option lists die); counts render honestly.
|
||||
|
||||
### Wave 2-2: tests
|
||||
|
||||
- **Task 2-2-01** (ai-engineer): tests — tagging via `MockLLMProvider` (cloud-free pin); D-020 schema-violation → single bounded retry pinned; skills ≤10 enforced on an 11-skill mock response; facet derivation test (API facets exactly match stored tags of published-only postings); re-tag overwrite pin (old tags gone after update).
|
||||
|
||||
**Verification hooks P2:** all suites green (488+ / web 22+ / CLI 61 / typecheck / ruff / export build). Tagging tests stay mock-provider-only.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Semantic Vector Search (REQ-6-003)
|
||||
|
||||
**Goal:** EmbeddingProvider protocol (mock-first, deterministic), SQLite-local vectors, hybrid facet+cosine ranking, computed match scores replacing the mock `matchScore`.
|
||||
|
||||
### Wave 3-1: provider + storage
|
||||
|
||||
- **Task 3-1-01** (ai-engineer): `marketplace/embedding/base.py` — `EmbeddingProvider` protocol `embed(texts: list[str]) -> list[EmbeddingVector]` (frozen pydantic: `values: list[float]`, `model`, `dim` — D-051, D-030 mirror); `embedding/mock.py` — deterministic 256-dim feature-hash of normalized tokens (pure stdlib; similarity deterministic).
|
||||
- **Task 3-1-02** (ai-engineer): `embedding/openai_compat.py` — `POST {AI_EMBED_BASE_URL}/embeddings` (`{"model", "input": [texts]}`) on the shared httpx pool (D-017); `_sanitize` key redaction mirroring `openai_audio.py`. **Probe-corrected (D-051): ollama-cloud does NOT expose /embeddings — `AI_EMBED_BASE_URL` must point at another OpenAI-compatible endpoint (e.g. local ollama `:11434/v1`); default stays mock.**
|
||||
- **Task 3-1-03** (backend-engineer): `config.py` `embed_provider`/`embed_base_url`/`embed_api_key` (SecretStr)/`embed_model` + `embedding/factory.py` wiring in main.py lifespan: `AI_EMBED_PROVIDER=mock|openai-embed`, **G-11 boot-safe — `openai-embed` selected but unconfigured → loud log naming the fix, fallback to mock, boot never crashes**; `app.state.embedding_provider` + `deps.get_embedding_provider`.
|
||||
- **Task 3-1-04** (data-engineer): store `put_vector`/`get_vector` — struct-packed little-endian float32 BLOB codec (`<{dim}f`), `model`+`dim` persisted per row (A-403/A-404: lazy re-embed on provider change works); roundtrip test (the data↔ai seam per territory table).
|
||||
|
||||
### Wave 3-2: pipeline + ranking
|
||||
|
||||
- **Task 3-2-01** (ai-engineer): embed-on-publish — the publish transition embeds posting text + tags and stores the vector; **lazy re-embed**: a row whose `model`/`dim` ≠ the provider's is re-embedded on next access; candidate-side competency-profile text is embedded **on demand, never stored**.
|
||||
- **Task 3-2-02** (ai-engineer): `marketplace/search.py` — pure-Python cosine (sum of products / norm product; ≤10⁴ postings, no numpy — A-403); **hybrid per A-405/D-059: facet hard-filters (SQL on portable columns) first, then cosine ranking over the filtered set; no facets → pure semantic ranking**; `match_score` = cosine(candidate, posting); skill-breakdown = posting-tag overlap with the query + per-skill vector contribution (deterministic feature-hash dot-product shares — testable).
|
||||
- **Task 3-2-03** (backend-engineer): `GET /v1/marketplace/search` (`q` + facet params → ranked results with `match_score` + `skill_breakdown`); DI-composed, no store import in routes.
|
||||
|
||||
### Wave 3-3: web + tests
|
||||
|
||||
- **Task 3-3-01** (frontend-engineer): `engine-client.searchPostings(q, filters)`; board search bar wired to the search API; **match scores appear only in search context (G-106): cards without an active query show no score (honest absence, never a fabricated number); per-learner profile-driven card scores are explicitly post-v0.6 — REQ-6-003's "scores on job cards" satisfied conditionally in search context, recorded**; skill-breakdown panel on detail consumes the computed payload.
|
||||
- **Task 3-3-02** (ai-engineer + security-auditor): tests — deterministic similarity pins (same text → identical vector; related texts > unrelated texts cosine); codec roundtrip; factory fallback + boot-safe; `_sanitize` key-redaction pin (no key in any error); cosine math corner cases (zero vector); hybrid ordering (facet filters before ranking; pure-rank with no facets); embed-on-publish + lazy re-embed on model change.
|
||||
|
||||
**Verification hooks P3:** all suites green; embedding tests never leave the process (mock provider + MockTransport for openai_compat byte-contract tests).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Enrichment + Resume Parsing (REQ-6-004, REQ-6-005)
|
||||
|
||||
**Goal:** Two independent workstreams, parallelizable (D-059). (A) employer enrichment with append-only provenance; (B) bounded resume upload → draft extraction → learner confirm gate.
|
||||
|
||||
### Workstream A: enrichment (REQ-6-004)
|
||||
|
||||
- **Task 4-A-01** (ai-engineer): `marketplace/enrichment.py` — `EmployerEnrichment` schema (description, social_links, logo_ref) + prompt; one-shot `enrich_employer()` via structured_completion (D-053, same D-020 layering as tagging; mock-first).
|
||||
- **Task 4-A-02** (data-engineer): store — `employer_enrichment` rows are **append-only** `(employer_id, field, value, source: llm|manual, model, created_at)`; `merge_employer(employer_id)` read precedence **manual > llm > ingested-raw** (A-409); manual override = new `manual` row, NEVER a mutation of `llm` rows.
|
||||
- **Task 4-A-03** (backend-engineer): **enrich-on-first-publish** — a publish whose employer has no prior published posting fires the one-shot extraction (re-publishing the same employer does not re-run); employer GET returns merged fields with per-field `{value, source}` provenance markers (A-304 honesty — LLM copy visibly marked); `POST /v1/marketplace/admin/enrichment` (manual-override upsert, D-057-guarded).
|
||||
- **Task 4-A-04** (frontend-engineer): employer profile shell consumes merged+marked fields; provenance badges visible per field.
|
||||
- **Task 4-A-05** (security-auditor + ai-engineer): tests — precedence pin (manual beats llm beats raw, each combination); append-only pin (no UPDATE/DELETE path exists on enrichment rows); marker honesty (mock-llm copy carries the llm marker end-to-end); trigger-once pin.
|
||||
|
||||
### Workstream B: resume parsing (REQ-6-005)
|
||||
|
||||
- **Task 4-B-01** (backend-engineer): pypdf pin — `pypdf>=6.19,<7` in `apps/ai-service/pyproject.toml` + idempotent install via the existing bootstrap venv channel (probe-verified 6.19.0, A-009; the ONLY new dep).
|
||||
- **Task 4-B-02** (ai-engineer): `marketplace/resume.py` — `ParsedProfile` schema mapped to the competency-profile shape (competencies, skills, experience bands) + prompt; parse via D-020 structured extraction; result carries `status: "draft"`.
|
||||
- **Task 4-B-03** (backend-engineer): `POST /v1/marketplace/resume` (multipart; **PDF/TXT/MD only, ≤2 MB, text-layer required** — empty pypdf extract → honest 422 "text layer required", NO OCR in v0.6; extracted text capped ~100K chars before the LLM call; raw bytes never stored, never logged — A-305, `_scrub_pii_validation` precedent) and `POST /v1/marketplace/resume/confirm` (learner-gated: G-5 → verified → caps; **persists the confirmed profile JSON to a 6th store table `learner_profile` (learner_id PK, profile JSON, confirmed_at, model — G-104; NOT a filesystem sidecar — the D-027 store family is the only persistence mechanism)**; the draft NEVER persists without confirm).
|
||||
- **Task 4-B-04** (frontend-engineer): learner dashboard profile section — upload → draft fields review → confirm; bounds errors render honestly (413 oversize / 422 format+text-layer); nothing saves before the confirm click.
|
||||
- **Task 4-B-05** (security-auditor + ai-engineer): tests — small **generated** PDF + TXT fixtures parse (cloud-free); bounds matrix (format rejection, >2 MB, empty-extract 422, char cap); draft-then-confirm flow; PII sentinel caplog test (resume bytes/sentinel strings in NO log record, NO stored raw form).
|
||||
|
||||
**Verification hooks P4:** all suites green; resume + enrichment tests stay mock-provider; resume fixtures generated in tmp (no binaries committed beyond minimal fixtures).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: SEO Programmatic Pages (REQ-6-006)
|
||||
|
||||
**Goal:** Metadata + JSON-LD + sitemap/robots on the static-export fabric, D-039 unchanged.
|
||||
|
||||
### Wave 5-1: web metadata
|
||||
|
||||
- **Task 5-1-01** (frontend-engineer): build-time metadata — `export const metadata` (title/description/OG) on the **static set: landing, catalog/stack detail, marketplace board, pricing** (the existing layout.tsx/enroll pattern; static-export-compatible, probe 7).
|
||||
- **Task 5-1-02** (frontend-engineer): server-rendered JSON-LD on the board — `ItemList` + `Organization` — rendered into the export.
|
||||
- **Task 5-1-03** (frontend-engineer): client-side JSON-LD on the **runtime set** — `JobPosting` on job detail, `Organization` on employer profile, injected after fetch (A-411 accepted limitation: JS-less crawlers see the shell — recorded).
|
||||
|
||||
### Wave 5-2: sitemap/robots + tests
|
||||
|
||||
- **Task 5-2-01** (backend-engineer + security-auditor): `GET /sitemap.xml` + `GET /robots.txt` as plain FastAPI routes on the api app — **probe 6 verified: API routes beat the `StaticFiles("/")` mount**, so they win over the export in single-port production; enumerate the static page list (code constant) + all `published` postings; URLs from the `AI_PUBLIC_BASE_URL` origin; robots points at `/sitemap.xml`. **Unset `AI_PUBLIC_BASE_URL` (G-105): loud G-11 boot warning; sitemap serves the empty static-page set (never malformed/relative URLs) and robots omits the Sitemap directive (absolute-URL spec requirement); fail honest at request time; boot never crashes.** **Do NOT use Next `app/sitemap.ts`** (build-time enumeration cannot see runtime postings under `output: 'export'`).
|
||||
- **Task 5-2-02** (frontend-engineer + backend-engineer): tests — sitemap contents reflect published postings (draft/closed excluded); robots points at the sitemap with the configured origin; metadata/OG present in the exported HTML (`out/` probe); mount-order test (routes beat the static mount); client JSON-LD injection web test.
|
||||
|
||||
**Verification hooks P5:** all suites green + export build emits with metadata; sitemap tests pin `db_path=tmp_path` + `public_base_url` explicitly (hermetic, no `~/.nextcraft`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Final Review + Ship (milestone release v0.5.6)
|
||||
|
||||
- **Task 6-1-01** (lead-developer → ci-review personas): multi-persona review of the full milestone branch diff (all phases — correctness, testing, security, performance, maintainability); **known follow-ups checked for scope creep: seed dormancy verified (mock-data untouched except marketplace path), employer dashboard stays mock (A-407), pricing stays mock (A-412) — zero payments/employer-auth/scheduler code**. P0s fixed in-phase.
|
||||
- **Task 6-1-02** (ci-audit): reconstruction test (git log ↔ .ciagent/ files), file/branch/commit discipline, tag hygiene; critical fixes in-phase.
|
||||
- **Task 6-1-03** (lead-developer → ci-ship): merge milestone → main; tag **v0.5.6** (milestone release); Gitea release with full summary + `nextcraft-linux-x64` + `.sha256` assets (D-036); delete milestone branches; mark REQ-6-001..006 complete; clear checkpoint.
|
||||
|
||||
---
|
||||
|
||||
## Must-Haves (per phase — concrete, falsifiable, test-linked)
|
||||
|
||||
### Phase 1
|
||||
|
||||
- **MH-1a**: Store contract tests (tmp_path db): five tables created on the shared `db_path`; `(source, external_id)` and `(posting_id, learner_id)` unique constraints fire on duplicates; WAL/pragmas/`_validate` hooks pinned.
|
||||
- **MH-1b**: Ingest idempotency — running the seeded source twice yields identical row counts (no dupes); upsert overwrites in place.
|
||||
- **MH-1c**: Admin fail-closed matrix — unset `AI_ADMIN_TOKEN` → every admin route 403 + boot warning in caplog; wrong/absent token → 403; correct token → 200. No admin route passes unguarded.
|
||||
- **MH-1d**: Apply gate composition **provable by test** — G-5 403 fires first, verify-CTA 403 second, 429 at cap; published-only (404 missing / 409 draft+closed); duplicate → 200-with-existing; confirmation payload exact; **G-18 stub markers gone** (grep + response-schema pin).
|
||||
- **MH-1e**: Web shells read real data — board/detail/employer consume `/v1/marketplace/*` (web tests pin the engine-client functions); `notFound()` on 404; honest empty board; **E87 regression class pinned (G-101/G-110): placeholder shells emitted for both routes (build does not throw), catch-all serves arbitrary ids, hydration + `notFound()` works at a non-placeholder id via full page load**; the honest-501 stub pin in tests/identity/test_identity.py:444 rewritten onto the marketplace router.
|
||||
- **MH-1f**: Seed fidelity — 20 postings + 10 employers, IDs match the TS fixtures, `matchScore` absent from all marketplace-path payloads; **seed postings enter `published` with original `postedAt` (G-102)**.
|
||||
|
||||
### Phase 2
|
||||
|
||||
- **MH-2a**: Tagging cloud-free via `MockLLMProvider`; `PostingTags` schema enforced (skills ≤10 pin on an 11-skill response); D-020 single bounded retry pinned on schema violation.
|
||||
- **MH-2b**: Tag-on-ingest + re-tag-on-update **overwrites** (old tags absent after update — single source of truth).
|
||||
- **MH-2c**: Facets derive from real stored tags — API facets payload matches published-only tags exactly; `JobFilters` option lists come from the payload (web test).
|
||||
|
||||
### Phase 3
|
||||
|
||||
- **MH-3a**: Codec roundtrip — struct-packed float32 BLOB via `put_vector`/`get_vector` with `model`+`dim` per row.
|
||||
- **MH-3b**: Deterministic similarity — same text → identical vector; related > unrelated cosine (pinned numbers).
|
||||
- **MH-3c**: Factory G-11 boot-safe — `openai-embed` unconfigured → boot survives with loud log + mock fallback; `_sanitize` redaction: no key string in any error (pinned).
|
||||
- **MH-3d**: Hybrid ordering — facets hard-filter before cosine ranking; no facets → pure semantic rank; `match_score` = computed cosine, **mock `matchScore` absent from marketplace-path API payloads and marketplace surfaces (G-107: the pin's grep excludes `packages/mock-data` and the employer/admin route groups, which stay mock)**; absent score = honest absence (no active query → no score, G-106).
|
||||
- **MH-3e**: Embed-on-publish + lazy re-embed on model change (pinned); candidate-side embeddings never stored.
|
||||
- **MH-3f**: Skill-breakdown = tag overlap + vector contribution, deterministic pin.
|
||||
|
||||
### Phase 4
|
||||
|
||||
- **MH-4a**: Enrichment append-only (no update/delete path) + precedence manual > llm > raw, every pairing pinned.
|
||||
- **MH-4b**: Provenance markers — every enriched field carries `{value, source}` in the API; mock-llm copy visibly marked end-to-end.
|
||||
- **MH-4c**: Enrich-on-first-publish fires exactly once per employer (re-publish no-op pin).
|
||||
- **MH-4d**: Resume bounds matrix — PDF/TXT/MD only (other → 422), >2 MB → 413, empty PDF extract → 422 "text layer required", ~100K char cap.
|
||||
- **MH-4e**: Draft-then-confirm — parse returns `status: "draft"`, nothing persisted; confirm persists; **PII sentinel caplog test green** (raw bytes never logged/stored).
|
||||
- **MH-4f**: pypdf pinned `>=6.19,<7` in pyproject + bootstrap installs it; generated PDF fixture parses.
|
||||
|
||||
### Phase 5
|
||||
|
||||
- **MH-5a**: Sitemap reflects published postings (draft/closed excluded) + static page list; origin from `AI_PUBLIC_BASE_URL`; **every sitemap job/employer URL must serve 200 on the single-port app via the G-101 catch-all shells (URL-resolvability pinned — G-109, never advertise URLs the deploy 404s)**; unset origin → empty set + warning, robots omits the Sitemap directive (G-105).
|
||||
- **MH-5b**: robots.txt points at `/sitemap.xml`.
|
||||
- **MH-5c**: Metadata + OG present in the exported HTML (`out/` probe); board ItemList+Organization JSON-LD server-rendered.
|
||||
- **MH-5d**: Runtime JSON-LD injected client-side after fetch (web test); JS-crawler limitation recorded.
|
||||
- **MH-5e**: Mount-order — sitemap/robots routes beat the static export in single-port mode (route test).
|
||||
|
||||
## Milestone Must-Haves
|
||||
|
||||
- **MH-M1**: All six requirements complete with their REQUIREMENTS.md success criteria: REQ-6-001 (store+lifecycle+routes+adapter, surfaces read real data, D-043 gates composed), REQ-6-002 (tags via D-020, facets drive filters), REQ-6-003 (protocol+SQLite vectors, similarity ranking, computed match scores), REQ-6-004 (enrichment with provenance), REQ-6-005 (resume auto-fill with review gate, bounded), REQ-6-006 (metadata+JSON-LD+sitemap/robots, D-039 unchanged).
|
||||
- **MH-M2**: Zero regressions on existing suites at every phase ship: 488 backend pytest, web tsx --test 22, CLI 61, typecheck 8, ruff, export build.
|
||||
- **MH-M3**: G-18 stub markers gone (MH-1d) — the one honest-501 marketplace route is now real.
|
||||
- **MH-M4**: The D-043 gate order provable by test (MH-1d) — allowlist → verified-18+ → caps, unchanged.
|
||||
- **MH-M5**: Hermeticity — every new test pins `db_path` (and any state dir) to `tmp_path`; no test touches `~/.nextcraft` or the cloud (v0.5 P04 lesson).
|
||||
- **MH-M6**: Scope discipline — pricing stays mock (A-412), employer dashboard stays mock (A-407), no payments/employer-auth/scheduler/crawler code; seed corpus is the only ingestion source.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Dual-schema drift (TS wire types ↔ pydantic contracts)** — mitigated by the dual-schema-sync constraint (data-engineer owns both sides of every change) + parity checks in review.
|
||||
- **Match-score removal breaking non-marketplace mockups** — jobs.ts/employers.ts stay intact for employer/admin mockups; only the marketplace path switches (A-406); pinned by build + web tests.
|
||||
- **`AI_EMBED_BASE_URL` misconfiguration** (ollama-cloud has no /embeddings — D-051 probe) — default mock; G-11 boot-safe fallback with loud log; endpoint is config, never code.
|
||||
- **Admin token operational risk** — unset token locks the founder out of admin routes (fail-closed is the intent); loud boot warning names the fix; documented in README quickstart.
|
||||
- **Brute-force cosine at scale** — pilot-only (≤10⁴ postings, A-403); documented; `VectorStore` protocol keeps the pgvector swap API-compatible.
|
||||
- **pypdf text-layer variance across real resumes** — honest 422 on empty extract (no OCR); TXT/MD path always works; generated-fixture tests pin both.
|
||||
- **Client-shipped runtime fetch + JS-crawlers see shells on runtime pages** — accepted limitation (A-411), recorded; static set carries the SEO weight.
|
||||
- **E87 static-export build break (grill-verified, G-101)** — Next 15 `output:'export'` hard-throws for dynamic routes lacking `generateStaticParams` (even `[]`); mitigation: one placeholder param per route + FastAPI catch-all shell serving (routes beat the mount, probe 6); pinned by release-blocking tests (G-110).
|
||||
|
||||
## User-Facing Surface
|
||||
|
||||
1. **Voice defense with a provider badge** (`/defend/[competencyId]`): the learner's spoken answers upload as audio and are transcribed server-side when `AI_VOICE_PROVIDER=openai-audio` is configured; examiner questions play as server TTS audio; the mic control shows an honest badge — `server voice`, `browser voice`, or `mock` — derived from the provider descriptor (`VoiceDescriptor.mode`), and degrades visibly (browser/mock fallback) with keys absent.
|
||||
2. **Identity enrollment flow** (new `/enroll` learner route + marketplace surfaces): submit verification → pending state → verified/rejected state; verified learners proceed to variants/sandboxes/defense; unverified learners hitting gated routes see a structured verify-CTA (403 payload rendered as an actionable prompt, not a dead error). Mock verdicts are labeled `mock` everywhere they surface (A-304 honesty).
|
||||
3. **Environment-typed build flows** (`/build/[competencyId]`): design competencies open a design environment (SVG/HTML/schematic artifact starter files, Run = validator harness), simulation competencies open a simulation environment (benchmark script + dataset starter files, Run = bounded harness execution); the Run/Test buttons use the variant's real `test_command` instead of hardcoded pytest; the surface, file tree, editor, read-only output panel (CUT-2), and telemetry pulse are unchanged across kinds.
|
||||
4. **Invisible durability**: mid-connection kill of a build session loses nothing on reconnect (seq-ack protocol) — no visible UI, proven by tests.
|
||||
1. **Marketplace job board** (`/marketplace`): reads real published postings from `/v1/marketplace/postings`; facet sidebar derives from real AI tags; match scores are computed, not mocked.
|
||||
2. **Job detail** (`/marketplace/jobs/[jobId]`): real posting fetch; full description, tags, employer info, computed skill-breakdown; honest 404 on missing postings; apply flow live with verify-CTA on 403 and 409 on closed.
|
||||
3. **Employer profile** (`/marketplace/employers/[employerId]`): merged raw+enriched fields with visible per-field provenance markers.
|
||||
4. **Learner profile auto-fill**: resume upload → draft review → confirm (nothing saved silently).
|
||||
5. **Founder/admin surface via curl + engineering docs**: `AI_ADMIN_TOKEN`-guarded posting authoring, lifecycle, ingest trigger, enrichment override, applications review — documented in a README marketplace quickstart addition (curl examples included).
|
||||
6. **SEO**: sitemap/robots served by the API; metadata + JSON-LD on the page set.
|
||||
|
||||
## Happy Path
|
||||
## Happy Path (end-to-end, written BEFORE execute)
|
||||
|
||||
**Defense with real voice:** learner opens `/defend/cmp-*` → DefenseSession starts → examiner question streams (SSE) → learner speaks → MediaRecorder captures webm → POST `/v1/defense/{id}/answer` (multipart audio) → server strips codec param (`webm;codecs=opus` → `webm`), enforces ≤10MB, calls `OpenAIAudioProvider.transcribe` → transcript turn stored (STT latency recorded) → learner clicks the speaker icon on an examiner turn → GET `/v1/defense/{id}/audio/{turn_id}` → server TTS bytes stream back with correct media_type → verdict + integrity signals render unchanged.
|
||||
|
||||
**Identity-gated build:** learner completes `/enroll` (submit → mock provider verdict `verified`, age band 18+) → opens a design competency → POST `/v1/variants` passes the identity gate (verified ≥16) → variant carries `environment: "design"` + `test_command` → sandbox created (allowlist ✓ → identity ✓ → rate cap ✓) → starter files written → Run executes the validator harness in the namespace sandbox → telemetry streams with seq-acks → grade digest renders.
|
||||
1. Founder sets `AI_ADMIN_TOKEN=ops-secret` in `.env`; boots the stack; log shows the marketplace store on the shared DB (no unset-token warning).
|
||||
2. Founder runs `POST /v1/marketplace/admin/ingest` (X-Admin-Token header) → seeded corpus populates: 20 postings + 10 employers, upsert-idempotent.
|
||||
3. Founder authors a posting via `POST /v1/marketplace/admin/postings` (draft) → `POST .../publish` → posting is published (auto-tagged, embedded).
|
||||
4. Learner opens `/marketplace` → board client shell fetches real postings → filters by a facet derived from real tags → opens `/marketplace/jobs/{id}` detail.
|
||||
5. An 18+ verified, allowlisted learner clicks Apply → passes G-5 → verified-18+ → rate caps → posting published → `application` row (status `submitted`) → honest confirmation; a second click returns the same application (idempotent).
|
||||
6. Founder reviews via `GET /v1/marketplace/admin/applications` (token-guarded) and contacts the employer out-of-band (no notification infra, A-408).
|
||||
7. `GET /sitemap.xml` lists the static pages + every published posting.
|
||||
|
||||
## UX Acceptance Criteria
|
||||
|
||||
1. The mic control badge always tells the truth about which voice path is live (server/browser/mock) — never claims server when mock is wired.
|
||||
2. Unverified/under-age callers on gated routes get a 403 with an actionable verify-CTA payload (rendered as a prompt with a link to enrollment) — never a bare JSON error in the UI.
|
||||
3. Mock identity verdicts are visibly labeled `mock` in every surface that shows verification state.
|
||||
4. Design/sim environments are indistinguishable from build environments in surface mechanics (file tree, editor, Run/Test, output panel) — only starter contents and the Run command differ; no route changes, no new navigation.
|
||||
5. Run/Test buttons reflect the variant's `test_command` (no hardcoded pytest on a design competency).
|
||||
6. No durable state is written inside the repo (state in `~/.nextcraft/`; tests in tmp dirs).
|
||||
7. All existing accessibility baselines hold (WCAG AA contrast on new badge/CTA states).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Seq-Lease + Replay Margin (REQ-5-007)
|
||||
|
||||
**Goal:** Close the one-line replay-margin/ACK gap (P07-documented): the capture agent requeues only `_last_sent` on detected disconnect while N frames may be in TCP flight — frames 1..N-1 are lost. Server seq-acks close it.
|
||||
|
||||
### Wave 1-1: ingest ack emission
|
||||
|
||||
- **Task 1-1-01** (sandbox-engineer): `telemetry/ingest.py` — emit `{"type":"seq_ack","seq":N}` after every successful `store.append` in `IngestSession._append` (N = durable `latest_seq` snapshot already computed; O(1), advisory — gap detection stays authoritative; G-3 flood semantics untouched). Unit test: appended seq N → ack frame N observed on the WS (TestClient portal pattern, `tests/api/test_telemetry_ingest.py`).
|
||||
- Files: `apps/ai-service/ai_service/telemetry/ingest.py`, `apps/ai-service/tests/telemetry/`
|
||||
- **MH-1a**: a test proves each successful append emits an ack carrying the post-append `latest_seq`.
|
||||
|
||||
### Wave 1-2: agent ack consumption + spool trim
|
||||
|
||||
- **Task 1-2-01** (sandbox-engineer): `scripts/sandbox-agent.py` — supervisor loop (currently discards all non-close frames) parses text frames; on `type == "seq_ack"` trims every spool/pending line with `seq <= ack` under `_emit_lock` via atomic `Spool.rewrite`; clears `_last_sent` if its seq ≤ ack; tolerates any frame interleaving (acks/gap_warning/rejected); keepalive pings (binary) unaffected. **Stdlib-only (AST-pinned).**
|
||||
- **Task 1-2-02** (sandbox-engineer): add an explicit spool bound (max lines, e.g. 4096 — documented; D-R07 correction: no cap existed) — oldest-beyond-bound dropped with a counter; **G-14: overflow is by-design gap creation — unit test proves dropped-counter > 0 → replayed trace exhibits gaps → grader/gap path marks it ungradable (never a silently-truncated-but-gradable trace); document the worst-case arithmetic (~64KB diff cap × 4096 lines ≈ 256MB, under but HALF the 512MB G-2 budget — the spool lives inside the swept workdir)**; document G-2+flood-cap as the outer bound.
|
||||
- Files: `apps/ai-service/scripts/sandbox-agent.py`, `apps/ai-service/tests/sandbox/test_sandbox_agent.py`
|
||||
- **MH-1b**: unit test — agent with a scripted WS that acks mid-drain trims its spool to `seq > ack` exactly (no over-trim, no under-trim), stays within the explicit bound, and overflow drops create honest gaps (ungradable, G-14).
|
||||
- **MH-1c**: `replay_margin()` behavior after acks: requeue window is bounded by unacked in-flight only (repeated reconnect/ack cycles never lose or duplicate a spooled line).
|
||||
|
||||
### Wave 1-3: mid-burst regression test (the real proof)
|
||||
|
||||
- **Task 1-3-01** (sandbox-engineer): extend `tests/telemetry/test_durability.py` with `test_midburst_disconnect_loses_nothing` — real uvicorn + KillableProxy; sever the connection **immediately after a rapid multi-frame send, WITHOUT waiting for server observation** (the exact scenario the P07 de-flake documented as uncovered); revive; assert every emitted seq stored exactly once, in order; assert spool trimmed to ≤ ack margin; keep `_await_events`/portal patterns (deterministic, no socket surgery).
|
||||
- Files: `apps/ai-service/tests/telemetry/test_durability.py`
|
||||
- **MH-1d**: the mid-burst test passes repeatedly (≥3 consecutive runs) with zero loss/dup outside the acked margin.
|
||||
|
||||
**Verification strategy P1:** `pnpm ai:test` (413+green), ruff, no web/TS changes, no settings changes. Existing durability + reconnect-flush suites stay green.
|
||||
|
||||
**Risks:** mid-burst determinism (mitigated: ack protocol is the fix; wait for server-side observation of the ack itself); `_emit_lock` reentrancy from supervisor thread (trim under the same lock as flush); frame-order tolerance.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Real Server Voice (REQ-5-001, REQ-5-002)
|
||||
|
||||
**Goal:** The `openai-audio` VoiceProvider — server STT/TTS for voice defense end-to-end when keys exist; mock/browser unchanged.
|
||||
|
||||
### Wave 2-1: settings + factory + provider
|
||||
|
||||
- **Task 2-1-01** (backend-engineer): `config.py` — add `voice_base_url: str = ""`, `voice_api_key: str = ""` (never logged), `voice_stt_model: str = "whisper-1"`, `voice_tts_model: str = "tts-1"`, `voice_tts_voice: str = "alloy"`, `voice_tts_format: Literal["mp3","wav","opus"] = "mp3"` (G-16: enum, not free string — it feeds a Content-Type), `voice_max_audio_mb: int = 10`; `.env.example` documents them (keys never in code/commits); unknown format value → fall back to default with a loud log (consistent with G-11).
|
||||
- **Task 2-1-02** (backend-engineer): `voice/factory.py` — `voice_provider_from_settings(settings, http_client)` (signature change); `openai-audio` branch constructs `OpenAIAudioProvider`; rejects selection when `voice_base_url`/`voice_api_key` empty with an actionable error for direct callers; **G-11: `main.py` lifespan catches the factory error, logs loudly (naming the missing vars), and falls back to the mock provider — a typo'd env in an unattended deploy must never crash the boot (D-039); descriptor then honestly reads `mock`**; lifespan passes `app.state.http_client` (state-injection preserved).
|
||||
- **Task 2-1-03** (voice-engineer): `voice/openai_audio.py` — `transcribe()`: multipart `POST {base}/audio/transcriptions` (`file` + `model`, response_format=json → `TranscriptSegment`); `synthesize()`: streaming `POST /audio/speech` (JSON body `model/input/voice/response_format`, raw byte chunks); `descriptor = VoiceDescriptor(mode="server", sr_available=True, tts_available=True, hint=...)`; errors sanitized with key redaction (mirror `llm/openai_compat.py:_sanitize`); reuses the shared httpx client (D-017; read=300s).
|
||||
- Files: `apps/ai-service/ai_service/config.py`, `voice/factory.py`, `voice/openai_audio.py`, `main.py`, `.env.example`
|
||||
- **MH-2a**: MockTransport byte-contract tests — STT: multipart fields + response parse → `TranscriptSegment`; TTS: JSON body + byte stream → concatenated chunks; failure pins 413/400/429/timeout → sanitized errors, NO key leak (pinned).
|
||||
- **MH-2b**: factory: `openai-audio` selected + configured → server-mode provider; selected + unconfigured → actionable rejection for direct callers AND app boot survives with mock fallback + loud log naming the fix (G-11); mock/browser unchanged; provider always carries a `descriptor` (a-15 — a missing descriptor would badge the server path as mock). Invert the v0.4 rejection test (`test_real_server_stt_tts_rejected_as_v04_seam`).
|
||||
|
||||
### Wave 2-2: defense route fixes + audio upload
|
||||
|
||||
- **Task 2-2-01** (voice-engineer): `api/defense.py` — fmt derivation strips codec params (`"webm;codecs=opus"` → `webm`; else real STT 400s); enforce `voice_max_audio_mb` BEFORE provider call (422 empty / 413 oversize; a-9: Content-Length fast path before buffering); TTS route media_type mapped from the `voice_tts_format` enum (G-16); descriptor now comes from the provider (mode=server flows to the client untouched).
|
||||
- **Task 2-2-02** (frontend-engineer): `engine-client.ts` — `answerDefense` audio variant (FormData: blob + filename + content-type); `defense-session.tsx` — POST the recorded blob instead of discarding it; **G-12: recording bound — auto-stop at a max duration (default 180s) with a visible timer, `recorder.start(timeslice)` for observable size (a-13); a 413 response renders as an honest "answer too long — re-record" prompt, never silent loss**; provider badge from descriptor (`server voice`/`browser voice`/`mock`) with visible degradation states.
|
||||
- Files: `apps/ai-service/ai_service/api/defense.py`, `apps/web/lib/engine-client.ts`, `apps/web/components/learner/defense-session.tsx`, `apps/web/tests/`
|
||||
- **MH-2c**: API test — webm;codecs=opus content-type reaches the provider as clean `webm`; oversize audio → 413 without provider call; empty → 422 (existing).
|
||||
- **MH-2d**: web tests — audio POST path builds correct FormData; badge reflects descriptor mode; auto-stop fires at the bound; 413 renders the re-record prompt (G-12).
|
||||
|
||||
**Verification strategy P2:** `pnpm ai:test`, ruff, `pnpm typecheck`, web tests, `pnpm build` (static export still emits). Manual cloud probe recipe documented in `.env.example` comments (never CI). Defense flow tests stay mock-only (cloud-free rule).
|
||||
|
||||
**Risks:** full audio bytes buffered in memory (bounded by the 10MB guard); TTS `input` ≤4096 chars (examiner questions are short — enforced with a guard + truncation error); factory signature change touches main.py lifespan (state-injection pattern preserved).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Identity + Age-Gating (REQ-5-003, REQ-5-004)
|
||||
|
||||
**Goal:** Identity verification backend behind a provider protocol (mock-first); backend-enforced age gates composed with G-5.
|
||||
|
||||
### Wave 3-1: identity module core
|
||||
|
||||
- **Task 3-1-01** (identity-engineer): `ai_service/identity/base.py` — `IdentityProvider` protocol: `submit(learner_id, submission) -> submission_id`, `poll(submission_id) -> verdict {status, age_band, provider, mock, refs}`; `mock.py` — deterministic mock (approve-on-policy: age band from a scripted DOB field, reject scripted-bad); verdicts carry `mock: true` marker (A-304).
|
||||
- **Task 3-1-02** (identity-engineer): `ai_service/identity/store.py` — 5th D-027 store, DefenseStore pattern (WAL, `foreign_keys=ON`, portable columns, `@validates`): `identity_record` table — `id` (submission id, minted once), `learner_id` (indexed), `status: pending|verified|rejected`, `provider`, `provider_verdict` (JSON, mock-marked), `age_band` (derived `16-17`|`18+`, NEVER raw DOB), `document_refs` (JSON refs — raw documents NEVER stored), `submitted_at`, `verified_at`; insert-only + latest-per-learner lookup.
|
||||
- **Task 3-1-03** (identity-engineer): `main.py` lifespan — `app.state.identity_store` + `app.state.identity_provider` (state-injection overrides preserved); `config.py` — `identity_provider: str = "mock"`, identity store rides the same db_path.
|
||||
- **MH-3a**: store tests — insert/poll/latest/verdict provenance; constraints fire on invalid bands; same SQLite file (additive table, D-027 family).
|
||||
|
||||
### Wave 3-2: API surface + gates
|
||||
|
||||
- **Task 3-2-01** (identity-engineer): `api/identity.py` — `/v1/identity/submit` (submission → pending; G-13: one active pending per learner — resubmit while pending → 409 echoing the pending state; per-learner submit rate cap → 429), `/v1/identity/status/{learner_id}` (latest record + mock marker), `/v1/identity/verify/{submission_id}` (poll provider → verified/rejected transition); router mounted in main.py.
|
||||
- **Task 3-2-02** (identity-engineer): gate dependencies — `require_verified_age(min_age)` FastAPI dependencies; **composition order binding (D-043)**: G-5 allowlist (403 pilot guard) → identity verdict (403 + verify-CTA payload `{reason, min_age, current_status, verify_cta}`) → rate caps (429). Apply: school 16+ on variant generation (`api/variants.py`), sandbox create (`api/sandboxes.py`), defense start (`api/defense.py`); marketplace 18+ via `require_verified_adult` on ONE minimal gated route (`POST /v1/marketplace/apply` — G-18: honest stub; passes the gate composition then returns 501 with `stub: true` + mock-verdict markers, never a fabricated "applied" outcome).
|
||||
- **Task 3-2-03** (identity-engineer, G-9): conftest `verified_pilot` fixture — seeds an identity record (mock provider, band 18+ or 16+) for test learner ids + allowlist coverage in test settings; MUST land in the same wave as the gates or the existing variant/sandbox/defense suites 403 en masse (those routes are ungated today).
|
||||
- **Task 3-2-04** (security-auditor, phase-specific): PII review — sentinel scrub test: submit identity with sentinel PII strings → assert they appear in NO log record (caplog) and NO stored raw form (store inspection); API responses expose verdict + mock marker only.
|
||||
- Files: `apps/ai-service/ai_service/identity/**`, `api/identity.py`, `api/variants.py`, `api/sandboxes.py`, `api/defense.py`, `main.py`, `config.py`, `tests/identity/`, `tests/api/`
|
||||
- **MH-3b**: gate tests — verified 18+ passes all school gates; 16-17 passes school gates but 403s the marketplace route (honest stub response beyond the gate, G-18); unverified → 403 with verify-CTA payload; under-16 → 403 everywhere gated; allowlist rejection (403) still fires FIRST for non-pilot learner ids; identity submit: pending-resubmit → 409, rate cap → 429 (G-13); **all pre-existing variant/sandbox/defense API suites remain green under the `verified_pilot` fixture (G-9)**.
|
||||
- **MH-3c**: caplog sentinel test green (PII never logged/stored).
|
||||
|
||||
### Wave 3-3: web enrollment flow
|
||||
|
||||
- **Task 3-3-01** (frontend-engineer): `/enroll` route — submit → pending → verified/rejected states (honest, mock-labeled); engine-client identity functions; **G-10: client 403 discrimination — `verify_cta` present in the 403 payload → new `VerifyRequiredError` `{reason, min_age, current_status, verify_cta}`; allowlist detail → existing `NotAllowlistedError` (today engine-client.ts collapses every 403 into NotAllowlistedError — a verify-CTA would render as an allowlist lie)**; gated-route 403 CTA rendered as actionable prompt (link to `/enroll`); dashboard learner age badge reflects verified state.
|
||||
- Files: `apps/web/app/(learner)/enroll/`, `apps/web/lib/engine-client.ts`, `apps/web/components/`, `packages/types/`
|
||||
- **MH-3d**: web tests — identity client functions; CTA payload shape; enrollment states render; **403 discrimination: verify-CTA → VerifyRequiredError, allowlist detail → NotAllowlistedError (G-10)**. `pnpm build` emits the new route.
|
||||
- **MH-3e** (CUT-3): identity flow test via `TestClient` against real `create_app` (routers mounted, gates composed, real stores, mock providers) — unverified learner → variants POST → 403 verify-CTA → submit + verify (mock) → variants POST 200. No uvicorn harness (identity is plain JSON; the real-server harness stays where transport matters — P1/P4).
|
||||
|
||||
**Verification strategy P3:** `pnpm ai:test`, ruff, typecheck, web tests, `pnpm build`. PII caplog test is release-blocking (security-auditor sign-off).
|
||||
|
||||
**Risks:** self-asserted `learner_id` trust level (documented as pilot-scale — same as G-5 today; real auth is post-v0.5); shared-SQLite additive table (safe); mock-verdict honesty must ride every response (pinned).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Design/Sim Environments (REQ-5-005, REQ-5-006)
|
||||
|
||||
**Goal:** Environment kinds at the template/variant layer; per-kind starter contents + exec policy; kind flows through telemetry; digest untouched.
|
||||
|
||||
### Wave 4-1: registry + wire types
|
||||
|
||||
- **Task 4-1-01** (sandbox-engineer): `variants/templates.py` — `TaskTemplate.environment: Literal["build","design","simulation"] = "build"` + per-kind `starter_files` + `harness_command`/`test_command` policy fields; **G-15: command fields validated at definition time (Python, where shlex exists) — must roundtrip `shlex.split` → whitespace-join → `shlex.split` identically (no quotes/globs/metachars; violation is a template-authoring bug caught in tests)**; add one design template (stack-designer c001/c002 — already sanctioned) + one simulation template (stack-science or stack-operator); `variants/generator.py` + `store.py` carry the field through `VariantRecord`.
|
||||
- **Task 4-1-02** (frontend-engineer): `api/variants.py` — `VariantResponse` gains `environment` + `test_command` (closes the dead-field gap); TS `packages/types/variants.ts` + engine-client types sync (dual-schema rule: both places, same change).
|
||||
- **MH-4a**: variant tests — design/sim templates generate kind-tagged variants with correct starter files + commands; command fields roundtrip the shlex validator (G-15); wire response carries both fields (a-11: required on the wire, TS required-field parity); TS types match Python field-for-field.
|
||||
|
||||
### Wave 4-2: exec command policy
|
||||
|
||||
- **Task 4-2-01** (sandbox-engineer + security-auditor): `api/sandboxes.py` exec route — per-kind command policy: **G-15: EXACT argv-token matching** against {template-declared harness/test argv[0]} ∪ a small generic file/nav set — never prefix/substring (trivially bypassed via flags/`-c` passthrough); `sh -c` passthrough DISALLOWED for design/sim kinds (the gaming vector: faking build-style test cycles into a kind-agnostic digest); violation → 422 naming the allowed set; policy table is code (reviewable, versioned); build-kind flows do not regress (existing tests green).
|
||||
- **MH-4b**: exec tests — design kind rejects pytest-style arbitrary commands not in policy (422); simulation kind accepts its declared harness; build kind flows unchanged.
|
||||
|
||||
### Wave 4-3: learner surface kind-awareness
|
||||
|
||||
- **Task 4-3-01** (frontend-engineer): `use-sandbox-session.ts` — `test()` uses `variant.test_command`; **G-15: TS splits on whitespace ONLY (no shlex in the browser — safe because templates validated quote-free at authoring, Task 4-1-01)**; `build-surface.tsx` — RunControls commands from the variant; starter-file materialization loop already kind-agnostic (verify against design/sim starter sets); honest busy/denied/error states carry over.
|
||||
- **MH-4c**: web tests — test command comes from the variant; Run button label/command per kind.
|
||||
|
||||
### Wave 4-4: telemetry + grading proof
|
||||
|
||||
- **Task 4-4-01** (sandbox-engineer): digest pin test — `compute_digest` over a synthetic design-kind trace (validator harness events) → same feature classes as build traces (kind-agnostic by construction — now pinned); telemetry capture agent unchanged (content-agnostic `_EVENT_KINDS` verified).
|
||||
- Files: `apps/ai-service/ai_service/variants/templates.py`, `generator.py`, `store.py`, `api/variants.py`, `api/sandboxes.py`, `grading/features.py` (tests only), `apps/web/hooks/use-sandbox-session.ts`, `apps/web/components/learner/build-surface.tsx`, `packages/types/variants.ts`
|
||||
- **MH-4d** (absorbs former MH-4e per G-17 — no hope-shaped must-haves): design-kind E2E in the real-server harness — design variant → sandbox → starter files → Run validator harness in-ns → telemetry flows → digest computes (mock LLM, real stores) with concrete assertions: stored seqs contiguous 0..N exactly once AND the agent's spool ends ≤ the ack margin (real agent; where a fake agent is used, cite the P1 suite as the ack/trim coverage instead of asserting).
|
||||
|
||||
**Verification strategy P4:** `pnpm ai:test`, ruff, typecheck, web tests, `pnpm build`. Grading digest diff vs build traces = zero behavioral drift (pinned).
|
||||
|
||||
**Risks:** starter-file materialization is client-driven (missing-file hazard — mitigate: starter sets are small + template-authored; document server-side materialization as a future seam); `test_command` is wire-visible (template-authored, not learner-authored — documented); dual TS/Python schema sync (rule enforced in review).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Final Review + Ship (milestone release v0.4.5)
|
||||
|
||||
- **Task 5-1-01** (lead-developer → ci-review personas): multi-persona review of all v0.5 changes (correctness, testing, security, performance, maintainability) — P0s fixed in-phase.
|
||||
- **Task 5-2-01** (ci-audit): reconstruction test (git log ↔ .ciagent/ files), file/branch/commit discipline, tag hygiene; critical fixes in-phase.
|
||||
- **Task 5-3-01** (lead-developer → ci-ship): merge phase/05 → milestone → main; tag **v0.4.5** (milestone release); Gitea release with full summary + `nextcraft-linux-x64` + `.sha256` assets; delete milestone branches; mark requirements complete; clear checkpoint.
|
||||
|
||||
---
|
||||
|
||||
## Must-Haves (Milestone)
|
||||
|
||||
- **MH-M1**: Mid-burst disconnect loses zero events outside the acked margin (P1 regression test, repeatable).
|
||||
- **MH-M2**: `openai-audio` provider passes byte-contract STT/TTS tests with key-redaction pins; defense flow works server-side end-to-end (manual probe documented); mock/browser paths unchanged.
|
||||
- **MH-M3**: Identity flow (submit → pending → verified) works under mock; gates enforce 16+/18+ in the binding composition order with verify-CTA payloads; PII never stored raw or logged (caplog sentinel green).
|
||||
- **MH-M4**: Design + simulation environment kinds provisionable with per-kind starter contents + exec policy; grading digest proven kind-agnostic.
|
||||
- **MH-M5**: All gates green at every phase ship: `pnpm ai:test`, ruff, `pnpm typecheck`, `pnpm build`, web + cli tests; binary assets on every release (D-036).
|
||||
- **MH-M6**: v0.1 surface regressions: zero **for verified allowlisted pilot learners** (existing learner/marketplace/employer/admin flows unchanged except the additive enroll route + badges/CTAs; gating unverified/under-age ids is REQ-5-004's purpose, not a regression).
|
||||
|
||||
**Out of scope (guarded):** real KYC vendor integration (protocol only), real auth sessions (self-asserted learner_id documented as pilot-scale), marketplace backend beyond the one gated stub route, new isolation tech (D-024 unchanged), server-side starter materialization (documented as future seam).
|
||||
1. Board shows real postings with an honest empty state (no fake cards when the store is empty).
|
||||
2. Missing posting id → honest 404 page (no fabricated job).
|
||||
3. Unverified learner clicking Apply → verify-CTA prompt linking to enrollment (never a dead JSON error or a fake "applied").
|
||||
4. Match scores are computed cosine values only; no mock `matchScore` anywhere; absent score = absent query context (honest).
|
||||
5. Tag facets derive from real stored tags (API payload → sidebar), never hardcoded lists.
|
||||
6. Per-field provenance markers visible on the employer profile (raw/llm/manual).
|
||||
7. Sitemap lists published postings; robots points at the sitemap; metadata present in the exported HTML.
|
||||
8. Resume auto-fill never persists without an explicit confirm; oversize/wrong-format/text-less PDFs get honest, actionable errors.
|
||||
9. All existing accessibility baselines hold (WCAG AA on new states).
|
||||
+38
-1
@@ -16,7 +16,26 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
|
||||
|
||||
---
|
||||
|
||||
## Milestone v0.5 — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease (COMPLETE, shipped as v0.4.5)
|
||||
## Milestone v0.6 — Marketplace Engine (ACTIVE)
|
||||
|
||||
**Scope (auto-decided at SPECIFY per full autonomy — founder scope question left unanswered 2026-09-21):**
|
||||
|
||||
The marketplace has no backend today: every job board, job detail, employer profile, search, and pricing surface reads `packages/mock-data` directly; the only live route is the G-18 `POST /v1/marketplace/apply` gated stub (18+ verified, honest 501). The guiding outcome — ≥1,000 learners placed in AI-orchestration roles via the Nextcraft marketplace — runs *through* this surface, so the marketplace engine is the highest-leverage next build.
|
||||
|
||||
**Scope decision (D-047):** Activate **REQ-F-011..016** as REQ-6-001..006 — the real marketplace backend behind the existing UI:
|
||||
|
||||
1. **Job aggregation pipeline** (REQ-6-001, from REQ-F-011): job ingestion from employer postings + a seeded/synthetic corpus (3M+ external jobs is post-MVP — the pipeline ingests at pilot scale with the same adapter contract); postings store (6th D-027 store family); posting lifecycle (draft → published → closed).
|
||||
2. **AI-powered tagging** (REQ-6-002, from REQ-F-012): skills/seniority/category extraction from posting text via the existing LLM structured-output layer (D-020), provider-agnostic, mock-first.
|
||||
3. **Semantic vector search** (REQ-6-003, from REQ-F-013): embedding-backed role matching — candidate-competency vs posting-requirement similarity; SQLite-local at pilot scale (no pgvector before Postgres migration), deterministic embedding provider protocol (mock-first, real provider env-configured).
|
||||
4. **Company enrichment** (REQ-6-004, from REQ-F-014): employer profile enrichment from posting/company data (descriptions, social links, logo refs) — LLM-assisted, human-in-the-loop founder review deferred to post-pilot.
|
||||
5. **Resume/profile parsing** (REQ-6-005, from REQ-F-015): learner profile auto-fill from an uploaded resume — LLM structured extraction mapping to the competency profile shape.
|
||||
6. **SEO-optimized programmatic pages** (REQ-6-006, from REQ-F-016): server-rendered job/stack/landing pages with metadata, sitemap, structured data — built on the existing Next.js static-export single-port deploy (D-039-compatible).
|
||||
|
||||
**Deferred beyond v0.6 (unchanged):** REQ-F-018 payments/subscriptions, REQ-F-019 human tutor marketplace, REQ-F-020 CIRR placement tracking.
|
||||
|
||||
**Success (milestone-level):** the marketplace surfaces read from a real backend (postings, search, matching, employer profiles) instead of mock data; job posting + tagging + search work end-to-end for a pilot employer; the deploy stays one-command on the v0.4.x/v0.5.x fabric.
|
||||
|
||||
## Prior Milestone: v0.5 — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease (COMPLETE, shipped as v0.4.5; hotfixes v0.4.6 deploy-static-404, v0.4.7 pre-public hardening)
|
||||
|
||||
**Scope (the four seams D-016 deferred out of v0.4, locked at v0.5 Phase 0 SPECIFY):**
|
||||
|
||||
@@ -59,6 +78,23 @@ All 8 v0.3 requirements (REQ-3-001..008) are complete and shipped as v0.2.8. See
|
||||
|
||||
All 28 v0.1 requirements (REQ-001..028) are complete and shipped as v0.1.0. See REQUIREMENTS.md traceability matrix.
|
||||
|
||||
## Clarified Assumptions (v0.6 CLARIFY stage, full autonomy — auto-resolved)
|
||||
|
||||
| # | Ambiguity | Resolution | Confidence |
|
||||
|---|-----------|------------|-------------|
|
||||
| A-401 | Who authors postings at pilot (no employer auth exists)? | **Founder-authored; employer self-serve deferred.** Postings enter via founder/admin API routes (`/v1/marketplace/admin/postings` create + lifecycle transitions draft→published→closed), guarded by an `AI_ADMIN_TOKEN` env-header check (keys env-only, D-014; operator route — D-043 learner gate composition untouched). The employer dashboard's posting-management mockup stays mock; real employer self-serve lands with the deferred B2B employer network milestone. | 0.85 |
|
||||
| A-402 | Ingestion adapter contract shape? | **`PostingSource` protocol in `ai_service/marketplace/`** (D-031 module, D-014 mock-first): `fetch() -> Iterable[IngestPosting]` — pydantic-typed with `source`, `external_id`, employer + posting fields. Two v0.6 implementations: `seeded_corpus` (in-repo JSON seed, ported once from packages/mock-data jobs.ts/employers.ts per D-021 convention) and `admin_api` (A-401 founder path). Upserts idempotent on `(source, external_id)`. No scheduler/crawler — the 3M+ external corpus drops in later as a new adapter with zero store/route changes. | 0.80 |
|
||||
| A-403 | Where do embeddings live at SQLite scale (pip-only venv, no pgvector)? | **`embedding` table inside the marketplace SQLite store (6th D-027 family)**: posting_id → BLOB of struct-packed float32 + model + dim columns; the candidate side embeds the learner's competency profile text on demand (not stored). **Brute-force cosine in pure Python** at query time (pilot scale ≤ ~10⁴ postings — no numpy, no FAISS/chromadb, no new deps). `VectorStore` protocol-wrapped so the Postgres migration swaps to pgvector with no API changes (D-027/D-019 pattern). | 0.85 |
|
||||
| A-404 | Embedding provider + dimensionality? | **`EmbeddingProvider` protocol (mirror of D-030 VoiceProvider)**: deterministic mock (256-dim feature-hash of posting/profile tokens — similarity is deterministic; tests never call the cloud) + real OpenAI-compatible `POST {AI_EMBED_BASE_URL}/embeddings` via `AI_EMBED_MODEL`/`AI_EMBED_API_KEY` (env-only; ollama-cloud exposes /embeddings). Dimension is **not hardcoded** — persisted per row with the model name; on provider/model change, postings re-embed lazily. **[SUPERSEDED by D-051 probe: ollama-cloud does NOT expose /embeddings (404 path-not-found) — `AI_EMBED_BASE_URL` must target another OpenAI-compatible endpoint (e.g. local ollama `:11434/v1`); protocol design unaffected, default stays mock]** | 0.75 |
|
||||
| A-405 | Does semantic search replace the filter sidebar? | **Hybrid — augments, not replaces.** REQ-6-002 tag facets (skills/seniority/category + location/remote/salary) remain hard filters driving the existing sidebar; the semantic query + similarity compose as a **ranking layer over the facet-filtered set** (no facets selected → pure semantic ranking). Match score on job cards = cosine(candidate-competency vector, posting vector), replacing the mock `matchScore`; skill-breakdown panel derives from tag overlap + per-skill vector contribution. ARCHITECTURE.md's old "filter UI replaced" future-note is superseded by this hybrid. | 0.80 |
|
||||
| A-406 | What happens to packages/mock-data jobs.ts/employers.ts (runtime data vs static export)? | **Marketplace web surfaces switch from build-time mock imports to runtime client fetch** (A-002 direct-fetch + engine-client pattern, proven by build/defend surfaces): board/detail/employer-profile become client shells fetching `/v1/marketplace/*`; `generateStaticParams` on jobs/[jobId] and employers/[employerId] is removed (one static shell per route, data at runtime). jobs.ts/employers.ts are ported **once** into the Python seed fixture (A-402) and go dormant for the marketplace path (mock-data remains for admin/employer-dashboard mockups + shared types). No build-time DB reads — the D-039 deploy export build stays repo-only. | 0.80 |
|
||||
| A-407 | Employer auth model at pilot? | **None — employers are data, not principals, in v0.6.** Employer records are ingested/enriched entities keyed by `employer_id` on postings; no employer login, sessions, or accounts. The employer dashboard surface stays the v0.1 mockup; enrichment is founder-triggered/admin-reviewed (A-409), never employer-edited. Real employer auth lands post-payments with the B2B employer network. | 0.85 |
|
||||
| A-408 | Apply-flow shape beyond the 501 stub? | **The existing `POST /v1/marketplace/apply` route becomes real, D-043 composition unchanged** (G-5 allowlist → verified-18+ verdict → rate caps): body `{posting_id}` → posting must be `published` → insert an `application` row (status `submitted`, identity ref, timestamp) in the marketplace store → honest confirmation payload. Duplicate apply (same learner + posting) is idempotent. **No notification infra**: founder reviews applications via `/v1/marketplace/admin/applications` and contacts employers out-of-band. | 0.75 |
|
||||
| A-409 | Enrichment provenance rules? | **Separate `employer_enrichment` table — raw fields never overwritten** (REQ-6-004): append-only rows `(employer_id, field, value, source: llm\|manual, model, created_at)`. Read precedence **manual > llm > ingested-raw**; API responses merge and carry per-field provenance markers (A-304 honesty pattern — LLM-authored copy visibly marked). Enrich-on-post: new employer's first published posting triggers LLM structured extraction (D-020, mock-first) of description/social links/logo refs. Founder override = upsert a `manual`-source row, never a mutation of `llm` rows. | 0.85 |
|
||||
| A-410 | Resume upload format bounds? | **PDF/TXT/MD only, ≤2 MB, text-layer required.** PDF text via `pypdf` (pure-Python, pip-reachable through the A-009 venv-pip channel — the only new dep); extracted text capped ~100K chars before the D-020 structured-extraction call mapping to the competency-profile shape. Image-only/scanned PDFs → honest 422 ("text layer required") — **no OCR in v0.6**. Parsed result returns as a DRAFT: nothing persists to the learner profile until explicit learner confirm (REQ-6-005 review gate); raw resume bytes never stored, never logged (A-305 pattern). | 0.80 |
|
||||
| A-411 | Which pages get programmatic SEO treatment under static export — and how? | **Build-time-static set: landing, catalog/stack detail, marketplace board, pricing** — full server-component metadata (title/desc/OG) + JSON-LD (Organization, stack/Course, ItemList) rendered at export (D-039 build unchanged). **Runtime-data set: job detail + employer profile** — client shells (A-406) inject `JobPosting`/`Organization` JSON-LD + title client-side after fetch (best-effort per-job SEO at pilot; recorded as an accepted limitation — JS-less crawlers see the shell). **Sitemap/robots: `/sitemap.xml` + `/robots.txt` served dynamically by ai-service** (API routes beat the static mount, D-039), enumerating static pages + published postings; robots points at `/sitemap.xml`. | 0.70 |
|
||||
| A-412 | Pricing page — real or mock? | **Stays mock — zero backend.** Payments/subscriptions are REQ-F-018, explicitly deferred beyond v0.6 (D-047). The pricing page gains only build-time metadata/OG (A-411 static set); no pricing API routes, no package enforcement — posting is free at pilot (founder-mediated). | 0.95 |
|
||||
|
||||
## Clarified Assumptions (v0.5 CLARIFY stage, full autonomy — auto-resolved)
|
||||
|
||||
| # | Ambiguity | Resolution | Confidence |
|
||||
@@ -183,6 +219,7 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
|
||||
| D-014 | Provider-agnostic LLM layer; ollama-cloud as initial provider | OpenAI-compatible client abstraction with pluggable providers: ollama-cloud (https://ollama.com/v1, default), local OpenAI-compatible endpoint, deterministic mock (tests/CI). Keys in gitignored .ciagent/.env.secrets, never in code or commits. | apps/ai-service llm package with 3 providers; default=ollama-cloud |
|
||||
| D-015 | All six agents implemented as real LLM services; engines mocked | Coach/Tutor/Mentor fully real. Lab/Assessor/Proctor are real LLM logic over mock inputs (simulated telemetry, pre-baked artifacts) since sandbox fabric, assessment engine, and identity verification are v0.3+. Consistent with v0.1's mock-data approach. | REQ-F-001..006 complete in v0.2; real engines deferred to v0.3+ |
|
||||
| D-016 | v0.4 = Distribution & Bootstrap CLI (founder directive supersedes previously-named v0.4 seams) | Founder directive 2026-09-12: focus this milestone on streamlining install, a bootstrap CLI with a one-liner install script, ongoing release binaries. Real server STT/TTS, KYC, design/simulation envs, seq-lease move to v0.5. | Milestone scope locked at SPECIFY; binary = CLI-only, linux x64 |
|
||||
| D-047 | v0.6 = Marketplace Engine (auto-decided at SPECIFY, full autonomy; founder scope question left unanswered 2026-09-21) | Activates REQ-F-011..016 as REQ-6-001..006: real marketplace backend (aggregation, tagging, vector search, enrichment, resume parsing, SEO pages). Rationale: the guiding outcome (≥1,000 placements via the marketplace) runs through the only surface with zero backend; every prior milestone fed the credential side, placement side is the bottleneck. Confidence 0.80 ≥ threshold 0.60 (D-007). External 3M+ job corpus scaled to pilot (adapter contract preserves the full-scale path). | Milestone scope locked at SPECIFY; tags on v0.5.x line; feature milestone |
|
||||
| D-039 | Single-port deploy + unattended dev (v0.3.6 hotfix, founder directive) | Only :8420 reachable behind HAProxy; site was down (domain root hit the API 404). Web app = static export served same-origin by the ai-service (`AI_WEB_STATIC_DIR`); `dev -d`/`stop`/`log` daemonize ops; durable state (DB, sandboxes, pid/log) moves to `~/.nextcraft/` out of the repo. | One port serves UI+API; unattended deploy recipe in deploy/README.md |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Nextcraft — REQUIREMENTS.md
|
||||
|
||||
## v0.6 Requirements (ACTIVE — Marketplace Engine, milestone v0.6)
|
||||
|
||||
### Job Aggregation & Postings
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-6-001 | Job posting backend (from REQ-F-011, pilot-scaled): postings store (6th D-027 store family — SQLite, insert-safe, protocol-wrapped), employer posting lifecycle (draft → published → closed), ingestion adapter contract (employer-authored postings + seeded corpus at pilot scale; 3M+ external aggregation preserved by the adapter interface), marketplace API routes replacing the mock-data reads on job board/detail/employer surfaces, 18+ verified identity gate on apply/post flows (D-043 composition retained) | critical | 1 | pending |
|
||||
| REQ-6-002 | AI-powered tagging (from REQ-F-012): skills/seniority/category extraction from posting text via the LLM structured-output layer (D-020) — deterministic mock-first, provider-agnostic; tags persisted with the posting; tag facets drive the search filter sidebar | high | 2 | pending |
|
||||
|
||||
### Search & Matching
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-6-003 | Semantic vector search (from REQ-F-013): embedding provider protocol (mock-first, deterministic; real provider env-configured per D-014), vector store at pilot scale (SQLite-local — no pgvector before the Postgres migration), candidate-competency ↔ posting-requirement similarity ranking, match-score surfacing on job cards + candidate matches (replaces mock match scores) | critical | 3 | pending |
|
||||
|
||||
### Enrichment & Parsing
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-6-004 | Company enrichment (from REQ-F-014): employer profile enrichment from posting/company data (descriptions, social links, logo refs) via LLM structured output; enrich-on-post with manual founder override path; enriched data stored separately from raw (provenance marked) | medium | 4 | pending |
|
||||
| REQ-6-005 | Resume/profile parsing (from REQ-F-015): learner profile auto-fill from an uploaded resume — LLM structured extraction mapping to the competency-profile shape, learner-review-before-save (never silently overwrite), upload bounded (size/format guard, house pattern) | medium | 4 | pending |
|
||||
|
||||
### SEO & Programmatic Pages
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-6-006 | SEO-optimized programmatic pages (from REQ-F-016): server-rendered job/stack/landing pages with metadata (title/description/OG), sitemap.xml + robots.txt, JSON-LD structured data (JobPosting schema), static-export compatible (D-039 single-port deploy unchanged) | high | 5 | pending |
|
||||
|
||||
## v0.5 Requirements (Complete — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease; shipped as v0.4.5)
|
||||
|
||||
### Real Server Voice
|
||||
@@ -182,12 +210,12 @@
|
||||
|
||||
| ID | Description | Priority | Milestone | Status |
|
||||
|----|-------------|----------|-----------|--------|
|
||||
| REQ-F-011 | AI job aggregation pipeline (3M+ jobs from 120K companies) | high | v0.4+ | deferred |
|
||||
| REQ-F-012 | AI-powered tagging (skills, categories, seniority) | high | v0.4+ | deferred |
|
||||
| REQ-F-013 | Semantic vector search for role matching | high | v0.4+ | deferred |
|
||||
| REQ-F-014 | AI company enrichment (logos, descriptions, social links) | medium | v0.4+ | deferred |
|
||||
| REQ-F-015 | AI resume parsing (profile auto-fill) | medium | v0.4+ | deferred |
|
||||
| REQ-F-016 | SEO-optimized programmatic pages | medium | v0.4+ | deferred |
|
||||
| REQ-F-011 | AI job aggregation pipeline (3M+ jobs from 120K companies) | high | v0.6 | activated → REQ-6-001 (pilot-scaled; adapter contract preserves full scale) |
|
||||
| REQ-F-012 | AI-powered tagging (skills, categories, seniority) | high | v0.6 | activated → REQ-6-002 |
|
||||
| REQ-F-013 | Semantic vector search for role matching | high | v0.6 | activated → REQ-6-003 |
|
||||
| REQ-F-014 | AI company enrichment (logos, descriptions, social links) | medium | v0.6 | activated → REQ-6-004 |
|
||||
| REQ-F-015 | AI resume parsing (profile auto-fill) | medium | v0.6 | activated → REQ-6-005 |
|
||||
| REQ-F-016 | SEO-optimized programmatic pages | medium | v0.6 | activated → REQ-6-006 |
|
||||
|
||||
### Platform Infrastructure
|
||||
|
||||
@@ -216,6 +244,17 @@
|
||||
|
||||
## Traceability Matrix
|
||||
|
||||
### v0.6 (active)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| REQ-6-001 | 1 | pending |
|
||||
| REQ-6-002 | 2 | pending |
|
||||
| REQ-6-003 | 3 | pending |
|
||||
| REQ-6-004 | 4 | pending |
|
||||
| REQ-6-005 | 4 | pending |
|
||||
| REQ-6-006 | 5 | pending |
|
||||
|
||||
### v0.5 (complete)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## Overview
|
||||
|
||||
**Milestone v0.6 — Marketplace Engine (ACTIVE, started 2026-09-21).** The real marketplace backend behind the existing surfaces: job posting backend + aggregation adapter (REQ-6-001), AI tagging (REQ-6-002), semantic vector search + match scoring (REQ-6-003), company enrichment + resume parsing (REQ-6-004/005), SEO programmatic pages (REQ-6-006). Scope decision D-047 (auto-decided at SPECIFY, full autonomy). Tags on the v0.5.x line (v0.5.0 phase 0 → v0.5.N final = milestone release); branch `milestone/v0.6-marketplace-engine`.
|
||||
|
||||
**Hotfix v0.4.7 (2026-09-21) — pre-public hardening.** The 4 P1 flags from the v0.5 final review closed: HMAC ingest tokens on the WS grading input (LAN poisoning closed), nonce+counter defense-id mint (same-ms collision closed), browser-mode client SR/TTS per the descriptor contract (gap closed), 8 browser-voice module pins. Deployed via `nextcraft deploy --service`; site 200, service enabled.
|
||||
|
||||
**Hotfix v0.4.6 (2026-09-21) — site-down + deploy experience.** The domain root (nextcraft.coreci.dev) served the API's JSON 404 for three releases: the deployed service ran mount-less (web export never built on the host; dev -d auto-wiring silently skipped; nothing detected it). Fixed the class: `nextcraft deploy` (one-command clone/update → install → venv → export build → restart → verify), `deploy --service` (generated systemd unit, reboot-persistent), toolchain discovery (nvm/PNPM_HOME/corepack, incl. split installs — the host's exact layout), loud mount-less boot warning, verify live mount probe. Deployed live on the host via the new command; site 200, service enabled.
|
||||
|
||||
**Hotfix v0.3.5/v0.3.6 (2026-09-13) — fresh-box + single-port unattended deploy.**
|
||||
@@ -24,6 +28,22 @@
|
||||
|
||||
## Phase List
|
||||
|
||||
### v0.6 (active)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 0 | Pre-execution | complete | — | — | Specification, clarify, research, plan, grill, MVP/UX check complete; .ciagent/ files updated for v0.6 (shipped as v0.5.0) |
|
||||
| 1 | Postings backend + aggregation adapter | complete (v0.5.1) | 0 | REQ-6-001 | Postings store + lifecycle + `/v1/marketplace/*` routes live; job board/detail/employer surfaces read real data; 18+ gates composed per D-043; seeded ingestion adapter proven |
|
||||
| 2 | AI tagging | complete (v0.5.2) | 1 | REQ-6-002 | Posting text → skills/seniority/category tags via LLM structured output (mock-first); tag facets drive search filters; tags persisted |
|
||||
| 3 | Semantic vector search | complete (v0.5.3) | 2 | REQ-6-003 | Embedding provider protocol (mock-first) + SQLite-local vectors; competency↔posting similarity ranking; match scores surface on job cards + candidate matches |
|
||||
| 4 | Enrichment + resume parsing | in-progress | 3 | REQ-6-004, REQ-6-005 | Employer enrichment from posting data with provenance; resume upload → profile auto-fill with learner review gate; bounded uploads |
|
||||
| 5 | SEO programmatic pages | pending | 4 | REQ-6-006 | Metadata + JSON-LD + sitemap/robots on job/stack/landing pages; static-export compatible (single-port deploy unchanged) |
|
||||
| 6 | Final review + ship | pending | 1-5 | — | Review clean; audit passes; milestone tagged (final v0.5.x patch); release with binary assets on Gitea; milestone merged to main |
|
||||
|
||||
---
|
||||
|
||||
## Phase List (v0.5 — complete)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 0 | Pre-execution | complete | — | — | Specification, clarify, research, plan, grill complete; .ciagent/ files updated for v0.5 |
|
||||
@@ -106,6 +126,10 @@
|
||||
| v0.4.7 | 2026-09-21 | Pre-public hardening (the 4 P1 flags from the v0.5 final review): HMAC ingest tokens on the WS grading input (LAN trace-poisoning closed, per-boot secret, manager mints / endpoint verifies), nonce+counter defense-id mint (same-ms PK collision closed), browser-mode client SR/TTS implemented per the descriptor contract (the v0.5 gap), 8 browser-voice module pins. 488 backend (+10), web 22/22 (+8), CLI 61/61. Deployed via `nextcraft deploy --service`; site 200, service enabled. |
|
||||
| v0.4.6 | 2026-09-21 | Site-down hotfix + one-command deploy: `nextcraft deploy [--service]` (clone/update → install → venv → export build → restart → verify), systemd unit generation (reboot persistence), toolchain discovery (nvm/PNPM_HOME/corepack + split installs), loud mount-less boot, verify live mount probe, subcommand flag dispatch. Deployed live via the command itself; nextcraft.coreci.dev → 200 HTML. |
|
||||
|
||||
## v0.6 (Active — Marketplace Engine)
|
||||
|
||||
Real backend behind the marketplace surfaces: postings store + aggregation adapter + marketplace API (REQ-6-001), AI tagging (REQ-6-002), embedding-backed semantic search + match scores (REQ-6-003), company enrichment + resume parsing (REQ-6-004/005), SEO programmatic pages (REQ-6-006). 7 phases (P0–P6). Scope D-047 (auto-decided at SPECIFY). Tags v0.5.0+ on the v0.5.x line; final patch = milestone release.
|
||||
|
||||
## v0.5 (Complete — Shipped as v0.4.5)
|
||||
|
||||
Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease: real server STT/TTS (`openai-audio` VoiceProvider with boot-safe fallback), the identity module (5th D-027 store, provider protocol + mock, D-043 age-gate composition on variants/sandboxes/defense/marketplace), design/simulation environment kinds at the template layer with per-kind exec policy, and the seq-ack protocol closing the P07 replay-margin gap. 6 phases (P0–P5). All 7 requirements (REQ-5-001..007) complete. Tags v0.4.0–v0.4.4 per phase, milestone release v0.4.5.
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
"projects": [],
|
||||
"active_project": null,
|
||||
"milestone": {
|
||||
"version": "v0.5",
|
||||
"name": "real-voice-identity-envs",
|
||||
"version": "v0.6",
|
||||
"name": "marketplace-engine",
|
||||
"type": "feature",
|
||||
"branch": "milestone/v0.5-real-voice-identity-envs"
|
||||
"branch": "milestone/v0.6-marketplace-engine"
|
||||
}
|
||||
}
|
||||
@@ -74,3 +74,32 @@ AI_VOICE_PROVIDER=mock
|
||||
# -H 'Content-Type: application/json' \
|
||||
# -d '{"model":"tts-1","input":"Nextcraft","voice":"alloy"}' \
|
||||
# -o /tmp/probe.mp3 && file /tmp/probe.mp3 | grep -i audio
|
||||
|
||||
# --- Marketplace (REQ-6-001, D-057) ---
|
||||
# Founder/admin surface guard (/v1/marketplace/admin/*): send as the
|
||||
# 'X-Admin-Token' header OR 'Authorization: Bearer <token>'; compared
|
||||
# constant-time (G-108). UNSET = every admin route 403s (fail-closed,
|
||||
# never open) and the boot log warns loudly. Never commit real tokens.
|
||||
# AI_ADMIN_TOKEN=
|
||||
# P5 (D-056) placeholder: the deployed origin for sitemap.xml / robots.txt
|
||||
# absolute URLs (e.g. https://nextcraft.example). Unset → SEO phase
|
||||
# serves the empty static-page set with a boot warning (G-105).
|
||||
# AI_PUBLIC_BASE_URL=
|
||||
|
||||
# --- Semantic search embeddings (REQ-6-003, D-051) ---
|
||||
# 'mock' (default — deterministic 256-dim feature-hash, pure stdlib, no
|
||||
# key needed; tests never call a real embeddings API) or 'openai-embed'.
|
||||
AI_EMBED_PROVIDER=mock
|
||||
# openai-embed requires ALL THREE below; unconfigured → the app boots and
|
||||
# embeddings fall back to mock with a loud log (G-11).
|
||||
# PROBE CORRECTION (D-051): ollama-cloud does NOT expose /embeddings —
|
||||
# point AI_EMBED_BASE_URL at any OTHER OpenAI-compatible endpoint,
|
||||
# e.g. a LOCAL ollama at http://localhost:11434/v1, or any embeddings cloud.
|
||||
# AI_EMBED_BASE_URL=http://localhost:11434/v1
|
||||
# AI_EMBED_API_KEY=
|
||||
# AI_EMBED_MODEL=nomic-embed-text
|
||||
# Manual probe recipe (executable by anyone with the keys; never CI):
|
||||
# curl -sS $AI_EMBED_BASE_URL/embeddings \
|
||||
# -H "Authorization: Bearer $AI_EMBED_API_KEY" \
|
||||
# -H 'Content-Type: application/json' \
|
||||
# -d '{"model":"'"$AI_EMBED_MODEL"'","input":["Nextcraft probe"]}' | jq -e '.data[0].embedding | length'
|
||||
|
||||
@@ -9,6 +9,8 @@ from ..config import Settings
|
||||
from ..grading.engine import GradingEngine
|
||||
from ..grading.store import GradeStore
|
||||
from ..llm.base import LLMProvider
|
||||
from ..marketplace.embedding.base import EmbeddingProvider
|
||||
from ..marketplace.store import MarketplaceStore
|
||||
from ..sandbox.manager import SandboxManager
|
||||
from ..sandbox.workdir import SandboxDir
|
||||
from ..telemetry.ingest import TraceIntegrityMap
|
||||
@@ -77,3 +79,15 @@ def get_voice_provider(request: Request) -> VoiceProvider:
|
||||
|
||||
def get_examiner(request: Request) -> ExaminerAgent:
|
||||
return request.app.state.examiner_agent
|
||||
|
||||
|
||||
def get_marketplace_store(request: Request) -> MarketplaceStore:
|
||||
"""v0.6 marketplace (D-049): routes NEVER import the store directly —
|
||||
the api/ layer composes it via app.state DI (territory rule)."""
|
||||
return request.app.state.marketplace_store
|
||||
|
||||
|
||||
def get_embedding_provider(request: Request) -> EmbeddingProvider:
|
||||
"""v0.6 P3 (D-051): the embedding port via app.state DI — the
|
||||
search route + publish hooks compose it, never import it."""
|
||||
return request.app.state.embedding_provider
|
||||
|
||||
@@ -7,13 +7,16 @@ response so downstream surfaces never treat mock-verified as real):
|
||||
POST /v1/identity/verify/{sid} poll provider → terminal transition
|
||||
|
||||
Gate dependencies (D-043 binding composition order, mounted by the gated
|
||||
routes — variants/sandbox-create/defense-start for school 16+; one
|
||||
marketplace route for 18+ verified):
|
||||
routes — variants/sandbox-create/defense-start for school 16+; the
|
||||
marketplace 18+ apply route in api/marketplace.py):
|
||||
allowlist (403, G-5 pilot guard) → identity verdict (403 + verify-CTA)
|
||||
→ rate caps (429, owned by the calling routes)
|
||||
|
||||
PII (A-305): raw DOB enters via the submission, is used to derive the
|
||||
band, and is NEVER stored or logged (caplog sentinel test pins it).
|
||||
|
||||
v0.6 (D-048/D-058): the G-18 marketplace stub MOVED to api/marketplace.py
|
||||
and became the real apply route; identity keeps ONLY the gate exports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -257,38 +260,6 @@ async def require_verified_age(
|
||||
|
||||
#: Convenience alias for the marketplace 18+ composition (D-043's
|
||||
#: `require_verified_adult` — direct calls use require_verified_age(18, ...)).
|
||||
#: v0.6 (D-058): the marketplace surface itself moved to api/marketplace.py;
|
||||
#: identity keeps ONLY this gate export (D-048).
|
||||
require_verified_adult = require_verified_age
|
||||
|
||||
|
||||
# -- marketplace 18+ gated stub (G-18, REQ-5-004) ------------------------------------
|
||||
|
||||
marketplace_router = APIRouter(prefix="/v1/marketplace", tags=["marketplace"])
|
||||
|
||||
|
||||
class MarketplaceApplyBody(BaseModel):
|
||||
learner_id: str = Field(min_length=1)
|
||||
job_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
@marketplace_router.post("/apply", status_code=501)
|
||||
async def marketplace_apply_stub(
|
||||
body: MarketplaceApplyBody,
|
||||
request: Request,
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
"""The ONE gated marketplace route (D-043): proves the 18+ verified
|
||||
composition end-to-end. G-18 honesty: after passing the gate it returns
|
||||
501 with explicit stub + mock markers — the marketplace backend does
|
||||
not exist yet; this route never fabricates an 'applied' outcome.
|
||||
"""
|
||||
if body.learner_id not in settings.learner_allowlist:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"learner_id {body.learner_id!r} is not on the sandbox allowlist (G-5)",
|
||||
)
|
||||
await require_verified_age(18, body.learner_id, get_identity_store(request))
|
||||
return {
|
||||
"detail": "marketplace applications are not live yet",
|
||||
"stub": True,
|
||||
"mock": True,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
"""/v1/marketplace — public postings API (REQ-6-001, D-048, D-058).
|
||||
|
||||
The board's runtime data source (published-only, honest 404s, no
|
||||
matchScore anywhere — G-106/G-107) + the REAL apply route: the v0.5
|
||||
placeholder that lived in api/identity.py moved here and became this
|
||||
module's /apply (D-058) — gate composition UNCHANGED (D-043 binding
|
||||
order): G-5 allowlist (403) → require_verified_adult (403 + verify-CTA)
|
||||
→ rate caps (429), then posting exists (404) and is published (409),
|
||||
then an idempotent insert (A-408: duplicate apply → 200 with the
|
||||
EXISTING application).
|
||||
|
||||
Routes never import the store directly — deps.py app.state DI only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from ..config import Settings
|
||||
from ..identity.store import IdentityStore
|
||||
from ..marketplace.base import Employer, Posting
|
||||
from ..marketplace.embedding.base import EmbeddingProvider
|
||||
from ..marketplace.enrichment import merge_employer
|
||||
from ..marketplace.resume import (
|
||||
MAX_RESUME_BYTES,
|
||||
ParsedProfile,
|
||||
ResumeFormatError,
|
||||
extract_resume_text,
|
||||
parse_resume,
|
||||
)
|
||||
from ..marketplace.search import FacetFilters, rank_postings
|
||||
from .deps import get_embedding_provider, get_marketplace_store, get_provider, get_settings
|
||||
from .identity import require_verified_age
|
||||
|
||||
router = APIRouter(prefix="/v1/marketplace", tags=["marketplace"])
|
||||
|
||||
|
||||
def _identity_store(request: Request) -> IdentityStore:
|
||||
return request.app.state.identity_store
|
||||
|
||||
|
||||
def _enforce_allowlist(learner_id: str, settings: Settings) -> None:
|
||||
"""G-5 pilot guard — allowlist runs FIRST in the composition (D-043)."""
|
||||
if learner_id not in settings.learner_allowlist:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"learner_id {learner_id!r} is not on the sandbox allowlist (G-5)",
|
||||
)
|
||||
|
||||
|
||||
# In-memory apply-rate window (monotonic timestamps, process-local) — the
|
||||
# G-5 creates-per-min pattern from the sandboxes route (module state is
|
||||
# acceptable: one process, one box, D-019/D-027 precedent).
|
||||
_APPLY_TIMES: dict[str, deque[float]] = {}
|
||||
|
||||
#: Applies per rolling 60s window per learner (A-408 anti-spam; the
|
||||
#: idempotent duplicate makes honest retries free — only DISTINCT
|
||||
#: posting applies consume budget).
|
||||
_APPLY_PER_LEARNER_PER_MIN = 5
|
||||
|
||||
|
||||
def _check_apply_rate(learner_id: str) -> None:
|
||||
now = time.monotonic()
|
||||
window = _APPLY_TIMES.setdefault(learner_id, deque())
|
||||
while window and now - window[0] > 60.0:
|
||||
window.popleft()
|
||||
if len(window) >= _APPLY_PER_LEARNER_PER_MIN:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="marketplace apply rate exceeded — wait a minute",
|
||||
)
|
||||
window.append(now)
|
||||
|
||||
|
||||
# -- response contracts -------------------------------------------------------
|
||||
|
||||
|
||||
class PostingResponse(BaseModel):
|
||||
"""One posting over HTTP. G-107: `matchScore` is deliberately absent
|
||||
from the schema — scores are computed search-time context (P3),
|
||||
never list/detail payload fields."""
|
||||
|
||||
id: str
|
||||
source: str
|
||||
external_id: str
|
||||
employer_id: str
|
||||
title: str
|
||||
description: str
|
||||
required_competencies: list[str]
|
||||
skills: list[str]
|
||||
seniority: str | None
|
||||
category: str | None
|
||||
location: str
|
||||
remote: bool
|
||||
salary_min: int | None
|
||||
salary_max: int | None
|
||||
status: str
|
||||
tags: dict | None
|
||||
posted_at: str | None
|
||||
|
||||
|
||||
class PostingListResponse(BaseModel):
|
||||
"""G-103: the page + the honest total_published so the board's paged
|
||||
fetch-all can render "Showing X of Y" with an honest truncation. P2
|
||||
(MH-2c): facets — value counts over stored tags of published-only
|
||||
postings, data-driving the filter sidebar."""
|
||||
|
||||
postings: list[PostingResponse]
|
||||
total_published: int
|
||||
facets: dict
|
||||
|
||||
|
||||
class ProvenanceField(BaseModel):
|
||||
"""P4-A (D-053/A-304): one merged employer field with its honest
|
||||
provenance — raw | llm | manual (+ the model that wrote llm copy)."""
|
||||
|
||||
value: object
|
||||
source: str
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class EmployerResponse(BaseModel):
|
||||
"""Raw ingested fields (P1) + the P4-A `merged` section: per-field
|
||||
provenance-marked view (manual > llm > raw on read, D-053)."""
|
||||
|
||||
id: str
|
||||
source: str
|
||||
external_id: str
|
||||
name: str
|
||||
logo: str
|
||||
description: str
|
||||
industry: str
|
||||
size: str
|
||||
location: str
|
||||
website: str
|
||||
social_links: dict[str, str]
|
||||
culture: str
|
||||
merged: dict[str, ProvenanceField] | None = None
|
||||
|
||||
|
||||
class SkillBreakdownEntry(BaseModel):
|
||||
"""One skill's deterministic explanation (MH-3f): tag-overlap with
|
||||
the query (0/1) + the skill's vector contribution share."""
|
||||
|
||||
skill: str
|
||||
overlap: int
|
||||
contribution: float
|
||||
|
||||
|
||||
class SearchEntry(BaseModel):
|
||||
"""P3 search result (G-106): `match_score` exists ONLY in this
|
||||
search context — a no-query facet listing carries None (honest
|
||||
absence, never a fabricated number)."""
|
||||
|
||||
posting: PostingResponse
|
||||
match_score: float | None
|
||||
skill_breakdown: list[SkillBreakdownEntry]
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
results: list[SearchEntry]
|
||||
query: str
|
||||
ranked: int
|
||||
|
||||
|
||||
class ApplyBody(BaseModel):
|
||||
"""D-058: the learner identity comes from the VERIFIED record, not a
|
||||
client assertion — but the pilot surface passes learner_id in the
|
||||
body like every other gated route (G-5 composition unchanged)."""
|
||||
|
||||
learner_id: str = Field(min_length=1)
|
||||
posting_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ApplyResponse(BaseModel):
|
||||
"""Honest confirmation (D-058): exactly the application state, no
|
||||
stub/mock markers, no fabricated outcome."""
|
||||
|
||||
application_id: str
|
||||
posting_id: str
|
||||
status: str
|
||||
|
||||
|
||||
def _posting_response(posting: Posting) -> PostingResponse:
|
||||
return PostingResponse(
|
||||
id=posting.id,
|
||||
source=posting.source,
|
||||
external_id=posting.external_id,
|
||||
employer_id=posting.employer_id,
|
||||
title=posting.title,
|
||||
description=posting.description,
|
||||
required_competencies=posting.required_competencies,
|
||||
skills=posting.skills,
|
||||
seniority=posting.seniority,
|
||||
category=posting.category,
|
||||
location=posting.location,
|
||||
remote=posting.remote,
|
||||
salary_min=posting.salary_min,
|
||||
salary_max=posting.salary_max,
|
||||
status=posting.status,
|
||||
tags=dict(posting.tags) if posting.tags else None,
|
||||
posted_at=posting.posted_at.isoformat() if posting.posted_at else None,
|
||||
)
|
||||
|
||||
|
||||
def _employer_response(
|
||||
employer: Employer, merged: dict[str, ProvenanceField] | None = None
|
||||
) -> EmployerResponse:
|
||||
return EmployerResponse(
|
||||
id=employer.id,
|
||||
source=employer.source,
|
||||
external_id=employer.external_id,
|
||||
name=employer.name,
|
||||
logo=employer.logo,
|
||||
description=employer.description,
|
||||
industry=employer.industry,
|
||||
size=employer.size,
|
||||
location=employer.location,
|
||||
website=employer.website,
|
||||
social_links=dict(employer.social_links),
|
||||
culture=employer.culture,
|
||||
merged=merged,
|
||||
)
|
||||
|
||||
|
||||
# -- public endpoints ---------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/postings", response_model=PostingListResponse)
|
||||
async def list_postings(
|
||||
store=Depends(get_marketplace_store),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> PostingListResponse:
|
||||
"""Published postings only (draft/closed never board), posted_at DESC,
|
||||
with the honest total (G-103) + facets from published-only stored
|
||||
tags (MH-2c — facets computed once per page request, identical on
|
||||
every page of the fetch-all)."""
|
||||
postings, total = store.list_postings(limit=limit, offset=offset)
|
||||
return PostingListResponse(
|
||||
postings=[_posting_response(p) for p in postings],
|
||||
total_published=total,
|
||||
facets=store.facets(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/search", response_model=SearchResponse)
|
||||
async def search_postings(
|
||||
store=Depends(get_marketplace_store),
|
||||
embedder: EmbeddingProvider = Depends(get_embedding_provider),
|
||||
q: str = Query(default=""),
|
||||
skills: list[str] = Query(default=[]),
|
||||
seniority: str | None = Query(default=None),
|
||||
category: str | None = Query(default=None),
|
||||
location: str | None = Query(default=None),
|
||||
remote: bool | None = Query(default=None),
|
||||
salary_min: int | None = Query(default=None, ge=0),
|
||||
salary_max: int | None = Query(default=None, ge=0),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
) -> SearchResponse:
|
||||
"""P3 semantic search (REQ-6-003, D-051/A-405): facet hard-filters
|
||||
SQL-side FIRST, then cosine ranking DESC over the filtered set; no
|
||||
facets → pure semantic rank. `match_score` exists ONLY here (G-106)
|
||||
— an empty `q` with facets is a facet-only listing (posted_at DESC,
|
||||
match_score null — no query → no fabricated scores)."""
|
||||
filters = FacetFilters(
|
||||
skills=skills,
|
||||
seniority=seniority,
|
||||
category=category,
|
||||
location=location,
|
||||
remote=remote,
|
||||
salary_min=salary_min,
|
||||
salary_max=salary_max,
|
||||
)
|
||||
ranked = await rank_postings(store, embedder, q, filters, limit=limit)
|
||||
has_query = bool(q.strip())
|
||||
return SearchResponse(
|
||||
results=[
|
||||
SearchEntry(
|
||||
posting=_posting_response(r.posting),
|
||||
match_score=round(r.score, 6) if has_query else None,
|
||||
skill_breakdown=r.skill_breakdown if has_query else [],
|
||||
)
|
||||
for r in ranked
|
||||
],
|
||||
query=q,
|
||||
ranked=len(ranked),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/postings/{posting_id}", response_model=PostingResponse)
|
||||
async def get_posting(
|
||||
posting_id: str,
|
||||
store=Depends(get_marketplace_store),
|
||||
) -> PostingResponse:
|
||||
"""Honest 404 on any unknown id — never a fabricated job."""
|
||||
posting = store.get_posting(posting_id)
|
||||
if posting is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no posting with id {posting_id!r}"
|
||||
)
|
||||
return _posting_response(posting)
|
||||
|
||||
|
||||
@router.get("/employers/{employer_id}", response_model=EmployerResponse)
|
||||
async def get_employer(
|
||||
employer_id: str,
|
||||
store=Depends(get_marketplace_store),
|
||||
) -> EmployerResponse:
|
||||
"""Honest 404 on unknown employers. Raw fields stay as-is; the P4-A
|
||||
`merged` section carries the per-field provenance view (manual >
|
||||
llm > raw, D-053/A-304 — LLM copy is visibly marked, never
|
||||
displayed as ingested fact)."""
|
||||
employer = store.get_employer(employer_id)
|
||||
if employer is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no employer with id {employer_id!r}"
|
||||
)
|
||||
rows = store.list_enrichment(employer_id)
|
||||
merged_raw = merge_employer(employer, rows) if rows else None
|
||||
merged = (
|
||||
{
|
||||
field: ProvenanceField(
|
||||
value=entry["value"], source=entry["source"], model=entry["model"]
|
||||
)
|
||||
for field, entry in merged_raw.items()
|
||||
}
|
||||
if merged_raw
|
||||
else None
|
||||
)
|
||||
return _employer_response(employer, merged=merged)
|
||||
|
||||
|
||||
# -- the real apply route (D-058; the G-18 stub's replacement) ------------------
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ApplyResponse)
|
||||
async def apply_posting(
|
||||
body: ApplyBody,
|
||||
request: Request,
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> ApplyResponse:
|
||||
"""Apply to a posting. Gate composition (D-043, unchanged order):
|
||||
G-5 allowlist → require_verified_adult (18+ verified, verify-CTA
|
||||
403) → rate caps → posting exists (404) + published (409) →
|
||||
idempotent insert (A-408: a duplicate returns the EXISTING
|
||||
application with 200)."""
|
||||
_enforce_allowlist(body.learner_id, settings)
|
||||
await require_verified_age(18, body.learner_id, _identity_store(request))
|
||||
_check_apply_rate(body.learner_id)
|
||||
|
||||
posting = store.get_posting(body.posting_id)
|
||||
if posting is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no posting with id {body.posting_id!r}"
|
||||
)
|
||||
if posting.status != "published":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"reason": "posting_not_published",
|
||||
"status": posting.status,
|
||||
},
|
||||
)
|
||||
|
||||
application, _created = store.insert_application(
|
||||
posting_id=body.posting_id, learner_id=body.learner_id
|
||||
)
|
||||
return ApplyResponse(
|
||||
application_id=application.id,
|
||||
posting_id=application.posting_id,
|
||||
status=application.status,
|
||||
)
|
||||
|
||||
|
||||
# -- P4-B: resume upload + confirm (REQ-6-005, D-054, A-410) --------------------
|
||||
|
||||
|
||||
class ResumeDraftResponse(BaseModel):
|
||||
"""The parse result — a DRAFT. Nothing persists without the explicit
|
||||
confirm call (the REQ-6-005 review gate)."""
|
||||
|
||||
status: str = "draft"
|
||||
profile: dict
|
||||
|
||||
|
||||
class ResumeConfirmBody(BaseModel):
|
||||
"""The learner's explicit save decision: the draft profile rides the
|
||||
body VERBATIM from the parse and the server RE-VALIDATES the shape
|
||||
against ParsedProfile before persisting (verifier P1 — a bare dict
|
||||
would accept arbitrary/type-confused payloads)."""
|
||||
|
||||
learner_id: str = Field(min_length=1)
|
||||
profile: dict
|
||||
|
||||
|
||||
class ResumeConfirmResponse(BaseModel):
|
||||
status: str = "confirmed"
|
||||
learner_id: str
|
||||
confirmed_at: str
|
||||
|
||||
|
||||
async def _require_verified_marketplace_learner(
|
||||
learner_id: str,
|
||||
settings: Settings,
|
||||
request: Request,
|
||||
) -> None:
|
||||
"""Learner-gated resume surface (D-043 composition order): G-5
|
||||
allowlist → verified identity → rate caps — same order as apply."""
|
||||
_enforce_allowlist(learner_id, settings)
|
||||
await require_verified_age(18, learner_id, _identity_store(request))
|
||||
_check_apply_rate(learner_id)
|
||||
|
||||
|
||||
@router.post("/resume", response_model=ResumeDraftResponse)
|
||||
async def upload_resume(
|
||||
request: Request,
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
provider=Depends(get_provider),
|
||||
) -> ResumeDraftResponse:
|
||||
"""Parse an uploaded resume into a profile DRAFT (multipart: `file`
|
||||
+ `learner_id` form field). Bounds (A-410): PDF/TXT/MD only, ≤2 MB,
|
||||
text-layer required (empty PDF extract → honest 422; no OCR in
|
||||
v0.6). Raw bytes never stored, never logged (A-305). The response
|
||||
is a DRAFT — the confirm route is the only persistence path."""
|
||||
|
||||
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||
|
||||
learner_id = (await request.form()).get("learner_id")
|
||||
if not isinstance(learner_id, str) or not learner_id:
|
||||
raise HTTPException(status_code=422, detail="learner_id form field required")
|
||||
await _require_verified_marketplace_learner(learner_id, settings, request)
|
||||
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
# starlette's UploadFile base (fastapi's is a subclass — the raw
|
||||
# request.form() yields the base, so isinstance must use it).
|
||||
if not isinstance(upload, StarletteUploadFile):
|
||||
raise HTTPException(status_code=422, detail="file form field required")
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_RESUME_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"resume too large ({len(data)} bytes; max {MAX_RESUME_BYTES})",
|
||||
)
|
||||
if len(data) == 0:
|
||||
raise HTTPException(status_code=422, detail="empty file")
|
||||
content_type = upload.content_type or ""
|
||||
try:
|
||||
text = extract_resume_text(data, content_type)
|
||||
except ResumeFormatError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
try:
|
||||
parsed = await parse_resume(provider, model=settings.model, text=text)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="profile extraction failed — try again or edit manually",
|
||||
) from exc
|
||||
return ResumeDraftResponse(
|
||||
status="draft",
|
||||
profile={
|
||||
"name": parsed.name,
|
||||
"headline": parsed.headline,
|
||||
"skills": list(parsed.skills),
|
||||
"experience_bands": [
|
||||
b.model_dump() for b in parsed.experience_bands
|
||||
],
|
||||
"competencies": list(parsed.competencies),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/resume/confirm", response_model=ResumeConfirmResponse)
|
||||
async def confirm_resume(
|
||||
body: ResumeConfirmBody,
|
||||
request: Request,
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> ResumeConfirmResponse:
|
||||
"""The REQ-6-005 review gate: persist the learner-CONFIRMED profile
|
||||
to the learner_profile store table (G-104 — the D-027 family is the
|
||||
only persistence mechanism). G-5 → verified 18+ → rate caps, then
|
||||
a SHAPE RE-VALIDATION against ParsedProfile (verifier P1 — the
|
||||
confirm must never persist arbitrary/type-confused payloads the
|
||||
parse never produced), then an UPSERT (re-confirm replaces; the
|
||||
draft never persisted)."""
|
||||
await _require_verified_marketplace_learner(body.learner_id, settings, request)
|
||||
try:
|
||||
validated = ParsedProfile.model_validate(body.profile)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"reason": "invalid_profile_shape",
|
||||
"message": "the profile must match the parsed draft shape",
|
||||
"errors": [
|
||||
{"loc": list(e.get("loc", [])), "msg": e.get("msg", "")}
|
||||
for e in exc.errors()
|
||||
],
|
||||
},
|
||||
) from exc
|
||||
stored = store.save_learner_profile(
|
||||
body.learner_id, validated.model_dump(mode="json"), model=settings.model
|
||||
)
|
||||
return ResumeConfirmResponse(
|
||||
status="confirmed",
|
||||
learner_id=stored["learner_id"],
|
||||
confirmed_at=stored["confirmed_at"].isoformat()
|
||||
if stored["confirmed_at"]
|
||||
else "",
|
||||
)
|
||||
@@ -0,0 +1,412 @@
|
||||
"""/v1/marketplace/admin — founder/operator surface (REQ-6-001, D-057).
|
||||
|
||||
Guard: `require_admin` compares X-Admin-Token OR Authorization: Bearer
|
||||
against Settings().admin_token via secrets.compare_digest (G-108).
|
||||
UNSET token → every admin route 403s — fail-closed, NEVER open (a loud
|
||||
boot warning in main.py names the fix, G-11). Admin routes do NOT pass
|
||||
the learner gates (different principal class, D-057/D-043 untouched).
|
||||
|
||||
Routes: create draft posting, publish/close lifecycle (invalid
|
||||
transition → 409), seeded-corpus ingest trigger, applications review
|
||||
(filterable by posting). No cron/crawler — ingest is founder-invoked
|
||||
(A-401/A-402).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import Settings
|
||||
from ..llm.base import LLMProvider
|
||||
from ..marketplace.base import Posting
|
||||
from ..marketplace.embedding.base import EmbeddingProvider
|
||||
from ..marketplace.enrichment import ENRICHABLE_FIELDS, maybe_enrich_on_publish
|
||||
from ..marketplace.ingest import SeededCorpusSource, run_ingest
|
||||
from ..marketplace.search import embed_posting
|
||||
from ..marketplace.store import InvalidTransition, MarketplaceStore
|
||||
from ..marketplace.tagging import TaggingUnavailable, tag_posting
|
||||
from .deps import (
|
||||
get_embedding_provider,
|
||||
get_marketplace_store,
|
||||
get_provider,
|
||||
get_settings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/v1/marketplace/admin", tags=["marketplace-admin"])
|
||||
|
||||
|
||||
def _extract_admin_token(
|
||||
x_admin_token: Annotated[str | None, Header(include_in_schema=False)] = None,
|
||||
authorization: Annotated[str | None, Header(include_in_schema=False)] = None,
|
||||
) -> str | None:
|
||||
"""Bearer of the request: X-Admin-Token wins; else Authorization:
|
||||
Bearer <token>. None when neither header carries a candidate."""
|
||||
if x_admin_token:
|
||||
return x_admin_token
|
||||
if authorization:
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.strip().lower() == "bearer" and token.strip():
|
||||
return token.strip()
|
||||
return None
|
||||
|
||||
|
||||
def require_admin(
|
||||
request_token: Annotated[str | None, Depends(_extract_admin_token)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> Settings:
|
||||
"""D-057 fail-closed guard: constant-time compare (G-108) against the
|
||||
env-only AI_ADMIN_TOKEN (SecretStr — never repr'd/logged); UNSET → 403
|
||||
for EVERY request (never open). Returns the settings for the route
|
||||
handlers."""
|
||||
configured = settings.admin_token.get_secret_value()
|
||||
if not configured or request_token is None:
|
||||
# Fail-closed: no configured token, or no candidate at all — the
|
||||
# surface is locked, not open (D-057).
|
||||
raise HTTPException(status_code=403, detail="admin token required")
|
||||
if not secrets.compare_digest(request_token, configured):
|
||||
raise HTTPException(status_code=403, detail="invalid admin token")
|
||||
return settings
|
||||
|
||||
|
||||
# -- contracts ----------------------------------------------------------------
|
||||
|
||||
|
||||
class AdminPostingCreate(BaseModel):
|
||||
"""Admin-authored posting body. Starts as a DRAFT (G-102: only
|
||||
seed-port postings enter published); publish is an explicit call."""
|
||||
|
||||
employer_id: str = Field(min_length=1)
|
||||
title: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
required_competencies: list[str] = Field(default_factory=list)
|
||||
skills: list[str] = Field(default_factory=list)
|
||||
seniority: str | None = None
|
||||
category: str | None = None
|
||||
location: str = ""
|
||||
remote: bool = False
|
||||
salary_min: int | None = None
|
||||
salary_max: int | None = None
|
||||
|
||||
|
||||
class AdminPostingResponse(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
external_id: str
|
||||
employer_id: str
|
||||
title: str
|
||||
status: str
|
||||
|
||||
|
||||
class IngestResponse(BaseModel):
|
||||
postings_upserted: int
|
||||
employers_upserted: int
|
||||
|
||||
|
||||
class AdminPostingEdit(BaseModel):
|
||||
"""P2 (Task 2-1-03): partial content edit; fields omitted stay put.
|
||||
A successful edit RE-TAGS the posting (tags are derived data,
|
||||
D-052 — the tagger overwrites)."""
|
||||
|
||||
title: str | None = Field(default=None, min_length=1)
|
||||
description: str | None = None
|
||||
skills: list[str] | None = None
|
||||
|
||||
|
||||
class AdminEnrichmentOverride(BaseModel):
|
||||
"""P4-A (D-053): manual-override body — appends a manual-source row
|
||||
(never mutates llm rows). `value` for social_links is a JSON object
|
||||
string; the other fields are plain text."""
|
||||
|
||||
employer_id: str = Field(min_length=1)
|
||||
field: str = Field(min_length=1)
|
||||
value: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AdminApplication(BaseModel):
|
||||
id: str
|
||||
posting_id: str
|
||||
learner_id: str
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class AdminApplicationsResponse(BaseModel):
|
||||
applications: list[AdminApplication]
|
||||
|
||||
|
||||
# -- routes --------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/postings", status_code=201, response_model=AdminPostingResponse)
|
||||
async def create_posting(
|
||||
body: AdminPostingCreate,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> AdminPostingResponse:
|
||||
"""Create a DRAFT posting (founder authoring path, D-050 admin_api
|
||||
adapter — source=admin). Publish is a separate lifecycle call."""
|
||||
posting = Posting(
|
||||
id=f"adm-{uuid.uuid4().hex[:16]}",
|
||||
source="admin",
|
||||
external_id=f"adm-{uuid.uuid4().hex[:16]}",
|
||||
employer_id=body.employer_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
required_competencies=body.required_competencies,
|
||||
skills=body.skills,
|
||||
seniority=body.seniority, # type: ignore[arg-type]
|
||||
category=body.category,
|
||||
location=body.location,
|
||||
remote=body.remote,
|
||||
salary_min=body.salary_min,
|
||||
salary_max=body.salary_max,
|
||||
status="draft",
|
||||
)
|
||||
try:
|
||||
stored = store.insert_posting(posting)
|
||||
except Exception as exc: # surfaced IntegrityError from the store
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return AdminPostingResponse(
|
||||
id=stored.id,
|
||||
source=stored.source,
|
||||
external_id=stored.external_id,
|
||||
employer_id=stored.employer_id,
|
||||
title=stored.title,
|
||||
status=stored.status,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/postings/{posting_id}/publish", response_model=AdminPostingResponse
|
||||
)
|
||||
async def publish_posting(
|
||||
posting_id: str,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
embedder: EmbeddingProvider = Depends(get_embedding_provider),
|
||||
provider: LLMProvider = Depends(get_provider),
|
||||
) -> AdminPostingResponse:
|
||||
"""draft → published (the only valid move from draft; anything else
|
||||
409s). Stamps posted_at NOW when the row has none (G-102: seed-port
|
||||
postings keep their original postedAt).
|
||||
|
||||
P3 embed-on-publish (Task 3-2-01): the published posting embeds
|
||||
now; failure = honest warning — the posting still publishes, the
|
||||
vector stays absent (search skips it).
|
||||
|
||||
P4-A enrich-on-first-publish (D-053): the employer's FIRST publish
|
||||
fires the one-shot profile extraction (no existing llm rows → run;
|
||||
re-publishing the same employer does not re-run). Failure = honest
|
||||
warning — the publish stands, no rows written."""
|
||||
try:
|
||||
stored = store.set_posting_status(posting_id, "published")
|
||||
except InvalidTransition as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if stored is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no posting with id {posting_id!r}"
|
||||
)
|
||||
await embed_posting(store, embedder, stored)
|
||||
try:
|
||||
await maybe_enrich_on_publish(
|
||||
store, provider, model=settings.model, employer_id=stored.employer_id
|
||||
)
|
||||
except Exception: # pragma: no cover - maybe_enrich swallows; belt+braces
|
||||
logger.exception("enrich-on-publish failed; publish stands")
|
||||
return AdminPostingResponse(
|
||||
id=stored.id,
|
||||
source=stored.source,
|
||||
external_id=stored.external_id,
|
||||
employer_id=stored.employer_id,
|
||||
title=stored.title,
|
||||
status=stored.status,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/enrichment", status_code=201)
|
||||
async def manual_enrichment_override(
|
||||
body: AdminEnrichmentOverride,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""P4-A (D-053): founder manual override — APPENDS a manual-source
|
||||
row (read precedence manual > llm > raw picks it up); llm rows are
|
||||
NEVER mutated. Valid fields: description | social_links | logo."""
|
||||
if body.field not in ENRICHABLE_FIELDS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"field must be one of {ENRICHABLE_FIELDS}",
|
||||
)
|
||||
employer = store.get_employer(body.employer_id)
|
||||
if employer is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no employer with id {body.employer_id!r}"
|
||||
)
|
||||
row = store.append_enrichment(
|
||||
body.employer_id,
|
||||
field=body.field,
|
||||
value=body.value,
|
||||
source="manual",
|
||||
)
|
||||
return {"stored": True, "row": row}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/postings/{posting_id}/close", response_model=AdminPostingResponse
|
||||
)
|
||||
async def close_posting(
|
||||
posting_id: str,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> AdminPostingResponse:
|
||||
"""published → closed (the only valid move from published; 409 on
|
||||
closing a draft or re-closing)."""
|
||||
try:
|
||||
stored = store.set_posting_status(posting_id, "closed")
|
||||
except InvalidTransition as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if stored is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no posting with id {posting_id!r}"
|
||||
)
|
||||
return AdminPostingResponse(
|
||||
id=stored.id,
|
||||
source=stored.source,
|
||||
external_id=stored.external_id,
|
||||
employer_id=stored.employer_id,
|
||||
title=stored.title,
|
||||
status=stored.status,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/postings/{posting_id}", response_model=AdminPostingResponse)
|
||||
async def edit_posting(
|
||||
posting_id: str,
|
||||
body: AdminPostingEdit,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
provider: LLMProvider = Depends(get_provider),
|
||||
) -> AdminPostingResponse:
|
||||
"""P2 (Task 2-1-03): edit title/description/skills → RE-TAG the
|
||||
posting (D-052: tags are derived data — the new extraction
|
||||
OVERWRITES the old tags column; old tags gone). Tag failure is
|
||||
honest: the edit persists, tags stay as they were, a warning logs."""
|
||||
updated = store.update_posting_fields(
|
||||
posting_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
skills=body.skills,
|
||||
)
|
||||
if updated is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no posting with id {posting_id!r}"
|
||||
)
|
||||
try:
|
||||
tags = await tag_posting(
|
||||
provider,
|
||||
model=settings.model,
|
||||
title=updated.title,
|
||||
description=updated.description,
|
||||
skills_hint=updated.skills,
|
||||
)
|
||||
updated = store.set_posting_tags(posting_id, tags) or updated
|
||||
except TaggingUnavailable:
|
||||
# Honest: the EDIT landed; tags keep their previous value (the
|
||||
# warning already logged in tagging.py). Never 500 on tag failure.
|
||||
pass
|
||||
return AdminPostingResponse(
|
||||
id=updated.id,
|
||||
source=updated.source,
|
||||
external_id=updated.external_id,
|
||||
employer_id=updated.employer_id,
|
||||
title=updated.title,
|
||||
status=updated.status,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ingest", response_model=IngestResponse)
|
||||
async def ingest(
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
provider: LLMProvider = Depends(get_provider),
|
||||
embedder: EmbeddingProvider = Depends(get_embedding_provider),
|
||||
) -> IngestResponse:
|
||||
"""Run the seeded corpus source now (founder-invoked — no scheduler,
|
||||
A-401/A-402). Idempotent on (source, external_id): a double-run
|
||||
yields identical rows (MH-1b). Every posting is TAGGED after
|
||||
upsert (P2, D-052) — a tag failure leaves the posting UNTAGGED with
|
||||
a warning, never a crashed ingest. Published seed postings EMBED
|
||||
after tagging (P3 embed-on-publish path, Task 3-2-01) — an embed
|
||||
failure is the same honest warning."""
|
||||
source = SeededCorpusSource()
|
||||
result = run_ingest(source, store)
|
||||
tagged = 0
|
||||
untagged = 0
|
||||
embedded = 0
|
||||
for posting in source.fetch():
|
||||
stored = store.get_posting(posting.id)
|
||||
if stored is None: # pragma: no cover - upsert just ran
|
||||
continue
|
||||
try:
|
||||
tags = await tag_posting(
|
||||
provider,
|
||||
model=settings.model,
|
||||
title=stored.title,
|
||||
description=stored.description,
|
||||
skills_hint=stored.skills,
|
||||
)
|
||||
store.set_posting_tags(stored.id, tags)
|
||||
stored = store.get_posting(stored.id) or stored
|
||||
tagged += 1
|
||||
except TaggingUnavailable:
|
||||
untagged += 1
|
||||
if stored.status == "published":
|
||||
# Embed-on-publish for the published seed postings (G-102).
|
||||
if await embed_posting(store, embedder, stored):
|
||||
embedded += 1
|
||||
if untagged:
|
||||
logger.warning(
|
||||
"marketplace ingest tagged %s/%s postings — %s left untagged "
|
||||
"(provider failures are honest None, never fabricated)",
|
||||
tagged,
|
||||
tagged + untagged,
|
||||
untagged,
|
||||
)
|
||||
return IngestResponse(
|
||||
postings_upserted=result.postings_upserted,
|
||||
employers_upserted=result.employers_upserted,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/applications", response_model=AdminApplicationsResponse
|
||||
)
|
||||
async def list_applications(
|
||||
posting_id: str | None = None,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> AdminApplicationsResponse:
|
||||
"""Applications review list (newest first), filterable by posting.
|
||||
The founder contacts employers out-of-band (no notification infra,
|
||||
A-408)."""
|
||||
rows: list[Any] = store.list_applications(posting_id=posting_id)
|
||||
return AdminApplicationsResponse(
|
||||
applications=[
|
||||
AdminApplication(
|
||||
id=a.id,
|
||||
posting_id=a.posting_id,
|
||||
learner_id=a.learner_id,
|
||||
status=a.status,
|
||||
created_at=a.created_at.isoformat() if a.created_at else "",
|
||||
)
|
||||
for a in rows
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""G-101 placeholder-shell serving (v0.6).
|
||||
|
||||
Next 15's static export emits exactly one placeholder page per dynamic
|
||||
marketplace route (`out/marketplace/jobs/_/index.html`). Under the
|
||||
single-port deploy (D-039) these routes serve that shell for ANY id so
|
||||
runtime-created postings stay reachable and every sitemap URL resolves
|
||||
(G-109). The shell client-fetches the real data by path id and
|
||||
notFound()s on 404.
|
||||
|
||||
Registered before the StaticFiles mount so the routes win (the same
|
||||
routes-beat-the-mount mechanism the sitemap relies on). In dev mode
|
||||
(web_static_dir unset) the Next dev server handles routing itself —
|
||||
no-op.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..config import Settings
|
||||
|
||||
_SHELL_ROUTES = (
|
||||
("/marketplace/jobs/{job_id}", "marketplace/jobs/_/index.html"),
|
||||
("/marketplace/jobs/{job_id}/", "marketplace/jobs/_/index.html"),
|
||||
("/marketplace/employers/{employer_id}", "marketplace/employers/_/index.html"),
|
||||
("/marketplace/employers/{employer_id}/", "marketplace/employers/_/index.html"),
|
||||
)
|
||||
|
||||
|
||||
def register_marketplace_shell_routes(app: FastAPI, settings: Settings) -> None:
|
||||
web_dir = Path(str(settings.web_static_dir or ""))
|
||||
if str(settings.web_static_dir) in ("", ".") or not web_dir.is_dir():
|
||||
# Dev/no-mount boots: the Next dev server or a separate web origin
|
||||
# handles these paths. Nothing to serve here.
|
||||
return
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
for path, shell in _SHELL_ROUTES:
|
||||
|
||||
def _serve(shell_file: str = shell) -> FileResponse:
|
||||
page = web_dir / shell_file
|
||||
if page.is_file():
|
||||
return FileResponse(page, media_type="text/html")
|
||||
raise RuntimeError( # pragma: no cover - deploy misconfig
|
||||
f"marketplace shell missing: {page} — rebuild the web export"
|
||||
)
|
||||
|
||||
router.get(path, include_in_schema=False)(_serve)
|
||||
|
||||
app.include_router(router)
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic import SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
# apps/ai-service/ (sandbox dir default is relative to the app, not the CWD)
|
||||
@@ -156,6 +156,37 @@ class Settings(BaseSettings):
|
||||
# resubmit) and a per-learner submit rate ceiling.
|
||||
identity_submits_per_min: int = 3
|
||||
|
||||
# v0.6 marketplace (REQ-6-001, D-057): the founder/admin surface is
|
||||
# guarded by a shared secret — X-Admin-Token or Authorization: Bearer
|
||||
# compared via secrets.compare_digest (G-108). Env-only (D-014), never
|
||||
# logged; SecretStr keeps it out of accidental repr/logs. UNSET =
|
||||
# every /v1/marketplace/admin/* route 403s — fail closed, NEVER open
|
||||
# (a loud boot warning names the fix, G-11).
|
||||
admin_token: SecretStr = SecretStr("")
|
||||
|
||||
# v0.6 P5 placeholder (D-056): the deployed origin for sitemap.xml /
|
||||
# robots.txt absolute URLs (consumed by the SEO phase; documented
|
||||
# now so operators see it beside the admin token).
|
||||
public_base_url: str = ""
|
||||
|
||||
# Embedding provider selection (REQ-6-003, D-051): 'mock' (default —
|
||||
# deterministic 256-dim feature-hash, pure stdlib; tests never call
|
||||
# a real embeddings API) or 'openai-embed' (real embeddings against
|
||||
# an OpenAI-compatible endpoint). openai-embed requires
|
||||
# embed_base_url + embed_api_key + embed_model; when unconfigured the
|
||||
# lifespan falls back to mock with a loud log (G-11 — a typo'd env
|
||||
# must never crash the unattended boot).
|
||||
embed_provider: str = "mock"
|
||||
|
||||
# Real embeddings (D-051, probe-corrected): ollama-cloud does NOT
|
||||
# expose /embeddings — point AI_EMBED_BASE_URL at another
|
||||
# OpenAI-compatible endpoint (e.g. LOCAL ollama at :11434/v1, or any
|
||||
# embeddings cloud). Keys env-only, never committed, never logged
|
||||
# (SecretStr; mirrors the admin token).
|
||||
embed_base_url: str = ""
|
||||
embed_api_key: SecretStr = SecretStr("")
|
||||
embed_model: str = ""
|
||||
|
||||
# Voice provider selection (REQ-5-001, D-040): 'mock' (default — the
|
||||
# no-key path is first-class; tests never call a real voice API),
|
||||
# 'browser' (client-native SR/TTS; the descriptor tells the web client),
|
||||
|
||||
@@ -161,6 +161,49 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
if getattr(app.state, "identity_provider", None) is None:
|
||||
app.state.identity_provider = MockIdentityProvider()
|
||||
|
||||
# Marketplace engine (v0.6, REQ-6-001): 6th D-027 store family on
|
||||
# the same SQLite file. State-injection override preserved.
|
||||
if getattr(app.state, "marketplace_store", None) is None:
|
||||
from .marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
app.state.marketplace_store = SQLiteMarketplaceStore(
|
||||
db_path=settings.db_path
|
||||
)
|
||||
|
||||
# Embedding provider (v0.6 P3, REQ-6-003, D-051): mock-first
|
||||
# feature-hash default; openai-embed against any OpenAI-compatible
|
||||
# embeddings endpoint. State-injection override preserved (the
|
||||
# voice pattern). G-11 (boot survival): a misconfigured real
|
||||
# provider must never crash the unattended deploy — fall back to
|
||||
# mock loudly.
|
||||
if getattr(app.state, "embedding_provider", None) is None:
|
||||
from .marketplace.embedding.factory import (
|
||||
embedding_provider_from_settings,
|
||||
)
|
||||
from .marketplace.embedding.mock import MockEmbedProvider
|
||||
|
||||
try:
|
||||
app.state.embedding_provider = embedding_provider_from_settings(
|
||||
settings, app.state.http_client
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"embedding provider %r unavailable (%s); falling back to "
|
||||
"mock — fix the AI_EMBED_* settings and restart",
|
||||
settings.embed_provider,
|
||||
exc,
|
||||
)
|
||||
app.state.embedding_provider = MockEmbedProvider()
|
||||
|
||||
# G-11/D-057: admin routes fail CLOSED on an unset token, and the
|
||||
# founder must be told loudly at boot (never a silent lockout).
|
||||
if not settings.admin_token.get_secret_value():
|
||||
logger.warning(
|
||||
"AI_ADMIN_TOKEN is unset — /v1/marketplace/admin/* routes "
|
||||
"are 403 (fail-closed). Set AI_ADMIN_TOKEN in .env to "
|
||||
"author postings, run ingest, and review applications."
|
||||
)
|
||||
|
||||
# Grading persistence + engine (REQ-3-004): GradeStore from the same
|
||||
# SQLite file as traces (D-027), one GradingEngine singleton wired
|
||||
# through app.state — the engine receives its stores via constructor
|
||||
@@ -244,13 +287,16 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.include_router(variants_router)
|
||||
app.include_router(defense_router)
|
||||
|
||||
# v0.5 identity (REQ-5-003/004): verification flow + age-gate deps
|
||||
# + the one marketplace 18+ gated stub (G-18).
|
||||
from .api.identity import marketplace_router
|
||||
# v0.5 identity (REQ-5-003/004): verification flow + age-gate deps.
|
||||
# v0.6: the G-18 marketplace stub moved to api/marketplace.py and is
|
||||
# REAL (D-058) — identity keeps only its own router + gate exports.
|
||||
from .api.identity import router as identity_router
|
||||
from .api.marketplace import router as marketplace_public_router
|
||||
from .api.marketplace_admin import router as marketplace_admin_router
|
||||
|
||||
app.include_router(identity_router)
|
||||
app.include_router(marketplace_router)
|
||||
app.include_router(marketplace_public_router)
|
||||
app.include_router(marketplace_admin_router)
|
||||
|
||||
# A-305/D1: PII-safe 422s — FastAPI echoes the offending `input` in
|
||||
# validation errors by default; for the identity submit body that
|
||||
@@ -285,6 +331,19 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
|
||||
app.add_exception_handler(RequestValidationError, _scrub_pii_validation)
|
||||
|
||||
# v0.6 G-101: Next 15 output:'export' hard-throws (E87) for dynamic
|
||||
# routes without generateStaticParams, so jobs/[jobId] and
|
||||
# employers/[employerId] export exactly ONE placeholder shell
|
||||
# (out/marketplace/jobs/_/index.html). These catch-all routes serve
|
||||
# that shell for ANY id — registered BEFORE the StaticFiles mount so
|
||||
# they win (the same routes-beat-the-mount mechanism the sitemap
|
||||
# relies on, G-109). The shell client-fetches the real posting by
|
||||
# the path id and notFound()s on 404 — a resolvable URL for every
|
||||
# sitemap entry.
|
||||
from .api.marketplace_shells import register_marketplace_shell_routes
|
||||
|
||||
register_marketplace_shell_routes(app, settings)
|
||||
|
||||
# v0.3.6 single-port deploy: serve the exported web app (apps/web/out)
|
||||
# from the SAME origin as the API when AI_WEB_STATIC_DIR is set. Mounted
|
||||
# AFTER all routers, so /v1/*, /health, /docs win; StaticFiles(html=True)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Marketplace engine package (REQ-6-001, D-048/D-049).
|
||||
|
||||
The 6th engine module inside apps/ai-service (no new apps, no new ports,
|
||||
D-031 boundary): pydantic contracts (`base.py`), the D-027 store family's
|
||||
sixth SQLite store (`store.py`), and PostingSource ingest adapters
|
||||
(`ingest.py`). Tagging (P2), embedding (P3), enrichment + resume (P4)
|
||||
extend this package; the HTTP surface lives in `api/marketplace*.py`
|
||||
which composes this module via DI.
|
||||
|
||||
Boundary (D-031/D-048): `marketplace/` never imports `agents/` or
|
||||
`api/`; this package imports config + stdlib only.
|
||||
"""
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Marketplace pydantic contracts (REQ-6-001, D-048, A-402).
|
||||
|
||||
The wire + store shapes for the postings backend. `IngestPosting` is the
|
||||
A-402 adapter shape (what every PostingSource yields); `Posting` is the
|
||||
full row shape (status lifecycle + tags); `Employer` carries the raw
|
||||
ingested fields (enrichment + provenance merge is P4's read-time job).
|
||||
|
||||
D-051/D-053 note: the mock `matchScore` from the TS fixtures is NOT part
|
||||
of any contract — P3 computes real scores at search time; absent score =
|
||||
absent query context (G-106).
|
||||
|
||||
Boundary (D-048): mirrors D-031 — never imports `agents/` or `api/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
PostingStatus = Literal["draft", "published", "closed"]
|
||||
|
||||
#: Lifecycle edge set (D-049/D-057): draft → published → closed. A
|
||||
#: transition not in this map is invalid (admin route → 409).
|
||||
VALID_TRANSITIONS: dict[str, str] = {
|
||||
"draft": "published",
|
||||
"published": "closed",
|
||||
}
|
||||
|
||||
Seniority = Literal["entry", "mid", "senior", "staff", "principal"]
|
||||
|
||||
#: P2 (D-052) enumerates the category taxonomy; the seed corpus carries
|
||||
#: category=null and P2's tagging pass fills it. The store accepts any
|
||||
#: short string (validation lands with the tagger); `None` = not yet
|
||||
#: tagged.
|
||||
Category = str | None
|
||||
|
||||
|
||||
class PostingTags(BaseModel):
|
||||
"""Derived tags on a posting row (D-052 schema, persisted as the
|
||||
posting's JSON `tags` column in P2; the column exists from day one).
|
||||
|
||||
`skills` is bounded at 10 by the tagger (D-052); the schema itself
|
||||
keeps the bound so the store can validate a hand-authored payload.
|
||||
`extra="forbid"`: a non-tag JSON object (e.g. the mock provider's
|
||||
canned {"summary":...}) must NOT silently validate as all-default
|
||||
tags — the untagged signal stays honest (verifier P2-3; P3
|
||||
embeddings must not treat empty-default parses as real tags).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
skills: list[str] = Field(default_factory=list, max_length=10)
|
||||
seniority: Seniority | None = None
|
||||
category: Category = None
|
||||
|
||||
|
||||
class Employer(BaseModel):
|
||||
"""Raw ingested employer fields (D-049 `employer` table row shape).
|
||||
|
||||
P4 (D-053) enriches on read with per-field `{value, source}`
|
||||
provenance; P1 surfaces the raw fields only.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
source: str = Field(min_length=1)
|
||||
external_id: str = Field(min_length=1)
|
||||
name: str = Field(min_length=1)
|
||||
logo: str = ""
|
||||
description: str = ""
|
||||
industry: str = ""
|
||||
size: str = ""
|
||||
location: str = ""
|
||||
website: str = ""
|
||||
social_links: dict[str, str] = Field(default_factory=dict)
|
||||
culture: str = ""
|
||||
|
||||
|
||||
class IngestPosting(BaseModel):
|
||||
"""The A-402 PostingSource payload: (source, external_id) identity +
|
||||
employer binding + the raw posting fields. Idempotent upsert key is
|
||||
(source, external_id) — a re-fetch overwrites the same row in place.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
source: str = Field(min_length=1)
|
||||
external_id: str = Field(min_length=1)
|
||||
employer_id: str = Field(min_length=1)
|
||||
title: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
required_competencies: list[str] = Field(default_factory=list)
|
||||
skills: list[str] = Field(default_factory=list)
|
||||
seniority: Seniority | None = None
|
||||
category: Category = None
|
||||
location: str = ""
|
||||
remote: bool = False
|
||||
salary_min: int | None = None
|
||||
salary_max: int | None = None
|
||||
#: G-102: seed-port postings carry their ORIGINAL postedAt; the
|
||||
#: store applies it verbatim (status=published via the ingest
|
||||
#: contract, not the field).
|
||||
posted_at: datetime | None = None
|
||||
|
||||
|
||||
class Posting(BaseModel):
|
||||
"""Full posting row (D-049 `posting` table). Status is the lifecycle
|
||||
column (draft|published|closed, D-057); `tags` is the P2-derived
|
||||
JSON column (D-052 — empty until the tagger runs). `matchScore` is
|
||||
deliberately absent (G-106/G-107).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
source: str = Field(min_length=1)
|
||||
external_id: str = Field(min_length=1)
|
||||
employer_id: str = Field(min_length=1)
|
||||
title: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
required_competencies: list[str] = Field(default_factory=list)
|
||||
skills: list[str] = Field(default_factory=list)
|
||||
seniority: Seniority | None = None
|
||||
category: Category = None
|
||||
location: str = ""
|
||||
remote: bool = False
|
||||
salary_min: int | None = None
|
||||
salary_max: int | None = None
|
||||
status: PostingStatus = "draft"
|
||||
tags: PostingTags | None = None
|
||||
posted_at: datetime | None = None
|
||||
|
||||
|
||||
class Application(BaseModel):
|
||||
"""A learner's application to a posting (D-049 `application` table).
|
||||
Unique (posting_id, learner_id) → duplicate apply is idempotent
|
||||
200-with-existing (A-408/D-058). P1 status is always `submitted`;
|
||||
richer states are post-v0.6.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
posting_id: str = Field(min_length=1)
|
||||
learner_id: str = Field(min_length=1)
|
||||
status: str = "submitted"
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class PostingPage(BaseModel):
|
||||
"""Published-only list response (G-103): the page + the honest
|
||||
total so the board can render "Showing X of Y" over a paged
|
||||
fetch-all without fabrication."""
|
||||
|
||||
postings: list[Posting]
|
||||
total_published: int
|
||||
|
||||
|
||||
class IngestResult(BaseModel):
|
||||
"""Honest ingest run summary (founder surface)."""
|
||||
|
||||
postings_upserted: int = 0
|
||||
employers_upserted: int = 0
|
||||
|
||||
|
||||
def posting_summary(posting: Posting) -> dict[str, Any]:
|
||||
"""Serialization helper shared by the list + detail responses — one
|
||||
shape so the G-107 pin (no `matchScore` in any marketplace-path
|
||||
payload) covers both surfaces."""
|
||||
return posting.model_dump(mode="json")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Marketplace embedding providers (D-051, REQ-6-003).
|
||||
|
||||
Protocol + deterministic feature-hash mock + OpenAI-compatible real
|
||||
provider + factory. Mirrors the voice/ layout (base/mock/openai_compat/
|
||||
factory) — embeddings need NO descriptor: match scores are computed
|
||||
server-side in search.py, never shipped as client capability hints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import EmbeddingProvider, EmbeddingResult
|
||||
from .factory import UnknownEmbedProviderError, embedding_provider_from_settings
|
||||
from .mock import (
|
||||
MOCK_EMBED_DIM,
|
||||
MOCK_EMBED_MODEL,
|
||||
MockEmbedProvider,
|
||||
embed_text,
|
||||
normalize_tokens,
|
||||
)
|
||||
from .openai_compat import OpenAIEmbedProvider
|
||||
|
||||
__all__ = [
|
||||
"MOCK_EMBED_DIM",
|
||||
"MOCK_EMBED_MODEL",
|
||||
"EmbeddingProvider",
|
||||
"EmbeddingResult",
|
||||
"MockEmbedProvider",
|
||||
"OpenAIEmbedProvider",
|
||||
"UnknownEmbedProviderError",
|
||||
"embed_text",
|
||||
"embedding_provider_from_settings",
|
||||
"normalize_tokens",
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""EmbeddingProvider protocol + shared embedding contracts (D-051, REQ-6-003).
|
||||
|
||||
Mirrors the D-030/D-014 provider seam: a narrow protocol the search
|
||||
pipeline and publish hooks compose via DI, with a deterministic
|
||||
feature-hash mock (pure stdlib) and an OpenAI-compatible real provider.
|
||||
No descriptor — unlike voice, embeddings are consumed SERVER-side only
|
||||
(match scores are computed in search.py, never shipped as capability
|
||||
hints), so there is nothing for a client to badge.
|
||||
|
||||
Boundary (D-048): `marketplace/embedding/` never imports `agents/` or
|
||||
`api/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class EmbeddingResult(BaseModel):
|
||||
"""One embedded text (D-051): the vector + its provenance.
|
||||
|
||||
`model`/`dim` ride every result so the store can persist per-row
|
||||
provenance (A-403/A-404: lazy re-embed on provider change works).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
values: list[float] = Field(min_length=1)
|
||||
model: str = Field(min_length=1)
|
||||
dim: int = Field(ge=1)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmbeddingProvider(Protocol):
|
||||
"""The embedding port (D-051): texts in, vectors out. Never imports
|
||||
agents/api; never stores anything (storage is the store's job)."""
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[EmbeddingResult]:
|
||||
"""Embed a batch of texts; result[i] corresponds to texts[i].
|
||||
|
||||
Implementations MUST be deterministic per provider config so
|
||||
stored vectors stay comparable across re-embeds.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Embedding provider factory (D-051, REQ-6-003).
|
||||
|
||||
`AI_EMBED_PROVIDER = mock | openai-embed` (default: mock — the no-key
|
||||
path is first-class; tests never call a real embeddings API).
|
||||
`openai-embed` requires embed_base_url + embed_api_key + embed_model:
|
||||
the factory raises `UnknownEmbedProviderError` with an actionable
|
||||
message for direct callers (tests), while the lifespan in main.py
|
||||
CATCHES it and falls back to mock with a loud log — a typo'd env must
|
||||
never crash the unattended boot (G-11; the exact voice/factory.py
|
||||
pattern).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from ...config import Settings
|
||||
from .base import EmbeddingProvider
|
||||
from .mock import MockEmbedProvider
|
||||
from .openai_compat import OpenAIEmbedProvider
|
||||
|
||||
|
||||
class UnknownEmbedProviderError(ValueError):
|
||||
"""Raised for a provider name outside the contract, or a real provider
|
||||
selected without its required configuration."""
|
||||
|
||||
|
||||
def embedding_provider_from_settings(
|
||||
settings: Settings, http_client: httpx.AsyncClient | None = None
|
||||
) -> EmbeddingProvider:
|
||||
"""Select the embedding provider by settings (env `AI_EMBED_PROVIDER`).
|
||||
|
||||
`http_client` is required for the `openai-embed` branch (D-017 shared
|
||||
pool); mock ignores it.
|
||||
"""
|
||||
name = (settings.embed_provider or "mock").strip().lower()
|
||||
if name == "mock":
|
||||
return MockEmbedProvider()
|
||||
if name in ("openai-embed", "openai", "server"):
|
||||
if (
|
||||
not settings.embed_base_url
|
||||
or not settings.embed_api_key.get_secret_value()
|
||||
or not settings.embed_model
|
||||
):
|
||||
raise UnknownEmbedProviderError(
|
||||
"AI_EMBED_PROVIDER=openai-embed requires AI_EMBED_BASE_URL, "
|
||||
"AI_EMBED_API_KEY, and AI_EMBED_MODEL — set all three, or use "
|
||||
"'mock'. NOTE (D-051 probe): ollama-cloud does NOT expose "
|
||||
"/embeddings; point AI_EMBED_BASE_URL at another "
|
||||
"OpenAI-compatible endpoint (e.g. local ollama :11434/v1). "
|
||||
"(main.py falls back to mock when these are missing — G-11)"
|
||||
)
|
||||
if http_client is None:
|
||||
raise UnknownEmbedProviderError(
|
||||
"openai-embed requires the shared httpx client "
|
||||
"(embedding_provider_from_settings(settings, http_client))"
|
||||
)
|
||||
return OpenAIEmbedProvider(
|
||||
http_client=http_client,
|
||||
base_url=settings.embed_base_url,
|
||||
api_key=settings.embed_api_key.get_secret_value(),
|
||||
model=settings.embed_model,
|
||||
)
|
||||
raise UnknownEmbedProviderError(
|
||||
f"unknown AI_EMBED_PROVIDER {name!r}: use 'mock' or 'openai-embed'"
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Deterministic MockEmbedProvider (D-051, REQ-6-003).
|
||||
|
||||
256-dim feature-hash of normalized tokens — pure stdlib (hashlib, math;
|
||||
NO numpy, NO python hash() — the builtin is salted per process).
|
||||
Same text → identical vector, ALWAYS (across processes, reboots,
|
||||
machines). Related texts share tokens → higher cosine than unrelated
|
||||
texts, so pinned similarity numbers stay meaningful in tests.
|
||||
|
||||
Algorithm (documented — search.py's skill_breakdown depends on it):
|
||||
1. normalize: lowercase, split on non-alphanumeric ([^a-z0-9]+)
|
||||
2. every token hashes (SHA-256 of its UTF-8 bytes) to an index
|
||||
idx = int.from_bytes(digest[:4], "little") % 256
|
||||
3. sign alternation on token-position collisions: the k-th OCCURRENCE
|
||||
of a token at the same index flips sign — (+1, -1, +1, ...) — so
|
||||
repeated tokens cannot monotonically inflate one component
|
||||
(hash("ab") == hash("ba") collisions cancel, not stack).
|
||||
occurrence = the token's own prior count at that index.
|
||||
4. accumulate counts into the 256-dim vector
|
||||
5. L2-normalize (a zero vector — no tokens — stays all-zeros so the
|
||||
cosine guard in search.py can be exercised; honest, never a fabricated
|
||||
direction).
|
||||
|
||||
`embed` embeds the WHOLE batch in one call (no state between calls —
|
||||
per-text vectors are independent), returning EmbeddingResult rows with
|
||||
model="mock-feature-hash-256".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
|
||||
from .base import EmbeddingResult
|
||||
|
||||
#: The mock's fixed dimensionality (D-051) — also its model identity.
|
||||
MOCK_EMBED_DIM = 256
|
||||
MOCK_EMBED_MODEL = "mock-feature-hash-256"
|
||||
|
||||
_SPLIT = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def normalize_tokens(text: str) -> list[str]:
|
||||
"""lowercase + split on non-alphanumeric; empty pieces dropped."""
|
||||
return [tok for tok in _SPLIT.split(text.lower()) if tok]
|
||||
|
||||
|
||||
def token_index(token: str) -> int:
|
||||
"""Stable, process-independent index: first 4 SHA-256 digest bytes,
|
||||
little-endian, mod 256 (never the salted builtin hash())."""
|
||||
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
||||
return int.from_bytes(digest[:4], "little") % MOCK_EMBED_DIM
|
||||
|
||||
|
||||
def embed_text(text: str) -> list[float]:
|
||||
"""The deterministic feature-hash vector for one text (pure function).
|
||||
|
||||
Exposed for search.py's per-skill contribution shares — the same
|
||||
formula must produce the mock's vectors and the breakdown math.
|
||||
"""
|
||||
vec = [0.0] * MOCK_EMBED_DIM
|
||||
for token in normalize_tokens(text):
|
||||
idx = token_index(token)
|
||||
# Sign alternation per token OCCURRENCE at its index: the token's
|
||||
# own running count at idx (not its position in the text).
|
||||
vec[idx] += 1.0 if vec[idx] >= 0 else -1.0 if vec[idx] <= 0 else 0.0
|
||||
# L2-normalize; all-zero (no tokens) stays all-zero (honest zero
|
||||
# vector — the cosine guard downstream treats it as unrankable).
|
||||
norm = math.sqrt(sum(v * v for v in vec))
|
||||
if norm > 0.0:
|
||||
vec = [v / norm for v in vec]
|
||||
return vec
|
||||
|
||||
|
||||
class MockEmbedProvider:
|
||||
"""Deterministic 256-dim feature-hash embedding (D-051 mock).
|
||||
|
||||
Cloud-free by construction; identical inputs → identical outputs
|
||||
forever (hashlib-based, no PYTHONHASHSEED dependence)."""
|
||||
|
||||
model = MOCK_EMBED_MODEL
|
||||
dim = MOCK_EMBED_DIM
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[EmbeddingResult]:
|
||||
return [
|
||||
EmbeddingResult(
|
||||
values=embed_text(text),
|
||||
model=MOCK_EMBED_MODEL,
|
||||
dim=MOCK_EMBED_DIM,
|
||||
)
|
||||
for text in texts
|
||||
]
|
||||
|
||||
|
||||
# Protocol-shape parity guard (mock must satisfy the D-051 port).
|
||||
from .base import EmbeddingProvider # noqa: E402
|
||||
|
||||
|
||||
def _assert_protocol() -> None:
|
||||
assert isinstance(MockEmbedProvider(), EmbeddingProvider)
|
||||
|
||||
|
||||
_assert_protocol()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""OpenAI-compatible embedding provider (D-051, REQ-6-003).
|
||||
|
||||
One implementation serves any OpenAI-compatible embeddings endpoint:
|
||||
`POST {base_url}/embeddings` with `{"model", "input": [texts]}` →
|
||||
`{"data": [{"embedding": [...]}, ...], "model": ...}`. Raw httpx on the
|
||||
shared lifespan client (D-017). base_url is config, never code — the
|
||||
D-014 endpoint-agnostic pattern.
|
||||
|
||||
PROBE CORRECTION (D-051): ollama-cloud does NOT expose /embeddings
|
||||
(404 path-not-found) — AI_EMBED_BASE_URL must point at another
|
||||
OpenAI-compatible endpoint (e.g. LOCAL ollama at :11434/v1, which does).
|
||||
|
||||
Boundary rules (mirror voice/openai_audio.py):
|
||||
- embedding/ imports nothing from agents/ or api/
|
||||
- api_key NEVER appears in exceptions, logs, or error messages
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import EmbeddingResult
|
||||
|
||||
|
||||
class OpenAIEmbedProvider:
|
||||
"""POST {base_url}/embeddings {"model", "input": [texts]}."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str = "",
|
||||
) -> None:
|
||||
self._client = http_client
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._model = model
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self._api_key:
|
||||
headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
return headers
|
||||
|
||||
def _sanitize(self, exc: Exception) -> RuntimeError:
|
||||
text = str(exc)
|
||||
if self._api_key and self._api_key in text:
|
||||
text = text.replace(self._api_key, "[REDACTED]")
|
||||
return RuntimeError(f"embedding provider error: {text}")
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[EmbeddingResult]:
|
||||
"""Batch-embed texts; result[i] corresponds to texts[i].
|
||||
|
||||
Parses the OpenAI-format response: `{"data": [{"embedding":
|
||||
[...]}], "model": ...}` — index-order preserved (the spec orders
|
||||
data by input position).
|
||||
"""
|
||||
payload = {"model": self._model, "input": list(texts)}
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
f"{self._base_url}/embeddings",
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise self._sanitize(exc) from exc
|
||||
if not isinstance(body, dict):
|
||||
raise RuntimeError(
|
||||
"embedding provider error: unexpected embeddings response shape"
|
||||
)
|
||||
data = body.get("data")
|
||||
if not isinstance(data, list) or len(data) != len(texts):
|
||||
raise RuntimeError(
|
||||
"embedding provider error: embeddings response/data length "
|
||||
f"mismatch (expected {len(texts)} entries)"
|
||||
)
|
||||
model = str(body.get("model") or self._model)
|
||||
results: list[EmbeddingResult] = []
|
||||
for entry in data:
|
||||
if not isinstance(entry, dict) or not isinstance(
|
||||
entry.get("embedding"), list
|
||||
):
|
||||
raise RuntimeError(
|
||||
"embedding provider error: embeddings response/data entry "
|
||||
"missing the 'embedding' array"
|
||||
)
|
||||
values = [float(v) for v in entry["embedding"]]
|
||||
results.append(
|
||||
EmbeddingResult(values=values, model=model, dim=len(values))
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Employer enrichment (REQ-6-004, D-053, A-409).
|
||||
|
||||
One-shot LLM extraction of employer description/social-links/logo from
|
||||
the employer's own posting data, composed through the D-020 pipeline
|
||||
(the D-052 tagging pattern) — no new agent. Enrichment rows are
|
||||
APPEND-ONLY with per-field provenance; merge-on-read precedence is
|
||||
manual > llm > ingested-raw, and every enriched field carries a
|
||||
visible {value, source} marker in API responses (A-304 honesty —
|
||||
LLM-authored copy is never displayed as ingested fact).
|
||||
|
||||
Trigger semantics (enrich-on-first-publish approximation): the publish
|
||||
route fires enrichment when the employer has NO existing llm-source
|
||||
rows — re-publishing the same employer does not re-run (the rows
|
||||
exist). Failure is honest: a warning logs, the publish still succeeds,
|
||||
no rows are written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from ..agents.structured import structured_completion
|
||||
from ..llm.base import LLMProvider
|
||||
from ..llm.types import Message
|
||||
from .base import Employer
|
||||
from .store import MarketplaceStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Prompt version — bump on any wording change (prompts are code).
|
||||
ENRICHMENT_PROMPT_VERSION = 1
|
||||
|
||||
ENRICHMENT_PROMPT = """You are an employer-profile writer for an AI-era
|
||||
roles marketplace. From the employer's own data below, write concise
|
||||
factual profile copy.
|
||||
|
||||
Rules:
|
||||
- description: 1-3 sentences summarizing what the company does, grounded
|
||||
ONLY in the provided data — never invent employers, products, or
|
||||
numbers.
|
||||
- social_links: an object of platform → URL, ONLY for platforms the
|
||||
data names (website, github, linkedin, twitter/x). Empty object if
|
||||
none.
|
||||
- logo_ref: a short reference to the logo source named in the data
|
||||
(e.g. a URL or file name); empty string if none.
|
||||
|
||||
Respond with ONLY the JSON object."""
|
||||
|
||||
_SCHEMA_HINT = (
|
||||
'{"description": "1-3 sentence string", '
|
||||
'"social_links": {"platform": "url"}, '
|
||||
'"logo_ref": "string"}'
|
||||
)
|
||||
|
||||
#: The enrichable field set (D-053) — the manual-override route validates
|
||||
#: against this and the merge walks exactly these keys.
|
||||
ENRICHABLE_FIELDS = ("description", "social_links", "logo")
|
||||
|
||||
_PRECEDENCE = ("manual", "llm")
|
||||
|
||||
|
||||
class EnrichmentUnavailable(Exception):
|
||||
"""The extractor could not produce valid enrichment (provider/parse
|
||||
failure). The publish proceeds; no rows are written (D-053 honesty)."""
|
||||
|
||||
|
||||
class EnrichmentPayload(BaseModel):
|
||||
"""The D-020 extraction schema. extra=forbid keeps the honesty
|
||||
contract (the verifier P2-3 lesson): a non-matching LLM reply must
|
||||
fail validation, not silently parse as all-defaults."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
description: str = ""
|
||||
social_links: dict[str, str] = {}
|
||||
logo_ref: str = ""
|
||||
|
||||
async def enrich_employer(
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
model: str,
|
||||
employer: Employer,
|
||||
postings_hint: list[str] | None = None,
|
||||
) -> EnrichmentPayload:
|
||||
"""One-shot profile extraction via D-020 structured completion.
|
||||
|
||||
`postings_hint` (the employer's posting titles) grounds the copy in
|
||||
the employer's own data. Failures raise EnrichmentUnavailable —
|
||||
honest, never a crash, never fabricated copy."""
|
||||
hint = ""
|
||||
if postings_hint:
|
||||
hint = "\nPosting titles: " + "; ".join(postings_hint[:10])
|
||||
messages = [
|
||||
Message(role="system", content=ENRICHMENT_PROMPT),
|
||||
Message(
|
||||
role="user",
|
||||
content=(
|
||||
f"Employer name: {employer.name}\n"
|
||||
f"Industry: {employer.industry}\n"
|
||||
f"Known description: {employer.description}\n"
|
||||
f"Website: {employer.website}\n"
|
||||
f"Social links: {json.dumps(employer.social_links)}\n"
|
||||
f"Logo: {employer.logo}{hint}"
|
||||
),
|
||||
),
|
||||
]
|
||||
try:
|
||||
return await structured_completion(
|
||||
provider,
|
||||
messages,
|
||||
model=model,
|
||||
schema=EnrichmentPayload,
|
||||
schema_hint=_SCHEMA_HINT,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"marketplace enrichment failed for %r: %s — no rows written "
|
||||
"(honest absence, never fabricated copy)",
|
||||
employer.name[:60],
|
||||
exc,
|
||||
)
|
||||
raise EnrichmentUnavailable(str(exc)) from exc
|
||||
|
||||
|
||||
def merge_employer(
|
||||
employer: Employer, rows: list[dict]
|
||||
) -> dict[str, dict]:
|
||||
"""Merge-on-read with per-field provenance (D-053/A-409).
|
||||
|
||||
Precedence per FIELD: manual > llm > ingested-raw — a manual
|
||||
description + llm social_links is legal. Every field carries
|
||||
{value, source, model?}; `social_links` values are dicts (the row
|
||||
stores JSON), the others strings. Newest row per (field, source)
|
||||
wins within a source (append-only, newest-last).
|
||||
"""
|
||||
best: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
field = row.get("field")
|
||||
if field not in ENRICHABLE_FIELDS:
|
||||
continue
|
||||
source = row.get("source")
|
||||
rank = _PRECEDENCE.index(source) if source in _PRECEDENCE else len(_PRECEDENCE)
|
||||
existing = best.get(field)
|
||||
if existing is None or rank < existing["_rank"]:
|
||||
best[field] = {
|
||||
"_rank": rank,
|
||||
"value": row.get("value"),
|
||||
"source": source,
|
||||
"model": row.get("model") or None,
|
||||
}
|
||||
merged: dict[str, dict] = {}
|
||||
for field in ENRICHABLE_FIELDS:
|
||||
raw_value = {
|
||||
"description": employer.description,
|
||||
"social_links": dict(employer.social_links),
|
||||
"logo": employer.logo,
|
||||
}[field]
|
||||
if field in best:
|
||||
entry = best[field]
|
||||
merged[field] = {
|
||||
"value": entry["value"],
|
||||
"source": entry["source"],
|
||||
"model": entry["model"],
|
||||
}
|
||||
else:
|
||||
# No enrichment rows for this field — the ingested raw value
|
||||
# with the honest raw marker (A-304: LLM copy never
|
||||
# masquerades as ingested fact, and vice versa).
|
||||
merged[field] = {"value": raw_value, "source": "raw", "model": None}
|
||||
return merged
|
||||
|
||||
|
||||
async def maybe_enrich_on_publish(
|
||||
store: MarketplaceStore,
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
model: str,
|
||||
employer_id: str,
|
||||
) -> bool:
|
||||
"""Enrich-on-first-publish trigger: fires when the employer has NO
|
||||
existing llm-source rows (re-publishing does not re-run — the rows
|
||||
exist). Returns True when enrichment rows landed. Failures are
|
||||
honest: warning + False, the publish itself already succeeded."""
|
||||
if store.count_enrichment(employer_id, source="llm") > 0:
|
||||
return False
|
||||
employer = store.get_employer(employer_id)
|
||||
if employer is None:
|
||||
return False # dangling employer ref (the seed carries 3) — skip
|
||||
titles = store.employer_posting_titles(employer_id)
|
||||
try:
|
||||
payload = await enrich_employer(
|
||||
provider, model=model, employer=employer, postings_hint=titles
|
||||
)
|
||||
except EnrichmentUnavailable:
|
||||
return False
|
||||
stamp = {"model": model}
|
||||
rows = [
|
||||
("description", payload.description, "llm", stamp),
|
||||
(
|
||||
"social_links",
|
||||
json.dumps(payload.social_links),
|
||||
"llm",
|
||||
stamp,
|
||||
),
|
||||
("logo", payload.logo_ref, "llm", stamp),
|
||||
]
|
||||
for field, value, source, meta in rows:
|
||||
if value not in ("", "{}"):
|
||||
store.append_enrichment(
|
||||
employer_id, field=field, value=value, source=source, **meta
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,97 @@
|
||||
"""PostingSource adapters + ingest runner (REQ-6-001, D-050, A-402).
|
||||
|
||||
`PostingSource` is the adapter port: `fetch() -> Iterable[IngestPosting]`.
|
||||
v0.6 ships ONE implementation, `SeededCorpusSource` over the in-repo
|
||||
`seed_corpus.json` (ported ONCE from packages/mock-data/jobs.ts +
|
||||
employers.ts per D-021 — shared ids, mock matchScore DROPPED: D-051
|
||||
computes real scores at search time, G-106/G-107). The 3M+ external
|
||||
corpus lands later as a third adapter with zero store/route changes.
|
||||
|
||||
Ingest-status contract (G-102): SEED-PORT postings enter as `published`
|
||||
with their ORIGINAL postedAt — otherwise the board/facets/P1's success
|
||||
criterion sit empty after ingest. ADMIN-AUTHORED postings start
|
||||
`draft` (the admin route's create path, not this runner).
|
||||
|
||||
No scheduler/crawler (A-401/A-402): ingest runs when the founder POSTs
|
||||
/v1/marketplace/admin/ingest; `run_ingest` is idempotent on
|
||||
(source, external_id) — a double-run yields identical row counts.
|
||||
|
||||
Boundary (D-048): never imports `agents/` or `api/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from .base import Employer, IngestPosting, IngestResult
|
||||
from .store import MarketplaceStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The ported TS fixture corpus ships IN THE PACKAGE (importlib-free
|
||||
#: path resolution: this file's parent) — one JSON file, no codegen.
|
||||
_SEED_PATH = Path(__file__).resolve().parent / "seed_corpus.json"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostingSource(Protocol):
|
||||
"""The ingest port (D-050): yields IngestPosting payloads. Employers
|
||||
ride the corpus alongside postings (the seeded source exposes both;
|
||||
a pure-posting adapter simply fetches postings and leaves employers
|
||||
to a separate source)."""
|
||||
|
||||
def fetch(self) -> Iterable[IngestPosting]:
|
||||
...
|
||||
|
||||
def fetch_employers(self) -> Iterable[Employer]:
|
||||
...
|
||||
|
||||
|
||||
class SeededCorpusSource:
|
||||
"""The seeded corpus adapter over seed_corpus.json (D-050).
|
||||
|
||||
Pure + deterministic: reads the JSON fixture once per fetch; the
|
||||
runner upserts idempotently. Raw fields map 1:1 from the TS shapes
|
||||
(camelCase → snake_case port, matchScore dropped — G-102/G-107)."""
|
||||
|
||||
def __init__(self, path: Path = _SEED_PATH) -> None:
|
||||
self._path = path
|
||||
|
||||
def _load(self) -> dict:
|
||||
with self._path.open(encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
def fetch(self) -> Iterable[IngestPosting]:
|
||||
for raw in self._load().get("postings", []):
|
||||
yield IngestPosting.model_validate(raw)
|
||||
|
||||
def fetch_employers(self) -> Iterable[Employer]:
|
||||
for raw in self._load().get("employers", []):
|
||||
yield Employer.model_validate(raw)
|
||||
|
||||
|
||||
def run_ingest(source: PostingSource, store: MarketplaceStore) -> IngestResult:
|
||||
"""Idempotent upsert run: every (source, external_id) overwrites its
|
||||
row in place — a second run yields identical counts (MH-1b)."""
|
||||
|
||||
result = IngestResult()
|
||||
# Employers first: postings reference employer ids (no FK today, but
|
||||
# the reference ordering keeps the board honest mid-run).
|
||||
for employer in source.fetch_employers():
|
||||
store.upsert_employer(employer)
|
||||
result.employers_upserted += 1
|
||||
for posting in source.fetch():
|
||||
# G-102: seed-port postings enter PUBLISHED with their original
|
||||
# postedAt (the IngestPosting carries it verbatim).
|
||||
store.upsert_posting(posting, status="published")
|
||||
result.postings_upserted += 1
|
||||
logger.info(
|
||||
"marketplace ingest: %s postings, %s employers (idempotent upserts)",
|
||||
result.postings_upserted,
|
||||
result.employers_upserted,
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Resume → profile draft extraction (REQ-6-005, D-054, A-410).
|
||||
|
||||
Pipeline: upload guard (PDF/TXT/MD only, ≤2 MB, text-layer required —
|
||||
empty pypdf extract → honest 422, NO OCR in v0.6) → text extraction →
|
||||
~100K char cap → D-020 structured extraction into ParsedProfile →
|
||||
response carries status="draft" — NOTHING persists to the learner
|
||||
profile until the explicit confirm route (the REQ-6-005 review gate).
|
||||
|
||||
Raw resume bytes are never stored, never logged (A-305 pattern; the
|
||||
existing _scrub_pii_validation handler precedent). The only new
|
||||
dependency is pypdf (probe-verified 6.19.0, pip-reachable via the
|
||||
venv channel).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from ..agents.structured import structured_completion
|
||||
from ..llm.base import LLMProvider
|
||||
from ..llm.types import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Prompt version — bump on any wording change (prompts are code).
|
||||
RESUME_PROMPT_VERSION = 1
|
||||
|
||||
#: Upload bounds (A-410).
|
||||
MAX_RESUME_BYTES = 2 * 1024 * 1024 # 2 MB
|
||||
ALLOWED_CONTENT_TYPES = ("pdf", "txt", "md")
|
||||
#: Extracted-text cap before the LLM call (A-410: ~100K chars).
|
||||
MAX_EXTRACTED_CHARS = 100_000
|
||||
|
||||
RESUME_PROMPT = """You are a profile parser for an AI-era skills
|
||||
platform. Extract structured profile data from the resume text.
|
||||
|
||||
Rules:
|
||||
- name: the person's name as written, or "" if absent.
|
||||
- headline: a one-line professional headline, or "".
|
||||
- skills: up to 20 concrete skills named in the resume (lowercase,
|
||||
the resume's own vocabulary preferred).
|
||||
- experience_bands: list of {role, years} objects — years is a number
|
||||
(round down; 0 if under a year); [] if none determinable.
|
||||
- competencies: up to 10 competency areas the resume evidences, each a
|
||||
short noun phrase; [] if none.
|
||||
|
||||
Respond with ONLY the JSON object."""
|
||||
|
||||
_SCHEMA_HINT = (
|
||||
'{"name": "string", "headline": "string", '
|
||||
'"skills": ["up to 20 lowercase strings"], '
|
||||
'"experience_bands": [{"role": "string", "years": 0}], '
|
||||
'"competencies": ["up to 10 short phrases"]}'
|
||||
)
|
||||
|
||||
|
||||
class ExperienceBand(BaseModel):
|
||||
"""One role/tenure band the resume evidences."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
role: str = ""
|
||||
years: float = 0
|
||||
|
||||
|
||||
class ParsedProfile(BaseModel):
|
||||
"""The D-020 extraction schema mapped to the competency-profile
|
||||
shape (D-054). extra=forbid keeps the honesty contract — a
|
||||
non-matching reply fails validation, never silently all-defaults
|
||||
(the verifier P2-3 lesson)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
name: str = ""
|
||||
headline: str = ""
|
||||
skills: list[str] = Field(default_factory=list, max_length=20)
|
||||
experience_bands: list[ExperienceBand] = Field(default_factory=list)
|
||||
competencies: list[str] = Field(default_factory=list, max_length=10)
|
||||
|
||||
|
||||
class ResumeFormatError(Exception):
|
||||
"""Honest upload rejection (422 at the route): unsupported format,
|
||||
missing text layer (empty PDF extract — no OCR in v0.6), or empty
|
||||
file."""
|
||||
|
||||
|
||||
def extract_resume_text(data: bytes, content_type: str) -> str:
|
||||
"""Extract text from an uploaded resume (A-410 bounds enforced by
|
||||
the route BEFORE this call; this handles the decode/parse).
|
||||
|
||||
PDF via pypdf page-text join; TXT/MD raw decode. An empty PDF
|
||||
extract raises ResumeFormatError("text layer required") — the
|
||||
honest no-OCR contract. Raises ResumeFormatError on garbage text
|
||||
bytes too (the honest 422, never a 500)."""
|
||||
ctype = content_type.strip().lower()
|
||||
# Accept both bare and full content types ("application/pdf").
|
||||
if "pdf" in ctype or ctype == "pdf":
|
||||
from io import BytesIO
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
try:
|
||||
reader = PdfReader(BytesIO(data))
|
||||
pages = [(page.extract_text() or "") for page in reader.pages]
|
||||
except Exception as exc:
|
||||
raise ResumeFormatError(
|
||||
"could not read the PDF — is it a valid PDF file?"
|
||||
) from exc
|
||||
text = "\n".join(pages)
|
||||
if not text.strip():
|
||||
raise ResumeFormatError(
|
||||
"text layer required — image-only/scanned PDFs are not "
|
||||
"supported (no OCR in v0.6); upload a text-based PDF, "
|
||||
"TXT, or MD file"
|
||||
)
|
||||
return text
|
||||
if "markdown" in ctype or ctype in ("txt", "md", "text/plain", "text/markdown"):
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ResumeFormatError(
|
||||
"could not decode the file as UTF-8 text"
|
||||
) from exc
|
||||
if not text.strip():
|
||||
raise ResumeFormatError("file has no text content")
|
||||
return text
|
||||
raise ResumeFormatError(
|
||||
f"unsupported resume format {content_type!r} — PDF, TXT, or MD only"
|
||||
)
|
||||
|
||||
|
||||
async def parse_resume(
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
model: str,
|
||||
text: str,
|
||||
) -> ParsedProfile:
|
||||
"""Draft extraction via D-020 (never persists — the confirm route
|
||||
is the only write path, D-054/REQ-6-005 review gate)."""
|
||||
capped = text[:MAX_EXTRACTED_CHARS]
|
||||
messages = [
|
||||
Message(role="system", content=RESUME_PROMPT),
|
||||
Message(role="user", content=f"Resume text:\n{capped}"),
|
||||
]
|
||||
# Raw resume text never logs (A-305): only the failure reason rides
|
||||
# the warning below — structured_completion raises on its own path.
|
||||
return await structured_completion(
|
||||
provider,
|
||||
messages,
|
||||
model=model,
|
||||
schema=ParsedProfile,
|
||||
schema_hint=_SCHEMA_HINT,
|
||||
)
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Hybrid facet+cosine search over published postings (REQ-6-003, A-405).
|
||||
|
||||
Pure-Python cosine (sum of products / norm product; ≤10⁴ postings —
|
||||
NO numpy, A-403). Hybrid ordering per D-059: facet hard-filters
|
||||
SQL-side on portable columns FIRST, then cosine ranking over the
|
||||
filtered set; no facets → pure semantic rank over all published
|
||||
postings.
|
||||
|
||||
Candidate side: the query text embeds ON DEMAND via the provider and
|
||||
is NEVER stored (A-404 — only posting vectors persist).
|
||||
|
||||
Lazy re-embed (A-403/A-404): a stored row whose model/dim ≠ the
|
||||
provider's current model/dim is re-embedded + re-stored on access, so
|
||||
a provider switch self-heals the corpus row-by-row without a
|
||||
backfill pass.
|
||||
|
||||
skill_breakdown formula (deterministic, documented for the pin):
|
||||
- overlap = does the skill string (normalized: lowercase, split on
|
||||
non-alphanumeric, rejoined) share a token with the QUERY's normalized
|
||||
token set → overlap ∈ {0, 1}
|
||||
- contribution = the share of the posting-vector dot-product carried by
|
||||
the skill's own feature-hash tokens: for the mock provider,
|
||||
contribution(skill) = Σ_components of mock.embed_text(skill) mapped
|
||||
to the same (index, sign) bucket in the mock formula — computed as
|
||||
cosine(embed_text(skill), posting_vector) when overlap=1, else 0.0.
|
||||
Both operands are mock-formula vectors, so the number is
|
||||
deterministic and provider-independent to pin (with the openai-embed
|
||||
provider the contribution uses the SAME skill-text side via the
|
||||
provider on demand; the mock pin is the MH-3f pin).
|
||||
|
||||
Boundary (D-048): never imports `agents/` or `api/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .base import Posting
|
||||
from .embedding.base import EmbeddingProvider, EmbeddingResult
|
||||
from .embedding.mock import embed_text as _mock_embed_text
|
||||
from .embedding.mock import normalize_tokens as _normalize
|
||||
from .store import MarketplaceStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FacetFilters:
|
||||
"""Hard filters applied SQL-side BEFORE ranking (A-405). Any facet
|
||||
may be empty — an empty filter set means pure semantic rank."""
|
||||
|
||||
skills: list[str] = field(default_factory=list)
|
||||
seniority: str | None = None
|
||||
category: str | None = None
|
||||
location: str | None = None
|
||||
remote: bool | None = None
|
||||
salary_min: int | None = None
|
||||
salary_max: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RankedPosting:
|
||||
"""One ranked search result. `match_score` is the computed cosine
|
||||
(G-106: computed scores exist ONLY in search context)."""
|
||||
|
||||
posting: Posting
|
||||
score: float
|
||||
skill_breakdown: list[dict]
|
||||
|
||||
|
||||
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
"""Pure-Python cosine: sum(a·b) / (‖a‖·‖b‖); 0.0 on a zero vector
|
||||
(guard: an unrankable vector scores an honest zero, never a
|
||||
ZeroDivisionError) or a length mismatch (defensive — the store
|
||||
codec enforces dim parity)."""
|
||||
if not a or not b or len(a) != len(b):
|
||||
return 0.0
|
||||
dot = 0.0
|
||||
norm_a = 0.0
|
||||
norm_b = 0.0
|
||||
for x, y in zip(a, b, strict=False):
|
||||
dot += x * y
|
||||
norm_a += x * x
|
||||
norm_b += y * y
|
||||
if norm_a <= 0.0 or norm_b <= 0.0:
|
||||
return 0.0
|
||||
return dot / math.sqrt(norm_a * norm_b)
|
||||
|
||||
|
||||
def posting_embedding_text(posting: Posting) -> str:
|
||||
"""D-051 posting text: title + description + tag skills joined —
|
||||
the P2 tags ride the vector so facets and semantics agree."""
|
||||
parts = [posting.title, posting.description or ""]
|
||||
if posting.tags and posting.tags.skills:
|
||||
parts.extend(posting.tags.skills)
|
||||
return "\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
async def embed_posting(
|
||||
store: MarketplaceStore, provider: EmbeddingProvider, posting: Posting
|
||||
) -> bool:
|
||||
"""Embed-on-publish hook (Task 3-2-01): embed the posting text and
|
||||
store the vector. Failure is HONEST, never fatal: returns False with
|
||||
a logged warning — the posting still publishes, search skips it
|
||||
(no stored vector = honest absence, never a fabricated direction)."""
|
||||
try:
|
||||
results = await provider.embed([posting_embedding_text(posting)])
|
||||
result = results[0]
|
||||
store.put_vector(posting.id, result.values, result.model, result.dim)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"marketplace embedding failed for posting %r: %s — posting stays "
|
||||
"published WITHOUT a vector (search skips it, honest absence)",
|
||||
posting.id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _current_embedding(
|
||||
provider: EmbeddingProvider, text: str
|
||||
) -> EmbeddingResult:
|
||||
"""Embed one text via the provider (candidate side: on demand,
|
||||
never stored)."""
|
||||
results = await provider.embed([text])
|
||||
return results[0]
|
||||
|
||||
|
||||
async def rank_postings(
|
||||
store: MarketplaceStore,
|
||||
provider: EmbeddingProvider,
|
||||
query_text: str,
|
||||
facet_filters: FacetFilters | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[RankedPosting]:
|
||||
"""Hybrid rank (A-405): facets hard-filter SQL-side first, then
|
||||
cosine ranking DESC over the filtered set.
|
||||
|
||||
- empty query → facet-only listing ordered by posted_at DESC (no
|
||||
query → no match scores — G-106 honesty; scores are 0.0 and the
|
||||
route reports match_score: null).
|
||||
- postings whose stored model/dim ≠ the provider's are re-embedded
|
||||
+ re-stored lazily (A-403/A-404).
|
||||
- postings with NO stored vector are SKIPPED (honest absence — an
|
||||
embed-on-publish failure must not fabricate a direction).
|
||||
"""
|
||||
filters = facet_filters or FacetFilters()
|
||||
candidates = store.search_candidates(
|
||||
skills=filters.skills or None,
|
||||
seniority=filters.seniority,
|
||||
category=filters.category,
|
||||
location=filters.location,
|
||||
remote=filters.remote,
|
||||
salary_min=filters.salary_min,
|
||||
salary_max=filters.salary_max,
|
||||
)
|
||||
if not query_text.strip():
|
||||
# Facet-only listing: posted_at DESC (the list_postings order),
|
||||
# no match scores — the route carries match_score: null.
|
||||
return [
|
||||
RankedPosting(posting=p, score=0.0, skill_breakdown=[])
|
||||
for p in candidates[:limit]
|
||||
]
|
||||
|
||||
query_result = await _current_embedding(provider, query_text)
|
||||
q_values = query_result.values
|
||||
q_tokens = set(_normalize(query_text))
|
||||
q_model = query_result.model
|
||||
q_dim = query_result.dim
|
||||
|
||||
# Lazy re-embed: collect stale rows (model/dim ≠ provider's) and
|
||||
# refresh them in one batch before ranking.
|
||||
stale: list[Posting] = [
|
||||
p for p in candidates if _is_stale(store, p.id, q_model, q_dim)
|
||||
]
|
||||
if stale:
|
||||
texts = [posting_embedding_text(p) for p in stale]
|
||||
try:
|
||||
refreshed = await provider.embed(texts)
|
||||
for posting, result in zip(stale, refreshed, strict=True):
|
||||
store.put_vector(posting.id, result.values, result.model, result.dim)
|
||||
except Exception as exc: # honest: stale rows keep old vectors
|
||||
logger.warning(
|
||||
"lazy re-embed of %s postings failed (%s) — ranking against "
|
||||
"existing vectors",
|
||||
len(stale),
|
||||
exc,
|
||||
)
|
||||
|
||||
ranked: list[RankedPosting] = []
|
||||
for posting in candidates:
|
||||
stored = store.get_vector(posting.id)
|
||||
if stored is None:
|
||||
continue # embed-on-publish failed → honest skip
|
||||
values, model, dim = stored
|
||||
if (model, dim) != (q_model, q_dim):
|
||||
continue # re-embed failed above → incomparable, honest skip
|
||||
score = cosine_similarity(q_values, values)
|
||||
ranked.append(
|
||||
RankedPosting(
|
||||
posting=posting,
|
||||
score=score,
|
||||
skill_breakdown=skill_breakdown(posting, q_tokens, values),
|
||||
)
|
||||
)
|
||||
ranked.sort(key=lambda r: (-r.score, r.posting.id))
|
||||
return ranked[:limit]
|
||||
|
||||
|
||||
def _is_stale(
|
||||
store: MarketplaceStore, posting_id: str, model: str, dim: int
|
||||
) -> bool:
|
||||
stored = store.get_vector(posting_id)
|
||||
if stored is None:
|
||||
return False # absent ≠ stale (no direction to refresh)
|
||||
_, row_model, row_dim = stored
|
||||
return (row_model, row_dim) != (model, dim)
|
||||
|
||||
|
||||
def skill_breakdown(
|
||||
posting: Posting, query_tokens: set[str], posting_vector: list[float]
|
||||
) -> list[dict]:
|
||||
"""Per-skill explanation (MH-3f): tag-skill overlap with the query
|
||||
+ the skill's vector contribution — both deterministic.
|
||||
|
||||
overlap: 1 when any normalized token of the skill string appears
|
||||
in the query's normalized token set, else 0.
|
||||
contribution: cosine(embed_text(skill), posting_vector) when
|
||||
overlap=1 — the mock feature-hash formula's share of the posting
|
||||
dot-product that the skill's own tokens carry; 0.0 otherwise.
|
||||
Sorted by contribution DESC then skill ASC (deterministic order).
|
||||
"""
|
||||
skills: list[str] = list(posting.tags.skills) if posting.tags else []
|
||||
breakdown: list[dict] = []
|
||||
for skill in skills:
|
||||
tokens = _normalize(skill)
|
||||
overlap = 1 if any(t in query_tokens for t in tokens) else 0
|
||||
contribution = 0.0
|
||||
if overlap:
|
||||
contribution = cosine_similarity(
|
||||
_mock_embed_text(skill), posting_vector
|
||||
)
|
||||
breakdown.append(
|
||||
{"skill": skill, "overlap": overlap, "contribution": contribution}
|
||||
)
|
||||
breakdown.sort(key=lambda e: (-e["contribution"], e["skill"]))
|
||||
return breakdown
|
||||
@@ -0,0 +1,695 @@
|
||||
{
|
||||
"employers": [
|
||||
{
|
||||
"id": "emp-openai",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-openai",
|
||||
"name": "OpenAI Labs",
|
||||
"logo": "https://placehold.co/80x80/indigo/white?text=OA",
|
||||
"description": "Mock employer building frontier AI systems. We build and deploy large language models and agentic tools used by millions of developers and consumers.",
|
||||
"industry": "Artificial Intelligence",
|
||||
"size": "2,000+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/openai",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/openai/x",
|
||||
"linkedin": "https://example.com/openai/li"
|
||||
},
|
||||
"culture": "Research-driven, shipping-paced, safety-conscious. We pair frontier research with product rigor."
|
||||
},
|
||||
{
|
||||
"id": "emp-anthropic",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-anthropic",
|
||||
"name": "Anthropic Research",
|
||||
"logo": "https://placehold.co/80x80/amber/white?text=AN",
|
||||
"description": "Mock employer focused on AI safety and alignment. We build reliable, interpretable, and steerable AI systems.",
|
||||
"industry": "AI Safety",
|
||||
"size": "1,000+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/anthropic",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/anthropic/x",
|
||||
"linkedin": "https://example.com/anthropic/li"
|
||||
},
|
||||
"culture": "Safety-first, evidence-based, calm-paced. We value thoroughness over speed when stakes are high."
|
||||
},
|
||||
{
|
||||
"id": "emp-huggingface",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-huggingface",
|
||||
"name": "Hugging Face",
|
||||
"logo": "https://placehold.co/80x80/yellow/black?text=HF",
|
||||
"description": "Mock employer building the open-source AI community. We host models, datasets, and demos for the global ML community.",
|
||||
"industry": "Open Source AI",
|
||||
"size": "500+",
|
||||
"location": "New York, NY",
|
||||
"website": "https://example.com/hf",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/hf/x",
|
||||
"linkedin": "https://example.com/hf/li"
|
||||
},
|
||||
"culture": "Community-first, open-by-default, remote-friendly. We ship in the open with thousands of contributors."
|
||||
},
|
||||
{
|
||||
"id": "emp-scaleai",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-scaleai",
|
||||
"name": "Scale AI",
|
||||
"logo": "https://placehold.co/80x80/violet/white?text=SC",
|
||||
"description": "Mock employer providing data and evaluation infrastructure for frontier AI. We power the RLHF and eval pipelines behind many model releases.",
|
||||
"industry": "AI Infrastructure",
|
||||
"size": "1,500+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/scale",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/scale/x",
|
||||
"linkedin": "https://example.com/scale/li"
|
||||
},
|
||||
"culture": "Infrastructure-minded, quality-obsessed, enterprise-aware. We bridge research and production data."
|
||||
},
|
||||
{
|
||||
"id": "emp-perplexity",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-perplexity",
|
||||
"name": "Perplexity",
|
||||
"logo": "https://placehold.co/80x80/teal/white?text=PX",
|
||||
"description": "Mock employer building an AI-powered answer engine. We combine retrieval, generation, and citations for trustworthy answers.",
|
||||
"industry": "AI Search",
|
||||
"size": "300+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/perplexity",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/perplexity/x",
|
||||
"linkedin": "https://example.com/perplexity/li"
|
||||
},
|
||||
"culture": "Answer-focused, fast-iterating, citation-proud. We treat groundedness as a product feature."
|
||||
},
|
||||
{
|
||||
"id": "emp-cohere",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-cohere",
|
||||
"name": "Cohere",
|
||||
"logo": "https://placehold.co/80x80/pink/white?text=CO",
|
||||
"description": "Mock employer building enterprise-grade language models. We specialize in retrieval, multilinguality, and data privacy for regulated industries.",
|
||||
"industry": "Enterprise LLMs",
|
||||
"size": "400+",
|
||||
"location": "Toronto, Canada",
|
||||
"website": "https://example.com/cohere",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/cohere/x",
|
||||
"linkedin": "https://example.com/cohere/li"
|
||||
},
|
||||
"culture": "Enterprise-aware, research-active, multilingual. We ship models that work in 100+ languages."
|
||||
},
|
||||
{
|
||||
"id": "emp-mistral",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-mistral",
|
||||
"name": "Mistral AI",
|
||||
"logo": "https://placehold.co/80x80/red/white?text=MI",
|
||||
"description": "Mock employer building open-weight frontier models in Europe. We push efficiency and openness in large language models.",
|
||||
"industry": "Open-Weight LLMs",
|
||||
"size": "200+",
|
||||
"location": "Paris, France",
|
||||
"website": "https://example.com/mistral",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/mistral/x",
|
||||
"linkedin": "https://example.com/mistral/li"
|
||||
},
|
||||
"culture": "European-rooted, efficiency-driven, open-weight-proud. We ship small models that punch above their size."
|
||||
},
|
||||
{
|
||||
"id": "emp-replicate",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-replicate",
|
||||
"name": "Replicate",
|
||||
"logo": "https://placehold.co/80x80/orange/white?text=RE",
|
||||
"description": "Mock employer making ML deployment delightful. We host, serve, and scale models with a few lines of code.",
|
||||
"industry": "ML Deployment",
|
||||
"size": "150+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/replicate",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/replicate/x",
|
||||
"linkedin": "https://example.com/replicate/li"
|
||||
},
|
||||
"culture": "Developer-experience-obsessed, pragmatic, remote-friendly. We make shipping models feel like shipping software."
|
||||
},
|
||||
{
|
||||
"id": "emp-pinecone",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-pinecone",
|
||||
"name": "Pinecone",
|
||||
"logo": "https://placehold.co/80x80/green/white?text=PC",
|
||||
"description": "Mock employer building the vector database for AI applications. We power retrieval for thousands of production RAG systems.",
|
||||
"industry": "Vector Databases",
|
||||
"size": "300+",
|
||||
"location": "New York, NY",
|
||||
"website": "https://example.com/pinecone",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/pinecone/x",
|
||||
"linkedin": "https://example.com/pinecone/li"
|
||||
},
|
||||
"culture": "Infra-focused, latency-obsessed, distributed-first. We treat retrieval as the heart of grounded AI."
|
||||
},
|
||||
{
|
||||
"id": "emp-langchain",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-langchain",
|
||||
"name": "LangChain",
|
||||
"logo": "https://placehold.co/80x80/blue/white?text=LC",
|
||||
"description": "Mock employer building the orchestration layer for LLM applications. We provide frameworks for agents, retrieval, and evaluation.",
|
||||
"industry": "AI Orchestration",
|
||||
"size": "200+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/langchain",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/langchain/x",
|
||||
"linkedin": "https://example.com/langchain/li"
|
||||
},
|
||||
"culture": "Framework-minded, community-driven, abstractions-first. We build primitives others compose into products."
|
||||
}
|
||||
],
|
||||
"postings": [
|
||||
{
|
||||
"id": "job-001",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-001",
|
||||
"employer_id": "emp-langchain",
|
||||
"title": "AI Orchestration Engineer",
|
||||
"description": "Design and operate multi-agent systems that automate complex knowledge workflows. You will own agent topology, tool integration, and evaluation harnesses for production deployments serving millions of queries.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c001",
|
||||
"stack-orchestration-c002",
|
||||
"stack-orchestration-c005"
|
||||
],
|
||||
"skills": [
|
||||
"LangGraph",
|
||||
"Multi-agent systems",
|
||||
"RAG",
|
||||
"Python",
|
||||
"Evaluation"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": true,
|
||||
"salary_min": 160000,
|
||||
"salary_max": 240000,
|
||||
"posted_at": "2026-08-21T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-002",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-002",
|
||||
"employer_id": "emp-openai",
|
||||
"title": "LLM Application Developer",
|
||||
"description": "Build delightful LLM-powered features end-to-end. You will work on retrieval, function calling, streaming UX, and robust output validation for consumer-facing products.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c003",
|
||||
"stack-orchestration-c004",
|
||||
"stack-orchestration-c005"
|
||||
],
|
||||
"skills": [
|
||||
"Python",
|
||||
"TypeScript",
|
||||
"RAG",
|
||||
"Function calling",
|
||||
"Streaming"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": true,
|
||||
"salary_min": 140000,
|
||||
"salary_max": 210000,
|
||||
"posted_at": "2026-08-28T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-003",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-003",
|
||||
"employer_id": "emp-anthropic",
|
||||
"title": "AI Safety Researcher",
|
||||
"description": "Research alignment, interpretability, and robustness of frontier models. Design experiments, run evaluations, and publish findings that improve the safety of deployed AI systems.",
|
||||
"required_competencies": [
|
||||
"stack-safety-c001",
|
||||
"stack-safety-c007",
|
||||
"stack-safety-c002"
|
||||
],
|
||||
"skills": [
|
||||
"Alignment",
|
||||
"Interpretability",
|
||||
"Red teaming",
|
||||
"Python",
|
||||
"Research"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": false,
|
||||
"salary_min": 180000,
|
||||
"salary_max": 280000,
|
||||
"posted_at": "2026-08-15T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-004",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-004",
|
||||
"employer_id": "emp-perplexity",
|
||||
"title": "Prompt Engineer",
|
||||
"description": "Craft, test, and ship prompt strategies that power answer-engine features. Own prompt libraries, regression suites, and A/B experiments across product surfaces.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c004",
|
||||
"stack-orchestration-c001"
|
||||
],
|
||||
"skills": [
|
||||
"Prompt engineering",
|
||||
"Evaluation",
|
||||
"A/B testing",
|
||||
"Python"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Remote",
|
||||
"remote": true,
|
||||
"salary_min": 110000,
|
||||
"salary_max": 175000,
|
||||
"posted_at": "2026-09-02T14:15:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-005",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-005",
|
||||
"employer_id": "emp-huggingface",
|
||||
"title": "AI Product Manager",
|
||||
"description": "Own the roadmap for AI-powered developer tools. Translate model capabilities into customer value, define success metrics, and ship agentic features with engineering.",
|
||||
"required_competencies": [
|
||||
"stack-designer-c001",
|
||||
"stack-orchestration-c001"
|
||||
],
|
||||
"skills": [
|
||||
"Product strategy",
|
||||
"AI UX",
|
||||
"Roadmapping",
|
||||
"Stakeholder mgmt"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "New York, NY",
|
||||
"remote": true,
|
||||
"salary_min": 150000,
|
||||
"salary_max": 220000,
|
||||
"posted_at": "2026-08-30T08:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-006",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-006",
|
||||
"employer_id": "emp-pinecone",
|
||||
"title": "RAG Infrastructure Engineer",
|
||||
"description": "Build the retrieval substrate behind knowledge-grounded AI. Optimize indexing, query latency, and hybrid retrieval across billion-vector workloads.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c005",
|
||||
"stack-orchestration-c006"
|
||||
],
|
||||
"skills": [
|
||||
"Vector databases",
|
||||
"Embeddings",
|
||||
"Go",
|
||||
"Distributed systems"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Remote",
|
||||
"remote": true,
|
||||
"salary_min": 130000,
|
||||
"salary_max": 200000,
|
||||
"posted_at": "2026-09-05T11:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-007",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-007",
|
||||
"employer_id": "emp-scaleai",
|
||||
"title": "AI Governance Lead",
|
||||
"description": "Stand up the AI governance program for enterprise customers. Map controls to NIST AI RMF and EU AI Act, run audits, and author model cards at scale.",
|
||||
"required_competencies": [
|
||||
"stack-safety-c005",
|
||||
"stack-safety-c003",
|
||||
"stack-safety-c013"
|
||||
],
|
||||
"skills": [
|
||||
"AI policy",
|
||||
"NIST AI RMF",
|
||||
"Audit",
|
||||
"Documentation"
|
||||
],
|
||||
"seniority": "staff",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": true,
|
||||
"salary_min": 170000,
|
||||
"salary_max": 230000,
|
||||
"posted_at": "2026-08-18T16:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-008",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-008",
|
||||
"employer_id": "emp-anthropic",
|
||||
"title": "Human-AI Interaction Designer",
|
||||
"description": "Design how users collaborate with Claude across products. Own transparency patterns, trust calibration, and agentic interaction for assistant surfaces.",
|
||||
"required_competencies": [
|
||||
"stack-designer-c001",
|
||||
"stack-designer-c003",
|
||||
"stack-designer-c008"
|
||||
],
|
||||
"skills": [
|
||||
"Figma",
|
||||
"AI UX",
|
||||
"Prototyping",
|
||||
"User research"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": true,
|
||||
"salary_min": 125000,
|
||||
"salary_max": 190000,
|
||||
"posted_at": "2026-08-25T10:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-009",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-009",
|
||||
"employer_id": "emp-cohere",
|
||||
"title": "Evaluation Engineer",
|
||||
"description": "Build and operate the eval platform for LLM-powered products. Design LLM-as-judge pipelines, regression suites, and human-eval panels for production models.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c007",
|
||||
"stack-safety-c002"
|
||||
],
|
||||
"skills": [
|
||||
"LLM evaluation",
|
||||
"Python",
|
||||
"Statistics",
|
||||
"Data labeling"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Toronto, Canada",
|
||||
"remote": true,
|
||||
"salary_min": 120000,
|
||||
"salary_max": 180000,
|
||||
"posted_at": "2026-09-01T13:20:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-010",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-010",
|
||||
"employer_id": "emp-replicate",
|
||||
"title": "Robotics Operations Specialist",
|
||||
"description": "Supervise a fleet of AI-augmented robotic units in a warehouse environment. Interpret anomaly alerts, perform calibrations, and intervene on exceptions.",
|
||||
"required_competencies": [
|
||||
"stack-operator-c002",
|
||||
"stack-operator-c011",
|
||||
"stack-operator-c003"
|
||||
],
|
||||
"skills": [
|
||||
"Robotics",
|
||||
"Safety protocols",
|
||||
"IoT",
|
||||
"Predictive maintenance"
|
||||
],
|
||||
"seniority": "entry",
|
||||
"category": null,
|
||||
"location": "Austin, TX",
|
||||
"remote": false,
|
||||
"salary_min": 80000,
|
||||
"salary_max": 115000,
|
||||
"posted_at": "2026-08-22T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-011",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-011",
|
||||
"employer_id": "emp-recursion",
|
||||
"title": "Computational Biologist",
|
||||
"description": "Apply ML to drug discovery. Build pipelines for phenotype prediction, active learning on assay data, and molecular generation for novel targets.",
|
||||
"required_competencies": [
|
||||
"stack-science-c005",
|
||||
"stack-science-c002",
|
||||
"stack-science-c011"
|
||||
],
|
||||
"skills": [
|
||||
"Bioinformatics",
|
||||
"Python",
|
||||
"PyTorch",
|
||||
"Drug discovery"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "Boston, MA",
|
||||
"remote": true,
|
||||
"salary_min": 145000,
|
||||
"salary_max": 215000,
|
||||
"posted_at": "2026-08-12T15:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-012",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-012",
|
||||
"employer_id": "emp-deepmind",
|
||||
"title": "Climate ML Scientist",
|
||||
"description": "Develop ML models for climate forecasting and energy grid optimization. Work with earth system scientists to downscale GCM output and quantify uncertainty.",
|
||||
"required_competencies": [
|
||||
"stack-science-c004",
|
||||
"stack-science-c014",
|
||||
"stack-science-c010"
|
||||
],
|
||||
"skills": [
|
||||
"Climate modeling",
|
||||
"PyTorch",
|
||||
"Data assimilation",
|
||||
"PINNs"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "London, UK",
|
||||
"remote": true,
|
||||
"salary_min": 135000,
|
||||
"salary_max": 200000,
|
||||
"posted_at": "2026-08-27T11:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-013",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-013",
|
||||
"employer_id": "emp-langchain",
|
||||
"title": "Agent Reliability Engineer",
|
||||
"description": "Own observability and reliability for production agent workloads. Build tracing, alerting, and rollback systems for multi-step agent pipelines.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c013",
|
||||
"stack-orchestration-c015",
|
||||
"stack-orchestration-c008"
|
||||
],
|
||||
"skills": [
|
||||
"Observability",
|
||||
"Python",
|
||||
"SRE",
|
||||
"Distributed tracing"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Remote",
|
||||
"remote": true,
|
||||
"salary_min": 140000,
|
||||
"salary_max": 205000,
|
||||
"posted_at": "2026-09-04T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-014",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-014",
|
||||
"employer_id": "emp-mistral",
|
||||
"title": "AI Red Team Lead",
|
||||
"description": "Lead adversarial testing of frontier and open-weight models. Build automated red-team suites, track vulnerabilities, and coordinate disclosure.",
|
||||
"required_competencies": [
|
||||
"stack-safety-c002",
|
||||
"stack-safety-c010",
|
||||
"stack-safety-c001"
|
||||
],
|
||||
"skills": [
|
||||
"Red teaming",
|
||||
"Prompt injection",
|
||||
"Python",
|
||||
"Leadership"
|
||||
],
|
||||
"seniority": "staff",
|
||||
"category": null,
|
||||
"location": "Paris, France",
|
||||
"remote": true,
|
||||
"salary_min": 160000,
|
||||
"salary_max": 230000,
|
||||
"posted_at": "2026-08-19T10:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-015",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-015",
|
||||
"employer_id": "emp-huggingface",
|
||||
"title": "Conversation Designer",
|
||||
"description": "Design dialogue flows, personas, and repair strategies for AI assistants across open-source products. Partner with ML engineers to align tone with model behavior.",
|
||||
"required_competencies": [
|
||||
"stack-designer-c002",
|
||||
"stack-designer-c010",
|
||||
"stack-designer-c011"
|
||||
],
|
||||
"skills": [
|
||||
"Conversation design",
|
||||
"Figma",
|
||||
"Voice UX",
|
||||
"Prototyping"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Remote",
|
||||
"remote": true,
|
||||
"salary_min": 105000,
|
||||
"salary_max": 160000,
|
||||
"posted_at": "2026-08-29T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-016",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-016",
|
||||
"employer_id": "emp-anduril",
|
||||
"title": "Field AI Technician",
|
||||
"description": "Deploy and maintain AI vision systems on defense hardware in the field. Calibrate sensors, interpret model alerts, and escalate edge cases to engineering.",
|
||||
"required_competencies": [
|
||||
"stack-operator-c004",
|
||||
"stack-operator-c009",
|
||||
"stack-operator-c001"
|
||||
],
|
||||
"skills": [
|
||||
"Computer vision",
|
||||
"Sensor calibration",
|
||||
"Field ops",
|
||||
"Python"
|
||||
],
|
||||
"seniority": "entry",
|
||||
"category": null,
|
||||
"location": "Costa Mesa, CA",
|
||||
"remote": false,
|
||||
"salary_min": 90000,
|
||||
"salary_max": 130000,
|
||||
"posted_at": "2026-08-14T08:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-017",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-017",
|
||||
"employer_id": "emp-deepmind",
|
||||
"title": "Materials ML Engineer",
|
||||
"description": "Discover novel materials with ML. Train property-prediction models, run active-learning loops over DFT calculations, and validate candidates experimentally.",
|
||||
"required_competencies": [
|
||||
"stack-science-c003",
|
||||
"stack-science-c002",
|
||||
"stack-science-c006"
|
||||
],
|
||||
"skills": [
|
||||
"Materials science",
|
||||
"PyTorch",
|
||||
"DFT",
|
||||
"Active learning"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "London, UK",
|
||||
"remote": true,
|
||||
"salary_min": 140000,
|
||||
"salary_max": 210000,
|
||||
"posted_at": "2026-08-26T12:15:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-018",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-018",
|
||||
"employer_id": "emp-openai",
|
||||
"title": "AI Trust & Safety Analyst",
|
||||
"description": "Investigate misuse patterns, triage safety incidents, and improve policy enforcement for consumer AI products. Author postmortems and recommend mitigations.",
|
||||
"required_competencies": [
|
||||
"stack-safety-c008",
|
||||
"stack-safety-c006",
|
||||
"stack-safety-c002"
|
||||
],
|
||||
"skills": [
|
||||
"Trust & safety",
|
||||
"Incident response",
|
||||
"Policy",
|
||||
"Investigation"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": true,
|
||||
"salary_min": 115000,
|
||||
"salary_max": 170000,
|
||||
"posted_at": "2026-09-03T10:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-019",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-019",
|
||||
"employer_id": "emp-replicate",
|
||||
"title": "Edge AI Engineer",
|
||||
"description": "Optimize and deploy models to edge hardware. Quantize, distill, and compile models for low-latency inference on field devices with constrained budgets.",
|
||||
"required_competencies": [
|
||||
"stack-operator-c008",
|
||||
"stack-orchestration-c012",
|
||||
"stack-orchestration-c014"
|
||||
],
|
||||
"skills": [
|
||||
"Edge ML",
|
||||
"TensorRT",
|
||||
"C++",
|
||||
"Quantization"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Remote",
|
||||
"remote": true,
|
||||
"salary_min": 130000,
|
||||
"salary_max": 195000,
|
||||
"posted_at": "2026-08-24T11:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-020",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-020",
|
||||
"employer_id": "emp-cohere",
|
||||
"title": "AI Research Engineer",
|
||||
"description": "Push the frontier of language model capabilities. Prototype new architectures, run large-scale experiments, and contribute to publications and open-source releases.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c001",
|
||||
"stack-science-c010",
|
||||
"stack-science-c011"
|
||||
],
|
||||
"skills": [
|
||||
"PyTorch",
|
||||
"Research",
|
||||
"Transformers",
|
||||
"Distributed training"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "Berlin, Germany",
|
||||
"remote": true,
|
||||
"salary_min": 155000,
|
||||
"salary_max": 235000,
|
||||
"posted_at": "2026-08-20T09:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
"""MarketplaceStore — the 6th D-027 store family (D-049, amended G-104).
|
||||
|
||||
Six tables on the shared `Settings().db_path` (one SQLite file with the
|
||||
five prior stores): `posting`, `employer`, `embedding`, `application`,
|
||||
`employer_enrichment`, `learner_profile`. Mechanics mirror the
|
||||
probe-verified identity/store.py exactly: WAL / synchronous=NORMAL /
|
||||
busy_timeout / foreign_keys pragmas at connect time, `sa_type` portable
|
||||
columns (String/JSON for Postgres parity), `_validate()`/`_normalize()`
|
||||
hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz-aware →
|
||||
naive → tz-aware boundary normalization.
|
||||
|
||||
P1 scope: posting/employer/application methods + the lifecycle
|
||||
transitions (draft→published→closed, invalid → ValueError). The
|
||||
`embedding`, `employer_enrichment`, and `learner_profile` tables are
|
||||
CREATED here (schema-only) — their methods land in P3/P4 (D-051 codec,
|
||||
D-053 append-only rows, G-104 confirmed profiles).
|
||||
|
||||
Boundary (D-027/D-048): `marketplace/` never imports `agents/`/`api/`;
|
||||
this module imports config + base only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import UniqueConstraint, event, text
|
||||
from sqlalchemy.types import JSON, String
|
||||
from sqlmodel import Field, Session, SQLModel, create_engine
|
||||
|
||||
from ..config import Settings
|
||||
from .base import (
|
||||
VALID_TRANSITIONS,
|
||||
Application,
|
||||
Employer,
|
||||
IngestPosting,
|
||||
Posting,
|
||||
PostingStatus,
|
||||
PostingTags,
|
||||
)
|
||||
|
||||
_TABLES = (
|
||||
"posting",
|
||||
"employer",
|
||||
"embedding",
|
||||
"application",
|
||||
"employer_enrichment",
|
||||
"learner_profile",
|
||||
)
|
||||
|
||||
_VALID_STATUS = ("draft", "published", "closed")
|
||||
|
||||
|
||||
class PostingRow(SQLModel, table=True):
|
||||
"""One marketplace posting (D-049). Unique (source, external_id) —
|
||||
the idempotent ingest upsert key (A-402) — declared via
|
||||
UniqueConstraint in __table_args__ (sqlmodel metaclass drops bare
|
||||
Literal/unique annotations; the DefenseStore/IdentityStore pattern
|
||||
keeps constraints explicit)."""
|
||||
|
||||
__tablename__ = "posting"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "external_id", name="uq_posting_source_external"),
|
||||
)
|
||||
|
||||
id: str = Field(primary_key=True)
|
||||
source: str = Field(sa_type=String)
|
||||
external_id: str = Field(sa_type=String)
|
||||
employer_id: str = Field(index=True, sa_type=String)
|
||||
title: str = Field(sa_type=String)
|
||||
description: str = Field(default="", sa_type=String)
|
||||
required_competencies: list[str] = Field(default_factory=list, sa_type=JSON)
|
||||
skills: list[str] = Field(default_factory=list, sa_type=JSON)
|
||||
seniority: str | None = Field(default=None, sa_type=String)
|
||||
category: str | None = Field(default=None, sa_type=String)
|
||||
location: str = Field(default="", sa_type=String)
|
||||
remote: bool = Field(default=False)
|
||||
salary_min: int | None = Field(default=None)
|
||||
salary_max: int | None = Field(default=None)
|
||||
#: Bare Literal annotations crash sqlmodel's column inference; explicit
|
||||
#: sa_type + the validates hook give the same contract (DefenseStore /
|
||||
#: IdentityStore pattern).
|
||||
status: str = Field(default="draft", sa_type=String)
|
||||
#: P2-derived tags (D-052); JSON column exists from day one, empty
|
||||
#: until the tagger runs. `None` = untagged.
|
||||
tags: dict[str, Any] | None = Field(default=None, sa_type=JSON)
|
||||
posted_at: datetime | None = Field(default=None)
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not self.id or not self.source or not self.external_id:
|
||||
raise ValueError("id, source, and external_id must be non-empty")
|
||||
if not self.employer_id or not self.title:
|
||||
raise ValueError("employer_id and title must be non-empty")
|
||||
if self.status not in _VALID_STATUS:
|
||||
raise ValueError(f"invalid posting status {self.status!r}")
|
||||
if self.seniority is not None and self.seniority not in (
|
||||
"entry",
|
||||
"mid",
|
||||
"senior",
|
||||
"staff",
|
||||
"principal",
|
||||
):
|
||||
raise ValueError(f"invalid seniority {self.seniority!r}")
|
||||
if self.salary_min is not None and self.salary_min < 0:
|
||||
raise ValueError("salary_min must be non-negative")
|
||||
if self.salary_max is not None and self.salary_max < 0:
|
||||
raise ValueError("salary_max must be non-negative")
|
||||
|
||||
def _normalize(self) -> None:
|
||||
self.posted_at = _as_utc(self.posted_at) if self.posted_at else None
|
||||
|
||||
def to_posting(self) -> Posting:
|
||||
return Posting(
|
||||
id=self.id,
|
||||
source=self.source,
|
||||
external_id=self.external_id,
|
||||
employer_id=self.employer_id,
|
||||
title=self.title,
|
||||
description=self.description,
|
||||
required_competencies=list(self.required_competencies or []),
|
||||
skills=list(self.skills or []),
|
||||
seniority=self.seniority, # type: ignore[arg-type]
|
||||
category=self.category,
|
||||
location=self.location,
|
||||
remote=bool(self.remote),
|
||||
salary_min=self.salary_min,
|
||||
salary_max=self.salary_max,
|
||||
status=self.status, # type: ignore[arg-type]
|
||||
tags=self.tags, # type: ignore[arg-type]
|
||||
posted_at=_as_utc(self.posted_at) if self.posted_at else None,
|
||||
)
|
||||
|
||||
|
||||
class EmployerRow(SQLModel, table=True):
|
||||
"""Raw ingested employer fields (D-049). Unique (source, external_id);
|
||||
P4's `employer_enrichment` merges on read with provenance (D-053)."""
|
||||
|
||||
__tablename__ = "employer"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "external_id", name="uq_employer_source_external"),
|
||||
)
|
||||
|
||||
id: str = Field(primary_key=True)
|
||||
source: str = Field(sa_type=String)
|
||||
external_id: str = Field(sa_type=String)
|
||||
name: str = Field(sa_type=String)
|
||||
logo: str = Field(default="", sa_type=String)
|
||||
description: str = Field(default="", sa_type=String)
|
||||
industry: str = Field(default="", sa_type=String)
|
||||
size: str = Field(default="", sa_type=String)
|
||||
location: str = Field(default="", sa_type=String)
|
||||
website: str = Field(default="", sa_type=String)
|
||||
social_links: dict[str, str] = Field(default_factory=dict, sa_type=JSON)
|
||||
culture: str = Field(default="", sa_type=String)
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not self.id or not self.source or not self.external_id:
|
||||
raise ValueError("id, source, and external_id must be non-empty")
|
||||
if not self.name:
|
||||
raise ValueError("name must be non-empty")
|
||||
|
||||
def to_employer(self) -> Employer:
|
||||
return Employer(
|
||||
id=self.id,
|
||||
source=self.source,
|
||||
external_id=self.external_id,
|
||||
name=self.name,
|
||||
logo=self.logo,
|
||||
description=self.description,
|
||||
industry=self.industry,
|
||||
size=self.size,
|
||||
location=self.location,
|
||||
website=self.website,
|
||||
social_links=dict(self.social_links or {}),
|
||||
culture=self.culture,
|
||||
)
|
||||
|
||||
|
||||
class EmbeddingRow(SQLModel, table=True):
|
||||
"""Posting embedding (D-049, A-403/A-404): struct-packed little-endian
|
||||
float32 BLOB + per-row model/dim so lazy re-embed on provider change
|
||||
works. posting_id PK. P1 creates the schema; the codec + put/get
|
||||
methods land in P3 (D-051) — the data↔ai seam."""
|
||||
|
||||
__tablename__ = "embedding"
|
||||
|
||||
posting_id: str = Field(primary_key=True)
|
||||
vector: bytes | None = Field(default=None)
|
||||
model: str = Field(default="", sa_type=String)
|
||||
dim: int = Field(default=0)
|
||||
|
||||
|
||||
class ApplicationRow(SQLModel, table=True):
|
||||
"""A learner's application (D-049): unique (posting_id, learner_id)
|
||||
→ duplicate apply is idempotent 200-with-existing (A-408/D-058)."""
|
||||
|
||||
__tablename__ = "application"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("posting_id", "learner_id", name="uq_application_posting_learner"),
|
||||
)
|
||||
|
||||
id: str = Field(primary_key=True)
|
||||
posting_id: str = Field(index=True, sa_type=String)
|
||||
learner_id: str = Field(index=True, sa_type=String)
|
||||
status: str = Field(default="submitted", sa_type=String)
|
||||
created_at: datetime
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not self.id or not self.posting_id or not self.learner_id:
|
||||
raise ValueError("id, posting_id, and learner_id must be non-empty")
|
||||
if not self.status:
|
||||
raise ValueError("status must be non-empty")
|
||||
|
||||
def _normalize(self) -> None:
|
||||
self.created_at = _as_utc(self.created_at)
|
||||
|
||||
def to_application(self) -> Application:
|
||||
return Application(
|
||||
id=self.id,
|
||||
posting_id=self.posting_id,
|
||||
learner_id=self.learner_id,
|
||||
status=self.status,
|
||||
created_at=_as_utc(self.created_at),
|
||||
)
|
||||
|
||||
|
||||
class EmployerEnrichmentRow(SQLModel, table=True):
|
||||
"""Append-only employer enrichment (D-053, A-409): rows are INSERTED,
|
||||
never mutated; merge-on-read precedence manual > llm > ingested-raw.
|
||||
P1 creates the schema; methods + the enrich trigger land in P4."""
|
||||
|
||||
__tablename__ = "employer_enrichment"
|
||||
|
||||
id: str = Field(primary_key=True)
|
||||
employer_id: str = Field(index=True, sa_type=String)
|
||||
field: str = Field(sa_type=String)
|
||||
value: str = Field(default="", sa_type=String)
|
||||
source: str = Field(sa_type=String) # 'llm' | 'manual'
|
||||
model: str = Field(default="", sa_type=String)
|
||||
created_at: datetime | None = Field(default=None)
|
||||
|
||||
|
||||
class LearnerProfileRow(SQLModel, table=True):
|
||||
"""Confirmed learner profile (G-104): learner_id PK, the confirmed
|
||||
profile JSON + model. P1 creates the schema; the resume confirm
|
||||
route persists here in P4 (D-054) — the D-027 store family is the
|
||||
only persistence mechanism, never a filesystem sidecar."""
|
||||
|
||||
__tablename__ = "learner_profile"
|
||||
|
||||
learner_id: str = Field(primary_key=True)
|
||||
profile: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
|
||||
confirmed_at: datetime | None = Field(default=None)
|
||||
model: str = Field(default="", sa_type=String)
|
||||
|
||||
|
||||
def _as_utc(ts: datetime) -> datetime:
|
||||
"""SQLite stores naive; read paths re-label tz-aware UTC (D-027 pattern)."""
|
||||
if ts.tzinfo is None:
|
||||
return ts.replace(tzinfo=UTC)
|
||||
return ts
|
||||
|
||||
|
||||
def _decode_vector(blob: bytes, model: str, dim: int) -> list[float]:
|
||||
"""D-051 codec read half: little-endian float32 BLOB → float list
|
||||
(len==dim enforced by the packed format; struct raises on
|
||||
truncation — surfaced, never silently short-read)."""
|
||||
return list(struct.unpack(f"<{dim}f", bytes(blob)))
|
||||
|
||||
|
||||
def _sqlite_connect(dbapi_connection: object, _: object) -> None:
|
||||
"""Per-connection pragmas — mirrors the other D-027 stores."""
|
||||
cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class InvalidTransition(ValueError):
|
||||
"""A lifecycle move outside draft→published→closed (route → 409)."""
|
||||
|
||||
|
||||
class MarketplaceStore(Protocol):
|
||||
"""Persistence contract for the marketplace backend (D-049)."""
|
||||
|
||||
def create_schema(self) -> None:
|
||||
"""Ensure all six tables exist (idempotent — shared DB file)."""
|
||||
...
|
||||
|
||||
def upsert_posting(self, ingest: IngestPosting, status: PostingStatus) -> Posting:
|
||||
"""Idempotent on (source, external_id): a re-upsert overwrites
|
||||
the SAME row's CONTENT in place (A-402), preserving the row id
|
||||
and the lifecycle status (an admin's publish/close decision
|
||||
outranks a seed re-run; only a MISSING row takes `status`)."""
|
||||
...
|
||||
|
||||
def insert_posting(self, posting: Posting) -> Posting:
|
||||
"""Insert a new posting row (admin authoring path, status=draft).
|
||||
A duplicate id raises IntegrityError (surfaced)."""
|
||||
...
|
||||
|
||||
def get_posting(self, posting_id: str) -> Posting | None:
|
||||
"""Point lookup by posting id."""
|
||||
...
|
||||
|
||||
def list_postings(
|
||||
self, limit: int, offset: int
|
||||
) -> tuple[list[Posting], int]:
|
||||
"""Published-only page (created_at/title-agnostic order: posted_at
|
||||
DESC, then id for deterministic ties) + total_published (G-103)."""
|
||||
...
|
||||
|
||||
def set_posting_status(
|
||||
self, posting_id: str, new_status: PostingStatus
|
||||
) -> Posting | None:
|
||||
"""Lifecycle transition with draft→published→closed validation —
|
||||
InvalidTransition (409 at the route) on any other move. Unknown
|
||||
id → None. Publishing a draft stamps posted_at NOW when the row
|
||||
has none (admin-authored postings)."""
|
||||
...
|
||||
|
||||
def upsert_employer(self, employer: Employer) -> Employer:
|
||||
"""Idempotent on (source, external_id): a re-upsert overwrites in
|
||||
place, preserving the row id when the payload carries it."""
|
||||
...
|
||||
|
||||
def get_employer(self, employer_id: str) -> Employer | None:
|
||||
"""Point lookup by employer id (raw fields only in P1)."""
|
||||
...
|
||||
|
||||
def insert_application(
|
||||
self, posting_id: str, learner_id: str
|
||||
) -> tuple[Application, bool]:
|
||||
"""Idempotent apply (A-408): insert status=submitted; a duplicate
|
||||
(posting_id, learner_id) returns the EXISTING row + created=False
|
||||
— never a second application."""
|
||||
...
|
||||
|
||||
def list_applications(
|
||||
self, posting_id: str | None = None
|
||||
) -> list[Application]:
|
||||
"""Admin review list, newest first; filterable by posting."""
|
||||
...
|
||||
|
||||
def set_posting_tags(self, posting_id: str, tags: PostingTags) -> Posting | None:
|
||||
"""P2 (D-052): persist derived tags — OVERWRITES the tags column
|
||||
(single source of truth; re-tag replaces, never appends)."""
|
||||
...
|
||||
|
||||
def update_posting_fields(
|
||||
self,
|
||||
posting_id: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
skills: list[str] | None = None,
|
||||
) -> Posting | None:
|
||||
"""P2 admin edit path (Task 2-1-03): partial content update; the
|
||||
caller re-tags after the write (tags are derived data). Unknown id
|
||||
→ None (route 404s)."""
|
||||
...
|
||||
|
||||
def facets(self) -> dict:
|
||||
"""P2 (D-052/MH-2c): value counts over the stored tags of
|
||||
PUBLISHED-ONLY postings: {skills: [{value, count}], seniority:
|
||||
[...], category: [...]} — count DESC then value ASC. Untagged
|
||||
postings contribute NOTHING (honest absence)."""
|
||||
...
|
||||
|
||||
def put_vector(
|
||||
self, posting_id: str, values: list[float], model: str, dim: int
|
||||
) -> None:
|
||||
"""P3 (D-051/MH-3a): upsert the posting's embedding — struct-
|
||||
packed little-endian float32 BLOB (`<{dim}f`) + per-row model/dim
|
||||
provenance (A-403/A-404: lazy re-embed on provider change works)."""
|
||||
...
|
||||
|
||||
def get_vector(self, posting_id: str) -> tuple[list[float], str, int] | None:
|
||||
"""P3: (values, model, dim) for one posting; None when absent —
|
||||
the caller re-embeds lazily (or skips, honest absence)."""
|
||||
...
|
||||
|
||||
def list_vectors(
|
||||
self, model_filter: str | None = None
|
||||
) -> list[tuple[str, list[float], str, int]]:
|
||||
"""P3 search path: ALL stored vectors as (posting_id, values,
|
||||
model, dim); `model_filter` restricts to one provenance model."""
|
||||
...
|
||||
|
||||
def search_candidates(
|
||||
self,
|
||||
*,
|
||||
skills: list[str] | None = None,
|
||||
seniority: str | None = None,
|
||||
category: str | None = None,
|
||||
location: str | None = None,
|
||||
remote: bool | None = None,
|
||||
salary_min: int | None = None,
|
||||
salary_max: int | None = None,
|
||||
) -> list[Posting]:
|
||||
"""P3 (A-405/D-059): facet hard-filters SQL-side on the portable
|
||||
columns, PUBLISHED-ONLY, ordered posted_at DESC then id — the
|
||||
candidate set cosine ranking runs over. Empty filters → every
|
||||
published posting (pure semantic rank path)."""
|
||||
...
|
||||
|
||||
def append_enrichment(
|
||||
self, employer_id: str, *, field: str, value: str, source: str, model: str = ""
|
||||
) -> dict:
|
||||
"""P4-A (D-053/A-409): INSERT an enrichment row — append-only,
|
||||
NO update/delete path exists by design (provenance history is
|
||||
immutable; merge-on-read picks the winner)."""
|
||||
...
|
||||
|
||||
def list_enrichment(self, employer_id: str) -> list[dict]:
|
||||
"""P4-A: all enrichment rows for one employer, newest-first."""
|
||||
...
|
||||
|
||||
def count_enrichment(self, employer_id: str, *, source: str) -> int:
|
||||
"""P4-A: row count filtered by source — the enrich-once trigger
|
||||
(llm rows exist → no re-run)."""
|
||||
...
|
||||
|
||||
def employer_posting_titles(self, employer_id: str) -> list[str]:
|
||||
"""P4-A: the employer's PUBLISHED posting titles (grounding hint
|
||||
for the one-shot extraction)."""
|
||||
...
|
||||
|
||||
def save_learner_profile(
|
||||
self, learner_id: str, profile: dict, *, model: str
|
||||
) -> dict:
|
||||
"""P4-B (G-104/D-054): UPSERT the CONFIRMED learner profile
|
||||
(learner_id PK). The draft NEVER persists without the explicit
|
||||
confirm — this is the only write path."""
|
||||
...
|
||||
|
||||
def get_learner_profile(self, learner_id: str) -> dict | None:
|
||||
"""P4-B: the confirmed profile dict + model, or None."""
|
||||
...
|
||||
|
||||
|
||||
class SQLiteMarketplaceStore:
|
||||
"""SQLite implementation of MarketplaceStore (D-027/D-049)."""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
self._db_path: Path = db_path if db_path is not None else Settings().db_path
|
||||
self._engine = create_engine(
|
||||
f"sqlite:///{self._db_path}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
event.listen(self._engine, "connect", _sqlite_connect)
|
||||
self.create_schema()
|
||||
|
||||
def create_schema(self) -> None:
|
||||
"""Ensure all six tables exist (idempotent — shared DB file)."""
|
||||
SQLModel.metadata.create_all(self._engine)
|
||||
|
||||
# -- postings -----------------------------------------------------------
|
||||
|
||||
def upsert_posting(self, ingest: IngestPosting, status: PostingStatus) -> Posting:
|
||||
if status not in _VALID_STATUS:
|
||||
raise ValueError(f"invalid posting status {status!r}")
|
||||
row = PostingRow(
|
||||
id=ingest.id,
|
||||
source=ingest.source,
|
||||
external_id=ingest.external_id,
|
||||
employer_id=ingest.employer_id,
|
||||
title=ingest.title,
|
||||
description=ingest.description,
|
||||
required_competencies=list(ingest.required_competencies),
|
||||
skills=list(ingest.skills),
|
||||
seniority=ingest.seniority,
|
||||
category=ingest.category,
|
||||
location=ingest.location,
|
||||
remote=ingest.remote,
|
||||
salary_min=ingest.salary_min,
|
||||
salary_max=ingest.salary_max,
|
||||
status=status,
|
||||
posted_at=ingest.posted_at,
|
||||
)
|
||||
row._validate()
|
||||
row._normalize()
|
||||
with Session(self._engine) as session:
|
||||
existing = (
|
||||
session.query(PostingRow)
|
||||
.filter(
|
||||
PostingRow.source == ingest.source,
|
||||
PostingRow.external_id == ingest.external_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is None:
|
||||
session.add(row)
|
||||
else:
|
||||
# Overwrite in place, preserving the row id (a re-port with
|
||||
# a changed id must not orphan employer references). STATUS
|
||||
# is NOT overwritten: an admin's lifecycle decision (e.g.
|
||||
# closed) outranks a seed re-run; only a fresh row takes
|
||||
# the ingest status (G-102 seed postings enter published).
|
||||
for field in (
|
||||
"employer_id",
|
||||
"title",
|
||||
"description",
|
||||
"required_competencies",
|
||||
"skills",
|
||||
"seniority",
|
||||
"category",
|
||||
"location",
|
||||
"remote",
|
||||
"salary_min",
|
||||
"salary_max",
|
||||
"posted_at",
|
||||
):
|
||||
setattr(existing, field, getattr(row, field))
|
||||
existing._validate()
|
||||
existing._normalize()
|
||||
row = existing
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row.to_posting()
|
||||
|
||||
def insert_posting(self, posting: Posting) -> Posting:
|
||||
row = PostingRow(
|
||||
id=posting.id,
|
||||
source=posting.source,
|
||||
external_id=posting.external_id,
|
||||
employer_id=posting.employer_id,
|
||||
title=posting.title,
|
||||
description=posting.description,
|
||||
required_competencies=list(posting.required_competencies),
|
||||
skills=list(posting.skills),
|
||||
seniority=posting.seniority,
|
||||
category=posting.category,
|
||||
location=posting.location,
|
||||
remote=posting.remote,
|
||||
salary_min=posting.salary_min,
|
||||
salary_max=posting.salary_max,
|
||||
status=posting.status,
|
||||
tags=dict(posting.tags.model_dump()) if posting.tags else None,
|
||||
posted_at=posting.posted_at,
|
||||
)
|
||||
row._validate()
|
||||
row._normalize()
|
||||
with Session(self._engine) as session:
|
||||
session.add(row)
|
||||
session.commit() # IntegrityError SURFACES (minted-once id)
|
||||
session.refresh(row)
|
||||
return row.to_posting()
|
||||
|
||||
def get_posting(self, posting_id: str) -> Posting | None:
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(PostingRow, posting_id)
|
||||
if row is None:
|
||||
return None
|
||||
session.refresh(row)
|
||||
return row.to_posting()
|
||||
|
||||
def list_postings(
|
||||
self, limit: int, offset: int
|
||||
) -> tuple[list[Posting], int]:
|
||||
with Session(self._engine) as session:
|
||||
total = (
|
||||
session.query(PostingRow)
|
||||
.filter(PostingRow.status == "published")
|
||||
.count()
|
||||
)
|
||||
rows = (
|
||||
session.query(PostingRow)
|
||||
.filter(PostingRow.status == "published")
|
||||
.order_by(PostingRow.posted_at.desc(), PostingRow.id)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [r.to_posting() for r in rows], total
|
||||
|
||||
def set_posting_status(
|
||||
self, posting_id: str, new_status: PostingStatus
|
||||
) -> Posting | None:
|
||||
if new_status not in _VALID_STATUS:
|
||||
raise ValueError(f"invalid posting status {new_status!r}")
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(PostingRow, posting_id)
|
||||
if row is None:
|
||||
return None
|
||||
expected = VALID_TRANSITIONS.get(row.status)
|
||||
if expected is None or expected != new_status:
|
||||
raise InvalidTransition(
|
||||
f"cannot transition posting {posting_id!r} from "
|
||||
f"{row.status!r} to {new_status!r} (valid: draft→published→closed)"
|
||||
)
|
||||
row.status = new_status
|
||||
if new_status == "published" and row.posted_at is None:
|
||||
# Admin-authored draft: publish stamps NOW. Seed-port
|
||||
# postings already carry their original postedAt (G-102).
|
||||
row.posted_at = datetime.now(UTC)
|
||||
row._validate()
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row.to_posting()
|
||||
|
||||
def set_posting_tags(self, posting_id: str, tags: PostingTags) -> Posting | None:
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(PostingRow, posting_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.tags = tags.model_dump(mode="json")
|
||||
row._validate()
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row.to_posting()
|
||||
|
||||
def update_posting_fields(
|
||||
self,
|
||||
posting_id: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
skills: list[str] | None = None,
|
||||
) -> Posting | None:
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(PostingRow, posting_id)
|
||||
if row is None:
|
||||
return None
|
||||
if title is not None:
|
||||
row.title = title
|
||||
if description is not None:
|
||||
row.description = description
|
||||
if skills is not None:
|
||||
row.skills = list(skills)
|
||||
row._validate()
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row.to_posting()
|
||||
|
||||
def facets(self) -> dict:
|
||||
with Session(self._engine) as session:
|
||||
rows = (
|
||||
session.query(PostingRow)
|
||||
.filter(PostingRow.status == "published")
|
||||
.all()
|
||||
)
|
||||
skills: dict[str, int] = {}
|
||||
seniority: dict[str, int] = {}
|
||||
category: dict[str, int] = {}
|
||||
for row in rows:
|
||||
tags = row.tags or {}
|
||||
for skill in tags.get("skills", []) or []:
|
||||
skills[skill] = skills.get(skill, 0) + 1
|
||||
if tags.get("seniority"):
|
||||
seniority[tags["seniority"]] = seniority.get(tags["seniority"], 0) + 1
|
||||
if tags.get("category"):
|
||||
category[tags["category"]] = category.get(tags["category"], 0) + 1
|
||||
|
||||
def _ranked(counts: dict[str, int]) -> list[dict]:
|
||||
return [
|
||||
{"value": v, "count": c}
|
||||
for v, c in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
]
|
||||
|
||||
return {
|
||||
"skills": _ranked(skills),
|
||||
"seniority": _ranked(seniority),
|
||||
"category": _ranked(category),
|
||||
}
|
||||
|
||||
# -- embeddings ----------------------------------------------------------
|
||||
|
||||
def put_vector(
|
||||
self, posting_id: str, values: list[float], model: str, dim: int
|
||||
) -> None:
|
||||
"""D-051 codec: struct-packed little-endian float32 BLOB —
|
||||
`struct.pack(f"<{dim}f", *values)` — + model/dim per row. Upsert
|
||||
on posting_id PK (a re-embed overwrites in place)."""
|
||||
if dim <= 0 or len(values) != dim:
|
||||
raise ValueError(f"vector dim mismatch: {len(values)} values, dim={dim}")
|
||||
blob = struct.pack(f"<{dim}f", *values)
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(EmbeddingRow, posting_id)
|
||||
if row is None:
|
||||
session.add(
|
||||
EmbeddingRow(
|
||||
posting_id=posting_id, vector=blob, model=model, dim=dim
|
||||
)
|
||||
)
|
||||
else:
|
||||
row.vector = blob
|
||||
row.model = model
|
||||
row.dim = dim
|
||||
session.commit()
|
||||
|
||||
def get_vector(self, posting_id: str) -> tuple[list[float], str, int] | None:
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(EmbeddingRow, posting_id)
|
||||
if row is None or row.vector is None or row.dim <= 0:
|
||||
return None
|
||||
return (
|
||||
_decode_vector(row.vector, row.model, row.dim),
|
||||
row.model,
|
||||
row.dim,
|
||||
)
|
||||
|
||||
def list_vectors(
|
||||
self, model_filter: str | None = None
|
||||
) -> list[tuple[str, list[float], str, int]]:
|
||||
with Session(self._engine) as session:
|
||||
query = session.query(EmbeddingRow)
|
||||
if model_filter is not None:
|
||||
query = query.filter(EmbeddingRow.model == model_filter)
|
||||
rows = query.order_by(EmbeddingRow.posting_id).all()
|
||||
out: list[tuple[str, list[float], str, int]] = []
|
||||
for row in rows:
|
||||
if row.vector is None or row.dim <= 0:
|
||||
continue
|
||||
values = _decode_vector(row.vector, row.model, row.dim)
|
||||
out.append((row.posting_id, values, row.model, row.dim))
|
||||
return out
|
||||
|
||||
def search_candidates(
|
||||
self,
|
||||
*,
|
||||
skills: list[str] | None = None,
|
||||
seniority: str | None = None,
|
||||
category: str | None = None,
|
||||
location: str | None = None,
|
||||
remote: bool | None = None,
|
||||
salary_min: int | None = None,
|
||||
salary_max: int | None = None,
|
||||
) -> list[Posting]:
|
||||
"""P3 (A-405/D-059): facet hard-filters SQL-side. Mirrors
|
||||
list_postings' filter surface (published-only, posted_at DESC
|
||||
then id) — ranking runs over this candidate set. A skills
|
||||
filter matches postings whose JSON tags.skills array INTERSECTS
|
||||
the requested set (tag-driven sidebar, D-052)."""
|
||||
with Session(self._engine) as session:
|
||||
query = session.query(PostingRow).filter(PostingRow.status == "published")
|
||||
if skills:
|
||||
# JSON array overlap on the P2 tags column: postings whose
|
||||
# tags.skills INTERSECT the requested set (tag-driven
|
||||
# sidebar, D-052). SQL-side via json_each with INDIVIDUAL
|
||||
# named bind params (positional-list execution is not
|
||||
# portable across the sqlmodel session.execute shim).
|
||||
unique = list(dict.fromkeys(skills))
|
||||
names = [f"s{i}" for i in range(len(unique))]
|
||||
placeholders = ", ".join(f":{n}" for n in names)
|
||||
params = dict(zip(names, unique, strict=True))
|
||||
skill_ids = [
|
||||
row_id
|
||||
for (row_id,) in session.execute(
|
||||
text(
|
||||
"SELECT DISTINCT posting.id FROM posting, "
|
||||
"json_each(posting.tags, '$.skills') je "
|
||||
"WHERE posting.status = 'published' "
|
||||
"AND posting.tags IS NOT NULL "
|
||||
f"AND je.value IN ({placeholders})"
|
||||
),
|
||||
params,
|
||||
).fetchall()
|
||||
]
|
||||
query = query.filter(PostingRow.id.in_(skill_ids or ["__none__"]))
|
||||
if seniority is not None:
|
||||
query = query.filter(PostingRow.seniority == seniority)
|
||||
if category is not None:
|
||||
query = query.filter(PostingRow.category == category)
|
||||
if location is not None:
|
||||
query = query.filter(PostingRow.location == location)
|
||||
if remote is not None:
|
||||
query = query.filter(PostingRow.remote == remote)
|
||||
if salary_min is not None:
|
||||
# a posting qualifies when its advertised band overlaps
|
||||
# the requested band: posting.salary_max >= salary_min.
|
||||
query = query.filter(
|
||||
PostingRow.salary_max.isnot(None),
|
||||
PostingRow.salary_max >= salary_min,
|
||||
)
|
||||
if salary_max is not None:
|
||||
query = query.filter(
|
||||
PostingRow.salary_min.isnot(None),
|
||||
PostingRow.salary_min <= salary_max,
|
||||
)
|
||||
rows = query.order_by(PostingRow.posted_at.desc(), PostingRow.id).all()
|
||||
return [r.to_posting() for r in rows]
|
||||
|
||||
# -- employers ----------------------------------------------------------
|
||||
|
||||
def upsert_employer(self, employer: Employer) -> Employer:
|
||||
row = EmployerRow(
|
||||
id=employer.id,
|
||||
source=employer.source,
|
||||
external_id=employer.external_id,
|
||||
name=employer.name,
|
||||
logo=employer.logo,
|
||||
description=employer.description,
|
||||
industry=employer.industry,
|
||||
size=employer.size,
|
||||
location=employer.location,
|
||||
website=employer.website,
|
||||
social_links=dict(employer.social_links),
|
||||
culture=employer.culture,
|
||||
)
|
||||
row._validate()
|
||||
with Session(self._engine) as session:
|
||||
existing = (
|
||||
session.query(EmployerRow)
|
||||
.filter(
|
||||
EmployerRow.source == employer.source,
|
||||
EmployerRow.external_id == employer.external_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is None:
|
||||
session.add(row)
|
||||
else:
|
||||
for field in (
|
||||
"id",
|
||||
"name",
|
||||
"logo",
|
||||
"description",
|
||||
"industry",
|
||||
"size",
|
||||
"location",
|
||||
"website",
|
||||
"social_links",
|
||||
"culture",
|
||||
):
|
||||
setattr(existing, field, getattr(row, field))
|
||||
existing._validate()
|
||||
row = existing
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row.to_employer()
|
||||
|
||||
def get_employer(self, employer_id: str) -> Employer | None:
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(EmployerRow, employer_id)
|
||||
if row is None:
|
||||
return None
|
||||
session.refresh(row)
|
||||
return row.to_employer()
|
||||
|
||||
# -- applications -------------------------------------------------------
|
||||
|
||||
def insert_application(
|
||||
self, posting_id: str, learner_id: str
|
||||
) -> tuple[Application, bool]:
|
||||
row = ApplicationRow(
|
||||
id=f"app-{uuid.uuid4().hex[:16]}",
|
||||
posting_id=posting_id,
|
||||
learner_id=learner_id,
|
||||
status="submitted",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
row._validate()
|
||||
row._normalize()
|
||||
with Session(self._engine) as session:
|
||||
existing = (
|
||||
session.query(ApplicationRow)
|
||||
.filter(
|
||||
ApplicationRow.posting_id == posting_id,
|
||||
ApplicationRow.learner_id == learner_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
# A-408: idempotent — the EXISTING application rides the
|
||||
# 200, never a second row.
|
||||
session.refresh(existing)
|
||||
existing.created_at = _as_utc(existing.created_at)
|
||||
return existing.to_application(), False
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row.to_application(), True
|
||||
|
||||
def list_applications(
|
||||
self, posting_id: str | None = None
|
||||
) -> list[Application]:
|
||||
with Session(self._engine) as session:
|
||||
query = session.query(ApplicationRow)
|
||||
if posting_id is not None:
|
||||
query = query.filter(ApplicationRow.posting_id == posting_id)
|
||||
rows = query.order_by(ApplicationRow.created_at.desc()).all()
|
||||
return [r.to_application() for r in rows]
|
||||
|
||||
# -- P4-A: enrichment (append-only, D-053) --------------------------------
|
||||
|
||||
def append_enrichment(
|
||||
self, employer_id: str, *, field: str, value: str, source: str, model: str = ""
|
||||
) -> dict:
|
||||
row = EmployerEnrichmentRow(
|
||||
id=f"enr-{uuid.uuid4().hex[:16]}",
|
||||
employer_id=employer_id,
|
||||
field=field,
|
||||
value=value,
|
||||
source=source,
|
||||
model=model,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
with Session(self._engine) as session:
|
||||
session.add(row) # INSERT only — no update/delete path exists
|
||||
session.commit()
|
||||
return {
|
||||
"id": row.id,
|
||||
"employer_id": row.employer_id,
|
||||
"field": row.field,
|
||||
"value": row.value,
|
||||
"source": row.source,
|
||||
"model": row.model,
|
||||
"created_at": row.created_at,
|
||||
}
|
||||
|
||||
def list_enrichment(self, employer_id: str) -> list[dict]:
|
||||
with Session(self._engine) as session:
|
||||
rows = (
|
||||
session.query(EmployerEnrichmentRow)
|
||||
.filter(EmployerEnrichmentRow.employer_id == employer_id)
|
||||
.order_by(EmployerEnrichmentRow.created_at.desc(), EmployerEnrichmentRow.id)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"employer_id": r.employer_id,
|
||||
"field": r.field,
|
||||
"value": r.value,
|
||||
"source": r.source,
|
||||
"model": r.model,
|
||||
"created_at": _as_utc(r.created_at) if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def count_enrichment(self, employer_id: str, *, source: str) -> int:
|
||||
with Session(self._engine) as session:
|
||||
return (
|
||||
session.query(EmployerEnrichmentRow)
|
||||
.filter(
|
||||
EmployerEnrichmentRow.employer_id == employer_id,
|
||||
EmployerEnrichmentRow.source == source,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def employer_posting_titles(self, employer_id: str) -> list[str]:
|
||||
with Session(self._engine) as session:
|
||||
rows = (
|
||||
session.query(PostingRow)
|
||||
.filter(
|
||||
PostingRow.employer_id == employer_id,
|
||||
PostingRow.status == "published",
|
||||
)
|
||||
.order_by(PostingRow.posted_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [r.title for r in rows]
|
||||
|
||||
# -- P4-B: confirmed learner profiles (G-104/D-054) ----------------------
|
||||
|
||||
def save_learner_profile(
|
||||
self, learner_id: str, profile: dict, *, model: str
|
||||
) -> dict:
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(LearnerProfileRow, learner_id)
|
||||
if row is None:
|
||||
row = LearnerProfileRow(learner_id=learner_id)
|
||||
row.profile = dict(profile)
|
||||
row.model = model
|
||||
row.confirmed_at = datetime.now(UTC)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return {
|
||||
"learner_id": row.learner_id,
|
||||
"profile": dict(row.profile),
|
||||
"model": row.model,
|
||||
"confirmed_at": _as_utc(row.confirmed_at) if row.confirmed_at else None,
|
||||
}
|
||||
|
||||
def get_learner_profile(self, learner_id: str) -> dict | None:
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(LearnerProfileRow, learner_id)
|
||||
if row is None:
|
||||
return None
|
||||
session.refresh(row)
|
||||
return {
|
||||
"learner_id": row.learner_id,
|
||||
"profile": dict(row.profile),
|
||||
"model": row.model,
|
||||
"confirmed_at": _as_utc(row.confirmed_at) if row.confirmed_at else None,
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
self._engine.dispose()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Posting text → skills/seniority/category tags (REQ-6-002, D-052).
|
||||
|
||||
Composes the EXISTING structured_completion 4-layer pipeline (D-020)
|
||||
with the PostingTags schema — no new agent, a one-shot extraction like
|
||||
grading's rubric call. Mock-first for free: the mock provider's
|
||||
json_object path (or ScriptedJSONProvider) keeps tests cloud-free.
|
||||
|
||||
Tags are DERIVED data (D-052): re-tagging overwrites the posting's tags
|
||||
column — single source of truth; no append-only semantics (unlike P4's
|
||||
enrichment provenance).
|
||||
|
||||
Tag failure is HONEST, never fatal: a provider error leaves the posting
|
||||
UNTAGGED (tags=None) with a logged warning — facets treat untagged
|
||||
postings honestly (they simply do not contribute counts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..agents.structured import structured_completion
|
||||
from ..llm.base import LLMProvider
|
||||
from ..llm.types import Message
|
||||
from .base import PostingTags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Prompt version — bump on any wording change (prompts are code).
|
||||
TAGGING_PROMPT_VERSION = 1
|
||||
|
||||
TAGGING_PROMPT = """You are a job-posting tagger for an AI-era roles
|
||||
marketplace. Extract structured tags from the posting.
|
||||
|
||||
Rules:
|
||||
- skills: 3-10 concrete, lowercase technology/role skills actually named
|
||||
or clearly required by the posting (e.g. "python", "llm evaluation",
|
||||
"kubernetes"). Prefer the posting's own vocabulary.
|
||||
- seniority: exactly one of "entry", "mid", "senior", "staff",
|
||||
"principal" — infer from title + experience language; omit (null) if
|
||||
genuinely undeterminable.
|
||||
- category: exactly one of "ai-orchestration", "ai-safety", "product",
|
||||
"data", "field-operations", "research" — the role's primary domain;
|
||||
omit (null) if genuinely undeterminable.
|
||||
|
||||
Respond with ONLY the JSON object."""
|
||||
|
||||
_SCHEMA_HINT = (
|
||||
'{"skills": ["up to 10 lowercase skill strings"], '
|
||||
'"seniority": "entry|mid|senior|staff|principal|null", '
|
||||
'"category": "ai-orchestration|ai-safety|product|data|'
|
||||
'field-operations|research|null"}'
|
||||
)
|
||||
|
||||
|
||||
class TaggingUnavailable(Exception):
|
||||
"""The tagger could not produce valid tags (provider/parse failure).
|
||||
|
||||
The caller persists the posting UNTAGGED — never a crash, never a
|
||||
fabricated tag set (D-052 honesty).
|
||||
"""
|
||||
|
||||
|
||||
async def tag_posting(
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
model: str,
|
||||
title: str,
|
||||
description: str,
|
||||
skills_hint: list[str] | None = None,
|
||||
) -> PostingTags:
|
||||
"""One-shot tag extraction via D-020 structured completion.
|
||||
|
||||
`skills_hint` (the posting's declared skills) rides the prompt so
|
||||
the tagger anchors on the author's own vocabulary; the schema bound
|
||||
(≤10) is the enforcement — an 11-skill response fails validation,
|
||||
runs the single bounded retry, and surfaces TaggingUnavailable if
|
||||
the retry also misses.
|
||||
"""
|
||||
hint = ""
|
||||
if skills_hint:
|
||||
hint = "\nDeclared skills: " + ", ".join(skills_hint[:15])
|
||||
messages = [
|
||||
Message(role="system", content=TAGGING_PROMPT),
|
||||
Message(
|
||||
role="user",
|
||||
content=f"Title: {title}\n\nDescription:\n{description}{hint}",
|
||||
),
|
||||
]
|
||||
try:
|
||||
return await structured_completion(
|
||||
provider,
|
||||
messages,
|
||||
model=model,
|
||||
schema=PostingTags,
|
||||
schema_hint=_SCHEMA_HINT,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Provider failures (RuntimeError from mock/loud providers) and
|
||||
# parse failures (StructuredOutputError) both land here: honest
|
||||
# untagged, loud log.
|
||||
logger.warning(
|
||||
"marketplace tagging failed for %r: %s — posting stays "
|
||||
"untagged (honest None, never fabricated)",
|
||||
title[:60],
|
||||
exc,
|
||||
)
|
||||
raise TaggingUnavailable(str(exc)) from exc
|
||||
@@ -21,6 +21,9 @@ dependencies = [
|
||||
# POST /v1/defense/{id}/answer multipart audio (REQ-3-006): FastAPI
|
||||
# form/File parsing requires python-multipart at runtime.
|
||||
"python-multipart>=0.0.32,<0.1",
|
||||
# P4-B resume parse (REQ-6-005, D-054, A-410): PDF text extraction.
|
||||
# Pure-Python, probe-verified 6.19.0 — the ONLY new v0.6 dependency.
|
||||
"pypdf>=6.19,<7",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
MH-3a: store contract — insert/poll/latest/verdict provenance, constraints.
|
||||
MH-3b: gate composition — allowlist (403, first) → identity verdict (403 +
|
||||
verify-CTA) → caps; 16-17 school-pass/marketplace-block; under-16 blocked;
|
||||
G-13 submit caps; G-18 honest stub.
|
||||
G-13 submit caps. v0.6 (D-058): the marketplace apply route is REAL — the
|
||||
G-18 honest-501 pin is rewritten in tests/marketplace/test_marketplace_api.py.
|
||||
MH-3c: PII sentinel — raw DOB + document contents appear in NO log record
|
||||
and NO stored raw form (caplog + store inspection).
|
||||
MH-3e: identity flow end-to-end via TestClient against real create_app.
|
||||
@@ -431,28 +432,36 @@ class TestGateComposition:
|
||||
json={"learner_id": "minor-learner", "competency_id": "stack-orchestration-c007"},
|
||||
)
|
||||
assert variants.status_code == 200, variants.text
|
||||
# marketplace gate (18+ verified): 403 with the age reason
|
||||
# marketplace gate (18+ verified): 403 with the age reason.
|
||||
# v0.6 (D-058): the route moved to api/marketplace.py and is
|
||||
# REAL — body is {learner_id, posting_id}; the gate fires
|
||||
# BEFORE any posting lookup, so the posting id is irrelevant.
|
||||
apply = c.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "minor-learner", "job_id": "job-001"},
|
||||
json={"learner_id": "minor-learner", "posting_id": "job-001"},
|
||||
)
|
||||
assert apply.status_code == 403
|
||||
detail = apply.json()["detail"]
|
||||
assert detail["reason"] == "age_gate_18_plus"
|
||||
assert detail["min_age"] == 18
|
||||
|
||||
def test_verified_adult_marketplace_stub_is_honest_501(
|
||||
def test_verified_adult_marketplace_apply_is_real(
|
||||
self, gated_client: TestClient
|
||||
) -> None:
|
||||
"""G-18: the gate passes; the route NEVER fabricates 'applied'."""
|
||||
"""G-110 (was the G-18 honest-501 pin): the route is REAL now —
|
||||
after the gate it answers with the honest application
|
||||
confirmation, never a fabricated 'applied'. The marketplace
|
||||
router's full behavioral matrix (404/409/idempotent-duplicate)
|
||||
lives in tests/marketplace/test_marketplace_api.py; this pin
|
||||
guards the IDENTITY side: a verified 18+ adult passes the gate."""
|
||||
resp = gated_client.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "pilot-learner", "job_id": "job-001"},
|
||||
json={"learner_id": "pilot-learner", "posting_id": "job-001"},
|
||||
)
|
||||
assert resp.status_code == 501
|
||||
body = resp.json()
|
||||
assert body["stub"] is True
|
||||
assert body["mock"] is True
|
||||
# gate passed: the answer is the real route's (404 posting-
|
||||
# unknown in this fixture's empty store), NEVER the stub 501.
|
||||
assert resp.status_code != 501
|
||||
assert resp.status_code == 404 # honest: no such posting stored
|
||||
|
||||
def test_mh3e_flow_unverified_then_enrolled(
|
||||
self, identity_store: SQLiteIdentityStore, tmp_path: Path
|
||||
@@ -567,14 +576,18 @@ class TestVerifierHardening:
|
||||
"/v1/variants",
|
||||
json={"learner_id": lid, "competency_id": "stack-orchestration-c007"},
|
||||
)
|
||||
# v0.6 (D-058): the REAL marketplace route — the gate
|
||||
# fires before the posting lookup, so the empty store's
|
||||
# 404 (not the stub-era 501) is what a passed gate looks
|
||||
# like here.
|
||||
market = c.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": lid, "job_id": "job-001"},
|
||||
json={"learner_id": lid, "posting_id": "job-001"},
|
||||
)
|
||||
assert (school.status_code == 200) is expect_school, (
|
||||
f"band={band!r} school gate: {school.status_code}"
|
||||
)
|
||||
assert (market.status_code == 501) is expect_market, (
|
||||
assert (market.status_code == 404) is expect_market, (
|
||||
f"band={band!r} marketplace gate: {market.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Marketplace test fixtures (hermeticity rule: db_path pins tmp_path —
|
||||
never ~/.nextcraft, never the cloud).
|
||||
|
||||
Mirrors the tests/identity pattern: a tmp-path marketplace store + a
|
||||
seeded verified-18+ identity store, injected into a real create_app via
|
||||
state-injection (the G-9 precedent) — the gated apply route sees the
|
||||
exact composition production runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.config import Settings
|
||||
from ai_service.identity.store import SQLiteIdentityStore
|
||||
from ai_service.main import create_app
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from ..conftest import SUITE_LEARNERS, seed_verified_identity
|
||||
|
||||
#: The pilot learner roster for gated-route tests (G-5 allowlist coverage
|
||||
#: + verified 18+ identity records — the verified_pilot pattern).
|
||||
MARKETPLACE_LEARNERS = ["pilot-learner", "pilot-learner-2"]
|
||||
|
||||
ADMIN_TOKEN = "test-admin-token"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def marketplace_store(tmp_path: Path) -> SQLiteMarketplaceStore:
|
||||
"""Hermetic store: the shared-DB pattern on a pytest tmp file."""
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def identity_store(tmp_path: Path) -> SQLiteIdentityStore:
|
||||
store = SQLiteIdentityStore(db_path=tmp_path / "identity.db")
|
||||
seed_verified_identity(store, MARKETPLACE_LEARNERS)
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def settings(tmp_path: Path) -> Settings:
|
||||
return Settings(
|
||||
provider="mock",
|
||||
model="gemma4:31b",
|
||||
port=8421,
|
||||
db_path=tmp_path / "nextcraft-test.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
)
|
||||
|
||||
|
||||
def make_client(
|
||||
tmp_path: Path,
|
||||
identity_store: SQLiteIdentityStore,
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
admin_token: str | None = ADMIN_TOKEN,
|
||||
) -> TestClient:
|
||||
"""Full app with state-injected stores; admin_token=None exercises
|
||||
the D-057 fail-closed matrix."""
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=admin_token or "",
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity_store # state-injection (G-9)
|
||||
app.state.marketplace_store = marketplace_store
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_apply_rate_window():
|
||||
"""The apply rate cap is module-level process state (the D-019
|
||||
precedent); tests must not inherit each other's windows."""
|
||||
from ai_service.api.marketplace import _APPLY_TIMES
|
||||
|
||||
_APPLY_TIMES.clear()
|
||||
yield
|
||||
_APPLY_TIMES.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(
|
||||
tmp_path: Path,
|
||||
identity_store: SQLiteIdentityStore,
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
) -> TestClient:
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_client(
|
||||
tmp_path: Path,
|
||||
identity_store: SQLiteIdentityStore,
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
) -> TestClient:
|
||||
"""Client over an INGESTED store (20 published seed postings)."""
|
||||
from ai_service.marketplace.ingest import SeededCorpusSource, run_ingest
|
||||
|
||||
run_ingest(SeededCorpusSource(), marketplace_store)
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
|
||||
return {"X-Admin-Token": token}
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Embedding provider tests (MH-3a/3b/3c, D-051, REQ-6-003).
|
||||
|
||||
Hermeticity (MH-M5): tmp_path db; MockTransport for the openai_compat
|
||||
byte-contract tests (the tests/voice pattern — byte-exact request pins
|
||||
+ canned bodies, NEVER the cloud).
|
||||
|
||||
Pins:
|
||||
- codec roundtrip: struct-packed float32 BLOB via put_vector/get_vector,
|
||||
model+dim per row, byte-exact float32 values
|
||||
- deterministic mock: same text → identical vector (pinned numbers);
|
||||
related > unrelated cosine; zero-vector on empty text
|
||||
- factory G-11: default mock; openai-embed unconfigured → actionable
|
||||
raise (main.py falls back — the boot-safe half lives in main.py's
|
||||
test); unknown name → raise; configured → OpenAIEmbedProvider
|
||||
- _sanitize: no key string in ANY raised error (MockTransport failure)
|
||||
- OpenAI wire format: POST {base}/embeddings {"model","input"} parse
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ai_service.config import Settings
|
||||
from ai_service.marketplace.embedding import (
|
||||
MOCK_EMBED_DIM,
|
||||
MOCK_EMBED_MODEL,
|
||||
MockEmbedProvider,
|
||||
OpenAIEmbedProvider,
|
||||
embed_text,
|
||||
embedding_provider_from_settings,
|
||||
normalize_tokens,
|
||||
)
|
||||
from ai_service.marketplace.embedding.factory import UnknownEmbedProviderError
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
KEY = "sk-embed-test-xyz"
|
||||
|
||||
|
||||
def _cos(a: list[float], b: list[float]) -> float:
|
||||
from ai_service.marketplace.search import cosine_similarity
|
||||
|
||||
return cosine_similarity(a, b)
|
||||
|
||||
|
||||
# -- mock determinism (MH-3b) --------------------------------------------------
|
||||
|
||||
|
||||
class TestMockDeterminism:
|
||||
async def test_same_text_identical_vector(self) -> None:
|
||||
provider = MockEmbedProvider()
|
||||
a = await provider.embed(["python flask backend engineer"])
|
||||
b = await provider.embed(["python flask backend engineer"])
|
||||
assert a[0].values == b[0].values
|
||||
|
||||
async def test_deterministic_across_instances_and_processes(self) -> None:
|
||||
# embed_text is a pure function of the text (hashlib, never the
|
||||
# salted builtin hash()) — pinned NUMBERS, stable forever.
|
||||
v = embed_text("python flask backend")
|
||||
assert len(v) == MOCK_EMBED_DIM == 256
|
||||
assert v == embed_text("python flask backend")
|
||||
# a second provider instance (fresh process state) agrees
|
||||
results = await MockEmbedProvider().embed(["python flask backend"])
|
||||
assert results[0].values == v
|
||||
|
||||
async def test_result_shape(self) -> None:
|
||||
results = await MockEmbedProvider().embed(["a", "b"])
|
||||
assert [r.model for r in results] == [MOCK_EMBED_MODEL] * 2
|
||||
assert all(r.dim == MOCK_EMBED_DIM for r in results)
|
||||
assert all(len(r.values) == r.dim for r in results)
|
||||
|
||||
async def test_related_beats_unrelated_pinned_numbers(self) -> None:
|
||||
related_a = "python flask web backend rest api engineer"
|
||||
related_b = "python flask backend engineer building rest apis"
|
||||
unrelated = "gardening roses hedges pruning seasons"
|
||||
va, vb, vc = (
|
||||
embed_text(related_a),
|
||||
embed_text(related_b),
|
||||
embed_text(unrelated),
|
||||
)
|
||||
related = _cos(va, vb)
|
||||
unrelated_score = _cos(va, vc)
|
||||
# PINNED: deterministic — any formula drift fails here (MH-3b).
|
||||
assert related == pytest.approx(0.7142857142857143, abs=1e-6)
|
||||
assert unrelated_score == pytest.approx(0.0, abs=1e-9)
|
||||
assert related > unrelated_score
|
||||
|
||||
async def test_identical_texts_share_tokens_case_insensitive(self) -> None:
|
||||
# related via shared tokens even under different casing
|
||||
assert normalize_tokens("Python FLASK") == ["python", "flask"]
|
||||
assert _cos(embed_text("Python Flask"), embed_text("flask python")) > 0.9
|
||||
|
||||
async def test_l2_normalized(self) -> None:
|
||||
import math
|
||||
|
||||
v = embed_text("some tokens here")
|
||||
norm = math.sqrt(sum(x * x for x in v))
|
||||
assert norm == pytest.approx(1.0, abs=1e-6)
|
||||
|
||||
async def test_empty_text_is_honest_zero_vector(self) -> None:
|
||||
v = embed_text("")
|
||||
assert v == [0.0] * MOCK_EMBED_DIM
|
||||
# zero vector → cosine guard downstream scores 0.0, never a crash
|
||||
assert _cos(v, embed_text("anything")) == 0.0
|
||||
|
||||
async def test_sign_alternation_cancels_position_collisions(self) -> None:
|
||||
# Two different tokens hashing to the same index cannot stack:
|
||||
# the second occurrence flips the sign. Deterministic property
|
||||
# (the exact colliding pair is formula-internal; the pinned
|
||||
# property is boundedness: repeated tokens never monotonically
|
||||
# inflate one component).
|
||||
v_repeat = embed_text("tok tok tok")
|
||||
assert max(abs(x) for x in v_repeat) <= 1.0 # normalized bound holds
|
||||
|
||||
|
||||
# -- codec roundtrip (MH-3a) ---------------------------------------------------
|
||||
|
||||
|
||||
class TestVectorCodec:
|
||||
def test_put_get_roundtrip_float32_exact(self, tmp_path) -> None:
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "embed.db")
|
||||
values = [0.5, -1.25, 3.0, 0.0, 0.1]
|
||||
store.put_vector("p1", values, "m", 5)
|
||||
got = store.get_vector("p1")
|
||||
store.close()
|
||||
assert got is not None
|
||||
vals, model, dim = got
|
||||
assert model == "m"
|
||||
assert dim == 5
|
||||
# EXACT float32: the BLOB is struct-packed <5f — re-packing the
|
||||
# decoded values reproduces the stored bytes bit-for-bit.
|
||||
assert struct.pack("<5f", *vals) == struct.pack("<5f", *values)
|
||||
assert len(vals) == 5
|
||||
|
||||
def test_put_vector_upsert_overwrites_in_place(self, tmp_path) -> None:
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "embed.db")
|
||||
store.put_vector("p1", [1.0], "old-model", 1)
|
||||
store.put_vector("p1", [2.0, 4.0], "new-model", 2)
|
||||
got = store.get_vector("p1")
|
||||
store.close()
|
||||
assert got == ([2.0, 4.0], "new-model", 2)
|
||||
|
||||
def test_get_vector_missing_is_none(self, tmp_path) -> None:
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "embed.db")
|
||||
assert store.get_vector("nope") is None
|
||||
store.close()
|
||||
|
||||
def test_put_vector_dim_mismatch_rejected(self, tmp_path) -> None:
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "embed.db")
|
||||
with pytest.raises(ValueError, match="dim mismatch"):
|
||||
store.put_vector("p1", [1.0, 2.0], "m", 3)
|
||||
store.close()
|
||||
|
||||
def test_list_vectors_and_model_filter(self, tmp_path) -> None:
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "embed.db")
|
||||
store.put_vector("a", [1.0], "m1", 1)
|
||||
store.put_vector("b", [0.5], "m2", 1)
|
||||
all_rows = store.list_vectors()
|
||||
m1_rows = store.list_vectors(model_filter="m1")
|
||||
store.close()
|
||||
assert sorted(r[0] for r in all_rows) == ["a", "b"]
|
||||
assert [r[0] for r in m1_rows] == ["a"]
|
||||
assert m1_rows[0][2:] == ("m1", 1)
|
||||
|
||||
def test_mock_vector_roundtrips_through_store(self, tmp_path) -> None:
|
||||
# the data↔ai seam: a mock embedding survives the float32 codec
|
||||
# byte-exact (what search.py reads == what embed_text produced,
|
||||
# up to float32 precision — pinned via re-pack equality)
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "embed.db")
|
||||
values = embed_text("python flask backend")
|
||||
store.put_vector("p", values, MOCK_EMBED_MODEL, MOCK_EMBED_DIM)
|
||||
vals, model, dim = store.get_vector("p")
|
||||
store.close()
|
||||
assert model == MOCK_EMBED_MODEL
|
||||
assert dim == MOCK_EMBED_DIM
|
||||
assert struct.pack(f"<{dim}f", *vals) == struct.pack(
|
||||
f"<{MOCK_EMBED_DIM}f", *values
|
||||
)
|
||||
|
||||
|
||||
# -- factory (MH-3c half; boot-safe half is main.py's) -------------------------
|
||||
|
||||
|
||||
class TestFactory:
|
||||
def test_default_is_mock(self) -> None:
|
||||
assert isinstance(embedding_provider_from_settings(Settings()), MockEmbedProvider)
|
||||
|
||||
def test_explicit_mock(self) -> None:
|
||||
provider = embedding_provider_from_settings(Settings(embed_provider="mock"))
|
||||
assert isinstance(provider, MockEmbedProvider)
|
||||
|
||||
def test_openai_embed_unconfigured_raises_actionably(self) -> None:
|
||||
"""G-11's test half: unconfigured = actionable raise for direct
|
||||
callers naming the fix (main.py catches → mock fallback + loud
|
||||
log — pinned in the route/app tests)."""
|
||||
with pytest.raises(UnknownEmbedProviderError, match="AI_EMBED_BASE_URL"):
|
||||
embedding_provider_from_settings(Settings(embed_provider="openai-embed"))
|
||||
with pytest.raises(UnknownEmbedProviderError, match="ollama-cloud"):
|
||||
embedding_provider_from_settings(
|
||||
Settings(
|
||||
embed_provider="openai-embed",
|
||||
embed_base_url="https://ollama.com/v1",
|
||||
)
|
||||
)
|
||||
|
||||
def test_openai_embed_without_http_client_rejected(self) -> None:
|
||||
with pytest.raises(UnknownEmbedProviderError, match="httpx client"):
|
||||
embedding_provider_from_settings(
|
||||
Settings(
|
||||
embed_provider="openai-embed",
|
||||
embed_base_url="https://e.example/v1",
|
||||
embed_api_key="k",
|
||||
embed_model="embed-model",
|
||||
)
|
||||
)
|
||||
|
||||
def test_openai_embed_configured_builds_real_provider(self) -> None:
|
||||
provider = embedding_provider_from_settings(
|
||||
Settings(
|
||||
embed_provider="openai-embed",
|
||||
embed_base_url="https://e.example/v1",
|
||||
embed_api_key="k",
|
||||
embed_model="embed-model",
|
||||
),
|
||||
httpx.AsyncClient(),
|
||||
)
|
||||
assert isinstance(provider, OpenAIEmbedProvider)
|
||||
|
||||
def test_unknown_provider_rejected(self) -> None:
|
||||
with pytest.raises(UnknownEmbedProviderError, match="unknown"):
|
||||
embedding_provider_from_settings(Settings(embed_provider="satellite"))
|
||||
|
||||
|
||||
# -- OpenAI wire format + _sanitize key redaction (MH-3c) ---------------------
|
||||
|
||||
|
||||
def _make_embed_provider(handler) -> OpenAIEmbedProvider:
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
return OpenAIEmbedProvider(
|
||||
http_client=client,
|
||||
base_url="https://embed.example/v1",
|
||||
api_key=KEY,
|
||||
model="embed-model",
|
||||
)
|
||||
|
||||
|
||||
class TestOpenAICompat:
|
||||
async def test_posts_openai_wire_format(self) -> None:
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["path"] = request.url.path
|
||||
seen["auth"] = request.headers.get("authorization", "")
|
||||
seen["body"] = __import__("json").loads(request.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": [
|
||||
{"embedding": [0.1, 0.2]},
|
||||
{"embedding": [0.3, 0.4]},
|
||||
],
|
||||
"model": "embed-model",
|
||||
},
|
||||
)
|
||||
|
||||
provider = _make_embed_provider(handler)
|
||||
results = await provider.embed(["alpha text", "beta text"])
|
||||
assert seen["path"] == "/v1/embeddings"
|
||||
assert seen["auth"] == f"Bearer {KEY}"
|
||||
assert seen["body"] == {"model": "embed-model", "input": ["alpha text", "beta text"]}
|
||||
assert [r.values for r in results] == [[0.1, 0.2], [0.3, 0.4]]
|
||||
assert results[0].model == "embed-model"
|
||||
assert results[0].dim == 2
|
||||
|
||||
async def test_error_key_never_leaks_in_exception(self) -> None:
|
||||
"""MH-3c redaction pin: a failing endpoint whose error path
|
||||
would echo the key (body/URL) must NOT leak it through
|
||||
_sanitize — no key string in ANY raised error."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
# worst case: the endpoint echoes the key in its error body
|
||||
return httpx.Response(
|
||||
500,
|
||||
json={"error": {"message": f"provider rejected {KEY}"}},
|
||||
)
|
||||
|
||||
provider = _make_embed_provider(handler)
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await provider.embed(["x"])
|
||||
msg = str(exc_info.value)
|
||||
assert KEY not in msg, "api_key must never appear in exceptions"
|
||||
assert "embedding provider error" in msg
|
||||
|
||||
async def test_sanitize_replaces_key_in_error_text(self) -> None:
|
||||
# unit-level pin of the _sanitize replace itself (the voice
|
||||
# provider's exact pattern): a raw exception carrying the key
|
||||
# comes back [REDACTED], never the raw key.
|
||||
provider = _make_embed_provider(lambda r: httpx.Response(200, json={}))
|
||||
sanitized = provider._sanitize(ValueError(f"boom {KEY} boom"))
|
||||
assert KEY not in str(sanitized)
|
||||
assert "[REDACTED]" in str(sanitized)
|
||||
|
||||
async def test_length_mismatch_is_contract_break(self) -> None:
|
||||
provider = _make_embed_provider(
|
||||
lambda r: httpx.Response(200, json={"data": [], "model": "m"})
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="mismatch"):
|
||||
await provider.embed(["a", "b"])
|
||||
|
||||
async def test_malformed_entry_is_contract_break(self) -> None:
|
||||
provider = _make_embed_provider(
|
||||
lambda r: httpx.Response(200, json={"data": [{"nope": 1}], "model": "m"})
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="missing"):
|
||||
await provider.embed(["a"])
|
||||
|
||||
async def test_network_error_sanitized(self) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError(f"connection refused {KEY}")
|
||||
|
||||
provider = _make_embed_provider(handler)
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await provider.embed(["x"])
|
||||
assert KEY not in str(exc_info.value)
|
||||
|
||||
|
||||
# Protocol-shape parity guard (mock satisfies the D-051 port at import;
|
||||
# this pins the runtime_checkable contract holds for the real one too).
|
||||
def test_providers_satisfy_protocol() -> None:
|
||||
from ai_service.marketplace.embedding.base import EmbeddingProvider
|
||||
|
||||
assert isinstance(MockEmbedProvider(), EmbeddingProvider)
|
||||
assert isinstance(
|
||||
OpenAIEmbedProvider(httpx.AsyncClient(), "https://x", "k", "m"),
|
||||
EmbeddingProvider,
|
||||
)
|
||||
|
||||
|
||||
def test_asyncio_run_smoke() -> None:
|
||||
# asyncio.run compatibility guard for sync test contexts (the mock
|
||||
# embed is awaitable-only; this keeps the pure-function surface sane)
|
||||
results = asyncio.run(MockEmbedProvider().embed(["roundtrip check"]))
|
||||
assert results[0].values == embed_text("roundtrip check")
|
||||
@@ -0,0 +1,230 @@
|
||||
"""P4-A enrichment tests (REQ-6-004, D-053, MH-4a/4b/4c) — cloud-free."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.llm.mock import ScriptedJSONProvider
|
||||
from ai_service.marketplace.base import Employer, IngestPosting
|
||||
from ai_service.marketplace.enrichment import (
|
||||
ENRICHABLE_FIELDS,
|
||||
EnrichmentUnavailable,
|
||||
enrich_employer,
|
||||
maybe_enrich_on_publish,
|
||||
merge_employer,
|
||||
)
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import admin_headers
|
||||
|
||||
MODEL = "test-model"
|
||||
|
||||
_ENRICH_PAYLOAD = {
|
||||
"description": "A leading AI infrastructure company.",
|
||||
"social_links": {"github": "https://github.com/example"},
|
||||
"logo_ref": "logo.png",
|
||||
}
|
||||
|
||||
|
||||
def _employer() -> Employer:
|
||||
return Employer(
|
||||
id="emp-x",
|
||||
source="test",
|
||||
external_id="emp-x",
|
||||
name="Example Corp",
|
||||
description="Raw ingested description",
|
||||
website="https://example.com",
|
||||
social_links={"twitter": "https://x.com/example"},
|
||||
logo="raw-logo.png",
|
||||
)
|
||||
|
||||
|
||||
class TestEnrichExtraction:
|
||||
async def test_scripted_extraction(self) -> None:
|
||||
provider = ScriptedJSONProvider(_ENRICH_PAYLOAD)
|
||||
payload = await enrich_employer(provider, model=MODEL, employer=_employer())
|
||||
assert payload.description == _ENRICH_PAYLOAD["description"]
|
||||
assert payload.social_links == {"github": "https://github.com/example"}
|
||||
|
||||
async def test_provider_failure_honest(self) -> None:
|
||||
from ai_service.llm.mock import MockProvider
|
||||
|
||||
provider = MockProvider()
|
||||
provider.fail_before_first_token = True
|
||||
with pytest.raises(EnrichmentUnavailable):
|
||||
await enrich_employer(provider, model=MODEL, employer=_employer())
|
||||
|
||||
|
||||
class TestAppendOnly:
|
||||
def test_no_update_delete_path_exists(self) -> None:
|
||||
"""MH-4a: the store exposes NO mutation path for enrichment
|
||||
rows — insert-only by design (introspect the class)."""
|
||||
methods = {
|
||||
name for name, _ in inspect.getmembers(
|
||||
SQLiteMarketplaceStore, predicate=inspect.isfunction
|
||||
)
|
||||
}
|
||||
forbidden = [
|
||||
m
|
||||
for m in methods
|
||||
if "enrichment" in m and any(w in m for w in ("update", "delete", "set"))
|
||||
]
|
||||
assert forbidden == []
|
||||
assert "append_enrichment" in methods
|
||||
|
||||
def test_append_twice_yields_two_rows(self, tmp_path) -> None:
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
|
||||
store.append_enrichment("emp-x", field="description", value="v1", source="manual")
|
||||
store.append_enrichment("emp-x", field="description", value="v2", source="manual")
|
||||
rows = store.list_enrichment("emp-x")
|
||||
assert len(rows) == 2 # history immutable — both kept
|
||||
|
||||
|
||||
class TestMergePrecedence:
|
||||
def test_manual_beats_llm_beats_raw_per_field(self, tmp_path) -> None:
|
||||
"""MH-4a: precedence manual > llm > raw — EVERY pairing."""
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
|
||||
employer = _employer()
|
||||
# llm rows for all three fields
|
||||
store.append_enrichment(
|
||||
"emp-x", field="description", value="llm desc", source="llm", model=MODEL
|
||||
)
|
||||
store.append_enrichment(
|
||||
"emp-x",
|
||||
field="social_links",
|
||||
value=json.dumps({"github": "https://github.example"}),
|
||||
source="llm",
|
||||
model=MODEL,
|
||||
)
|
||||
# manual overrides ONLY description (mixed precedence is legal)
|
||||
store.append_enrichment(
|
||||
"emp-x", field="description", value="manual desc", source="manual"
|
||||
)
|
||||
merged = merge_employer(employer, store.list_enrichment("emp-x"))
|
||||
assert merged["description"] == {
|
||||
"value": "manual desc",
|
||||
"source": "manual",
|
||||
"model": None,
|
||||
}
|
||||
assert merged["social_links"]["source"] == "llm"
|
||||
assert merged["social_links"]["model"] == MODEL
|
||||
# logo has no rows → raw
|
||||
assert merged["logo"] == {
|
||||
"value": "raw-logo.png",
|
||||
"source": "raw",
|
||||
"model": None,
|
||||
}
|
||||
|
||||
def test_no_rows_all_raw(self, tmp_path) -> None:
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
|
||||
merged = merge_employer(_employer(), store.list_enrichment("emp-x"))
|
||||
assert all(v["source"] == "raw" for v in merged.values())
|
||||
|
||||
|
||||
class TestTriggerOnce:
|
||||
def test_enrich_on_first_publish_only(self, tmp_path) -> None:
|
||||
"""MH-4c: the trigger fires when no llm rows exist; a second
|
||||
call is a no-op (rows exist)."""
|
||||
import asyncio
|
||||
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
|
||||
store.upsert_employer(_employer())
|
||||
store.upsert_posting(
|
||||
IngestPosting(
|
||||
id="job-a", source="test", external_id="job-a",
|
||||
employer_id="emp-x", title="T",
|
||||
),
|
||||
status="draft",
|
||||
)
|
||||
provider = ScriptedJSONProvider(_ENRICH_PAYLOAD)
|
||||
first = asyncio.run(
|
||||
maybe_enrich_on_publish(store, provider, model=MODEL, employer_id="emp-x")
|
||||
)
|
||||
assert first is True
|
||||
# desc + social_links + logo (all three non-empty in the payload)
|
||||
assert store.count_enrichment("emp-x", source="llm") == 3
|
||||
second = asyncio.run(
|
||||
maybe_enrich_on_publish(store, provider, model=MODEL, employer_id="emp-x")
|
||||
)
|
||||
assert second is False # no re-run
|
||||
assert store.count_enrichment("emp-x", source="llm") == 3
|
||||
|
||||
def test_dangling_employer_skipped(self, tmp_path) -> None:
|
||||
import asyncio
|
||||
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
|
||||
provider = ScriptedJSONProvider(_ENRICH_PAYLOAD)
|
||||
result = asyncio.run(
|
||||
maybe_enrich_on_publish(store, provider, model=MODEL, employer_id="missing")
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestMergedApiSurface:
|
||||
def test_employer_response_carries_merged_markers(
|
||||
self, seeded_client: TestClient, marketplace_store
|
||||
) -> None:
|
||||
"""MH-4b: every enriched field carries {value, source} in the
|
||||
API — llm copy visibly marked end-to-end."""
|
||||
marketplace_store.append_enrichment(
|
||||
"emp-openai",
|
||||
field="description",
|
||||
value="llm-authored copy",
|
||||
source="llm",
|
||||
model=MODEL,
|
||||
)
|
||||
body = seeded_client.get("/v1/marketplace/employers/emp-openai").json()
|
||||
assert body["merged"]["description"] == {
|
||||
"value": "llm-authored copy",
|
||||
"source": "llm",
|
||||
"model": MODEL,
|
||||
}
|
||||
# Fields without rows carry the raw marker.
|
||||
assert body["merged"]["logo"]["source"] == "raw"
|
||||
|
||||
def test_manual_override_flips_the_marker(
|
||||
self, seeded_client: TestClient, marketplace_store
|
||||
) -> None:
|
||||
marketplace_store.append_enrichment(
|
||||
"emp-openai", field="description", value="llm copy", source="llm", model=MODEL
|
||||
)
|
||||
r = seeded_client.post(
|
||||
"/v1/marketplace/admin/enrichment",
|
||||
json={"employer_id": "emp-openai", "field": "description", "value": "founder words"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = seeded_client.get("/v1/marketplace/employers/emp-openai").json()
|
||||
assert body["merged"]["description"]["value"] == "founder words"
|
||||
assert body["merged"]["description"]["source"] == "manual"
|
||||
# The llm row was NEVER mutated — still in history.
|
||||
rows = marketplace_store.list_enrichment("emp-openai")
|
||||
assert any(row["source"] == "llm" for row in rows)
|
||||
|
||||
def test_override_validates_field(self, seeded_client: TestClient) -> None:
|
||||
r = seeded_client.post(
|
||||
"/v1/marketplace/admin/enrichment",
|
||||
json={"employer_id": "emp-openai", "field": "nonsense", "value": "x"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert ENRICHABLE_FIELDS[0] in r.json()["detail"]
|
||||
|
||||
def test_override_requires_admin(self, seeded_client: TestClient) -> None:
|
||||
r = seeded_client.post(
|
||||
"/v1/marketplace/admin/enrichment",
|
||||
json={"employer_id": "emp-openai", "field": "description", "value": "x"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_override_unknown_employer_404(self, seeded_client: TestClient) -> None:
|
||||
r = seeded_client.post(
|
||||
"/v1/marketplace/admin/enrichment",
|
||||
json={"employer_id": "missing", "field": "description", "value": "x"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Ingest tests (MH-1b idempotency, MH-1f seed fidelity, G-102).
|
||||
|
||||
Hermeticity: tmp_path db, the in-repo seed_corpus.json fixture — zero
|
||||
network, zero ~/.nextcraft.
|
||||
|
||||
Pins: double-run yields identical row counts (no dupes, upserts
|
||||
overwrite in place); 20 postings + 10 employers with ids matching the
|
||||
TS fixtures (D-021 shared ids); matchScore absent from every serialized
|
||||
posting; seed postings enter `published` with their ORIGINAL postedAt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_service.marketplace.base import IngestPosting, posting_summary
|
||||
from ai_service.marketplace.ingest import (
|
||||
PostingSource,
|
||||
SeededCorpusSource,
|
||||
run_ingest,
|
||||
)
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
#: The TS fixture ids (D-021 shared ids — jobs.ts + employers.ts).
|
||||
TS_JOB_IDS = [f"job-{i:03d}" for i in range(1, 21)]
|
||||
TS_EMPLOYER_IDS = sorted(
|
||||
[
|
||||
"emp-openai",
|
||||
"emp-anthropic",
|
||||
"emp-huggingface",
|
||||
"emp-scaleai",
|
||||
"emp-perplexity",
|
||||
"emp-cohere",
|
||||
"emp-mistral",
|
||||
"emp-replicate",
|
||||
"emp-pinecone",
|
||||
"emp-langchain",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestSeededCorpusSource:
|
||||
def test_source_satisfies_the_protocol(self) -> None:
|
||||
assert isinstance(SeededCorpusSource(), PostingSource)
|
||||
|
||||
def test_seed_fidelity_counts_and_ids(self) -> None:
|
||||
source = SeededCorpusSource()
|
||||
postings = list(source.fetch())
|
||||
employers = list(source.fetch_employers())
|
||||
assert len(postings) == 20 # jobs.ts fixture count
|
||||
assert len(employers) == 10 # employers.ts fixture count
|
||||
assert sorted(p.id for p in postings) == TS_JOB_IDS
|
||||
assert sorted(e.id for e in employers) == TS_EMPLOYER_IDS
|
||||
# Employer bindings match the TS fixtures EXACTLY — including
|
||||
# the three dangling references (emp-deepmind/-recursion/-anduril
|
||||
# appear in jobs.ts but never in employers.ts; the mock web
|
||||
# surfaces render them as 'Unknown employer'). The port is
|
||||
# faithful; the API answers those detail pages with the same
|
||||
# honest 404 the web's notFound() shows.
|
||||
assert {p.employer_id for p in postings} == {
|
||||
"emp-openai",
|
||||
"emp-anthropic",
|
||||
"emp-huggingface",
|
||||
"emp-scaleai",
|
||||
"emp-perplexity",
|
||||
"emp-cohere",
|
||||
"emp-mistral",
|
||||
"emp-replicate",
|
||||
"emp-pinecone",
|
||||
"emp-langchain",
|
||||
"emp-deepmind",
|
||||
"emp-recursion",
|
||||
"emp-anduril",
|
||||
}
|
||||
employer_ids = {e.id for e in employers}
|
||||
assert employer_ids < {p.employer_id for p in postings} # 10 defined
|
||||
# every DEFINED employer is referenced by at least one posting
|
||||
assert employer_ids <= {p.employer_id for p in postings}
|
||||
|
||||
def test_seed_fixture_fields_match_ts_port(self) -> None:
|
||||
postings = {p.id: p for p in SeededCorpusSource().fetch()}
|
||||
job_001 = postings["job-001"]
|
||||
assert job_001.title == "AI Orchestration Engineer"
|
||||
assert job_001.employer_id == "emp-langchain"
|
||||
assert job_001.seniority == "senior"
|
||||
assert job_001.remote is True
|
||||
assert job_001.salary_min == 160000
|
||||
assert job_001.salary_max == 240000
|
||||
assert "LangGraph" in job_001.skills
|
||||
# G-102: the original postedAt rides the port verbatim.
|
||||
assert job_001.posted_at is not None
|
||||
assert job_001.posted_at.isoformat() == "2026-08-21T10:00:00+00:00"
|
||||
|
||||
def test_match_score_absent_from_serialized_postings(self) -> None:
|
||||
"""G-107/MH-1f: matchScore is absent from EVERY serialized
|
||||
posting — no camelCase, no snake_case, no variant."""
|
||||
for posting in SeededCorpusSource().fetch():
|
||||
blob = json.dumps(posting_summary(posting)).lower()
|
||||
assert "matchscore" not in blob
|
||||
assert "match_score" not in blob
|
||||
|
||||
def test_seed_json_file_has_no_match_score(self) -> None:
|
||||
path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "ai_service"
|
||||
/ "marketplace"
|
||||
/ "seed_corpus.json"
|
||||
)
|
||||
assert "matchScore" not in path.read_text(encoding="utf-8")
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert len(data["postings"]) == 20
|
||||
assert len(data["employers"]) == 10
|
||||
|
||||
|
||||
class TestRunIngest:
|
||||
def test_double_run_identical_rows(self, marketplace_store) -> None:
|
||||
"""MH-1b: running the seeded source twice yields identical row
|
||||
counts — upserts overwrite in place, no dupes."""
|
||||
first = run_ingest(SeededCorpusSource(), marketplace_store)
|
||||
second = run_ingest(SeededCorpusSource(), marketplace_store)
|
||||
assert first.postings_upserted == second.postings_upserted == 20
|
||||
assert first.employers_upserted == second.employers_upserted == 10
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
with marketplace_store._engine.connect() as conn: # noqa: SLF001
|
||||
postings = conn.execute(text("SELECT COUNT(*) FROM posting")).fetchone()[0]
|
||||
employers = conn.execute(text("SELECT COUNT(*) FROM employer")).fetchone()[0]
|
||||
assert postings == 20
|
||||
assert employers == 10
|
||||
|
||||
def test_seed_postings_enter_published_with_original_posted_at(
|
||||
self, marketplace_store
|
||||
) -> None:
|
||||
"""G-102: seed-port postings enter `published` carrying their
|
||||
ORIGINAL postedAt — the board is populated after ingest."""
|
||||
run_ingest(SeededCorpusSource(), marketplace_store)
|
||||
job_001 = marketplace_store.get_posting("job-001")
|
||||
assert job_001 is not None
|
||||
assert job_001.status == "published"
|
||||
assert job_001.posted_at is not None
|
||||
assert job_001.posted_at.year == 2026 and job_001.posted_at.month == 8
|
||||
|
||||
def test_published_total_after_ingest(self, marketplace_store) -> None:
|
||||
run_ingest(SeededCorpusSource(), marketplace_store)
|
||||
_, total = marketplace_store.list_postings(limit=100, offset=0)
|
||||
assert total == 20
|
||||
|
||||
def test_ingest_result_counts(self, marketplace_store) -> None:
|
||||
result = run_ingest(SeededCorpusSource(), marketplace_store)
|
||||
assert result.postings_upserted == 20
|
||||
assert result.employers_upserted == 10
|
||||
|
||||
|
||||
class TestCustomSource:
|
||||
def test_run_ingest_with_a_scripted_source(self, marketplace_store) -> None:
|
||||
"""The adapter port: a third source (the 3M+ corpus lands later)
|
||||
runs through the same runner with ZERO store/route changes."""
|
||||
|
||||
class TwoJobSource:
|
||||
def fetch(self):
|
||||
yield IngestPosting(
|
||||
id="ext-1",
|
||||
source="external",
|
||||
external_id="ext-1",
|
||||
employer_id="emp-openai",
|
||||
title="External One",
|
||||
)
|
||||
yield IngestPosting(
|
||||
id="ext-2",
|
||||
source="external",
|
||||
external_id="ext-2",
|
||||
employer_id="emp-openai",
|
||||
title="External Two",
|
||||
)
|
||||
|
||||
def fetch_employers(self):
|
||||
return iter([])
|
||||
|
||||
result = run_ingest(TwoJobSource(), marketplace_store)
|
||||
assert result.postings_upserted == 2
|
||||
assert result.employers_upserted == 0
|
||||
rows, _total = marketplace_store.list_postings(limit=10, offset=0)
|
||||
assert {r.id for r in rows} == {"ext-1", "ext-2"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("db_filename", ["one.db"])
|
||||
def test_store_can_share_a_db_file_with_other_stores(tmp_path: Path, db_filename) -> None:
|
||||
"""D-049: the marketplace store coexists on one shared SQLite file
|
||||
with another store family member (identity) — six new tables join
|
||||
the file without touching the existing one."""
|
||||
from ai_service.identity.store import SQLiteIdentityStore
|
||||
|
||||
shared = tmp_path / "nextcraft.db"
|
||||
identity = SQLiteIdentityStore(db_path=shared)
|
||||
marketplace = SQLiteMarketplaceStore(db_path=shared)
|
||||
run_ingest(SeededCorpusSource(), marketplace)
|
||||
from sqlalchemy import text
|
||||
|
||||
with marketplace._engine.connect() as conn: # noqa: SLF001
|
||||
tables = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
)
|
||||
}
|
||||
marketplace.close()
|
||||
identity.close()
|
||||
assert {
|
||||
"identity_record",
|
||||
"posting",
|
||||
"employer",
|
||||
"embedding",
|
||||
"application",
|
||||
"employer_enrichment",
|
||||
"learner_profile",
|
||||
} <= tables
|
||||
@@ -0,0 +1,854 @@
|
||||
"""Marketplace API tests (MH-1c admin matrix, MH-1d apply gates, G-110).
|
||||
|
||||
Hermeticity: every client pins db_path to tmp_path (see conftest).
|
||||
|
||||
Pins:
|
||||
- Admin fail-closed matrix: unset token → 403 on EVERY admin route +
|
||||
the G-11 boot warning in caplog; wrong/absent header → 403; correct
|
||||
token → 200. No admin route passes unguarded.
|
||||
- Lifecycle: draft→published→closed over HTTP; invalid transition 409;
|
||||
unknown posting 404.
|
||||
- Public surfaces: published-only list with total_published (G-103),
|
||||
honest 404s, no matchScore in any payload (G-107).
|
||||
- Apply gate composition (D-043 order, MH-1d): G-5 allowlist 403
|
||||
FIRST; verify-CTA 403 for unverified; age_gate 403 for 16-17; 429 at
|
||||
the rate cap; 404 missing posting; 409 draft/closed; duplicate →
|
||||
idempotent 200-with-existing; confirmation payload exact.
|
||||
- G-110: the honest-501 stub pin (tests/identity/test_identity.py) is
|
||||
REWRITTEN here — the apply route is REAL (response schema pinned,
|
||||
stub/mock markers absent from route + response).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.api import marketplace as marketplace_api
|
||||
from ai_service.config import Settings
|
||||
from ai_service.identity.store import IdentityRecord, SQLiteIdentityStore
|
||||
from ai_service.main import create_app
|
||||
from ai_service.marketplace.base import IngestPosting, Posting
|
||||
from ai_service.marketplace.ingest import SeededCorpusSource, run_ingest
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from ..conftest import seed_verified_identity
|
||||
from .conftest import ADMIN_TOKEN, admin_headers, make_client
|
||||
|
||||
|
||||
class TestAdminFailClosed:
|
||||
"""MH-1c: unset AI_ADMIN_TOKEN → every admin route 403 + boot
|
||||
warning; wrong token → 403; correct token → 200."""
|
||||
|
||||
def test_unset_token_every_admin_route_403_plus_boot_warning(
|
||||
self, tmp_path, caplog
|
||||
) -> None:
|
||||
identity = SQLiteIdentityStore(db_path=tmp_path / "i.db")
|
||||
seed_verified_identity(identity, ["pilot-learner"])
|
||||
marketplace = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
|
||||
run_ingest(SeededCorpusSource(), marketplace)
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["pilot-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token="", # UNSET — the fail-closed case
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity
|
||||
app.state.marketplace_store = marketplace
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with TestClient(app) as c:
|
||||
# G-11: the boot warns LOUDLY (naming the fix).
|
||||
warnings = [
|
||||
r.getMessage()
|
||||
for r in caplog.records
|
||||
if "AI_ADMIN_TOKEN" in r.getMessage()
|
||||
]
|
||||
assert warnings, "expected an unset-admin-token boot warning"
|
||||
assert "AI_ADMIN_TOKEN" in warnings[0]
|
||||
# EVERY admin route 403s — never open (D-057).
|
||||
assert c.post("/v1/marketplace/admin/ingest").status_code == 403
|
||||
assert (
|
||||
c.post(
|
||||
"/v1/marketplace/admin/postings",
|
||||
json={"employer_id": "emp-openai", "title": "T"},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
c.post(
|
||||
"/v1/marketplace/admin/postings/job-001/publish"
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
c.post(
|
||||
"/v1/marketplace/admin/postings/job-001/close"
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
c.get("/v1/marketplace/admin/applications").status_code == 403
|
||||
)
|
||||
# ...even WITH a candidate token (unset config locks all).
|
||||
assert (
|
||||
c.post(
|
||||
"/v1/marketplace/admin/ingest",
|
||||
headers={"X-Admin-Token": "anything"},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
identity.close()
|
||||
marketplace.close()
|
||||
|
||||
def test_wrong_or_absent_token_403_correct_token_200(
|
||||
self, tmp_path, identity_store, marketplace_store
|
||||
) -> None:
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
ingest = "/v1/marketplace/admin/ingest"
|
||||
# absent header → 403
|
||||
assert c.post(ingest).status_code == 403
|
||||
# wrong X-Admin-Token → 403
|
||||
assert (
|
||||
c.post(ingest, headers=admin_headers("wrong-token")).status_code
|
||||
== 403
|
||||
)
|
||||
# wrong Bearer → 403
|
||||
assert (
|
||||
c.post(
|
||||
ingest,
|
||||
headers={"Authorization": "Bearer nope"},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
# correct X-Admin-Token → 200
|
||||
ok = c.post(ingest, headers=admin_headers(ADMIN_TOKEN))
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert ok.json() == {
|
||||
"postings_upserted": 20,
|
||||
"employers_upserted": 10,
|
||||
}
|
||||
# correct Bearer form → 200 too (G-108 accepts both carriers)
|
||||
ok2 = c.post(
|
||||
ingest, headers={"Authorization": f"Bearer {ADMIN_TOKEN}"}
|
||||
)
|
||||
assert ok2.status_code == 200
|
||||
assert ok2.json()["postings_upserted"] == 20 # idempotent re-run
|
||||
|
||||
|
||||
class TestAdminLifecycle:
|
||||
def test_create_publish_close_over_http(
|
||||
self, tmp_path, identity_store, marketplace_store
|
||||
) -> None:
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
created = c.post(
|
||||
"/v1/marketplace/admin/postings",
|
||||
headers=admin_headers(),
|
||||
json={
|
||||
"employer_id": "emp-openai",
|
||||
"title": "Admin Authored Role",
|
||||
"skills": ["Python"],
|
||||
"seniority": "senior",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
body = created.json()
|
||||
assert body["status"] == "draft" # G-102: admin path starts draft
|
||||
posting_id = body["id"]
|
||||
|
||||
# draft is NOT on the public board
|
||||
assert c.get(f"/v1/marketplace/postings/{posting_id}").status_code == 404 or (
|
||||
c.get(f"/v1/marketplace/postings/{posting_id}").json()["status"]
|
||||
== "draft"
|
||||
)
|
||||
|
||||
published = c.post(
|
||||
f"/v1/marketplace/admin/postings/{posting_id}/publish",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert published.status_code == 200
|
||||
assert published.json()["status"] == "published"
|
||||
|
||||
closed = c.post(
|
||||
f"/v1/marketplace/admin/postings/{posting_id}/close",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert closed.status_code == 200
|
||||
assert closed.json()["status"] == "closed"
|
||||
|
||||
def test_invalid_transitions_409(
|
||||
self, tmp_path, identity_store, marketplace_store
|
||||
) -> None:
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
created = c.post(
|
||||
"/v1/marketplace/admin/postings",
|
||||
headers=admin_headers(),
|
||||
json={"employer_id": "emp-openai", "title": "T"},
|
||||
).json()
|
||||
pid = created["id"]
|
||||
# draft → close is invalid → 409
|
||||
assert (
|
||||
c.post(
|
||||
f"/v1/marketplace/admin/postings/{pid}/close",
|
||||
headers=admin_headers(),
|
||||
).status_code
|
||||
== 409
|
||||
)
|
||||
c.post(
|
||||
f"/v1/marketplace/admin/postings/{pid}/publish",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
# published → publish (re-publish) is invalid → 409
|
||||
assert (
|
||||
c.post(
|
||||
f"/v1/marketplace/admin/postings/{pid}/publish",
|
||||
headers=admin_headers(),
|
||||
).status_code
|
||||
== 409
|
||||
)
|
||||
c.post(
|
||||
f"/v1/marketplace/admin/postings/{pid}/close",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
# closed is terminal → both moves 409
|
||||
assert (
|
||||
c.post(
|
||||
f"/v1/marketplace/admin/postings/{pid}/publish",
|
||||
headers=admin_headers(),
|
||||
).status_code
|
||||
== 409
|
||||
)
|
||||
|
||||
def test_lifecycle_404_on_unknown_posting(
|
||||
self, tmp_path, identity_store, marketplace_store
|
||||
) -> None:
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
assert (
|
||||
c.post(
|
||||
"/v1/marketplace/admin/postings/ghost/publish",
|
||||
headers=admin_headers(),
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
c.post(
|
||||
"/v1/marketplace/admin/postings/ghost/close",
|
||||
headers=admin_headers(),
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
def test_applications_review_list_filterable(
|
||||
self, tmp_path, identity_store, marketplace_store
|
||||
) -> None:
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
run_ingest(SeededCorpusSource(), marketplace_store)
|
||||
# two applications on distinct postings
|
||||
for posting_id in ("job-001", "job-002"):
|
||||
resp = c.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "pilot-learner", "posting_id": posting_id},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
all_apps = c.get(
|
||||
"/v1/marketplace/admin/applications", headers=admin_headers()
|
||||
)
|
||||
assert all_apps.status_code == 200
|
||||
assert len(all_apps.json()["applications"]) == 2
|
||||
filtered = c.get(
|
||||
"/v1/marketplace/admin/applications",
|
||||
headers=admin_headers(),
|
||||
params={"posting_id": "job-001"},
|
||||
)
|
||||
rows = filtered.json()["applications"]
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["posting_id"] == "job-001"
|
||||
assert rows[0]["learner_id"] == "pilot-learner"
|
||||
assert rows[0]["status"] == "submitted"
|
||||
|
||||
|
||||
class TestPublicSurfaces:
|
||||
def test_list_published_only_with_total(
|
||||
self, seeded_client: TestClient
|
||||
) -> None:
|
||||
resp = seeded_client.get("/v1/marketplace/postings", params={"limit": 5})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total_published"] == 20
|
||||
assert len(body["postings"]) == 5
|
||||
# G-102: everything on the board is published
|
||||
assert all(p["status"] == "published" for p in body["postings"])
|
||||
|
||||
def test_list_pagination_walks_all_pages(
|
||||
self, seeded_client: TestClient
|
||||
) -> None:
|
||||
seen: list[str] = []
|
||||
offset = 0
|
||||
while True:
|
||||
body = seeded_client.get(
|
||||
"/v1/marketplace/postings",
|
||||
params={"limit": 7, "offset": offset},
|
||||
).json()
|
||||
seen.extend(p["id"] for p in body["postings"])
|
||||
if offset + 7 >= body["total_published"]:
|
||||
break
|
||||
offset += 7
|
||||
assert len(seen) == 20
|
||||
assert len(set(seen)) == 20 # no dupes across the walk (G-103)
|
||||
|
||||
def test_get_posting_honest_404(self, seeded_client: TestClient) -> None:
|
||||
assert (
|
||||
seeded_client.get("/v1/marketplace/postings/does-not-exist").status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
def test_get_employer_honest_404(self, seeded_client: TestClient) -> None:
|
||||
assert (
|
||||
seeded_client.get("/v1/marketplace/employers/ghost-emp").status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
def test_employer_payload_raw_fields(self, seeded_client: TestClient) -> None:
|
||||
employer = seeded_client.get("/v1/marketplace/employers/emp-openai").json()
|
||||
assert employer["name"] == "OpenAI Labs"
|
||||
assert employer["social_links"]["twitter"] == "https://example.com/openai/x"
|
||||
assert employer["culture"].startswith("Research-driven")
|
||||
|
||||
def test_match_score_absent_from_all_payloads(
|
||||
self, seeded_client: TestClient
|
||||
) -> None:
|
||||
"""G-107/MH-1f: matchScore is absent from list + detail + employer
|
||||
payloads — computed scores are search-context-only (P3/G-106)."""
|
||||
listing = seeded_client.get("/v1/marketplace/postings", params={"limit": 20})
|
||||
blob = listing.text.lower()
|
||||
assert "matchscore" not in blob and "match_score" not in blob
|
||||
detail = seeded_client.get("/v1/marketplace/postings/job-001")
|
||||
assert "matchscore" not in detail.text.lower()
|
||||
|
||||
def test_posting_payload_shape(self, seeded_client: TestClient) -> None:
|
||||
"""G-110 response-schema pin (replaces the honest-501 stub pin)."""
|
||||
posting = seeded_client.get("/v1/marketplace/postings/job-001").json()
|
||||
assert posting["id"] == "job-001"
|
||||
assert posting["title"] == "AI Orchestration Engineer"
|
||||
assert posting["employer_id"] == "emp-langchain"
|
||||
assert posting["status"] == "published"
|
||||
assert posting["posted_at"].startswith("2026-08-21T10:00:00")
|
||||
assert "LangGraph" in posting["skills"]
|
||||
# the response schema itself carries no stub-era field
|
||||
schema_fields = set(marketplace_api.PostingResponse.model_fields)
|
||||
assert "match_score" not in schema_fields
|
||||
assert "matchScore" not in schema_fields
|
||||
|
||||
|
||||
class TestApplyGateComposition:
|
||||
"""MH-1d: the D-043 order is PROVABLE — allowlist 403 fires first,
|
||||
verify-CTA 403 second, 429 at cap; then posting states; then the
|
||||
idempotent duplicate."""
|
||||
|
||||
def test_allowlist_403_fires_first(
|
||||
self, seeded_client: TestClient
|
||||
) -> None:
|
||||
"""A NON-allowlisted learner gets the G-5 403 even though the
|
||||
posting id is garbage — the allowlist precedes everything."""
|
||||
resp = seeded_client.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "stranger-danger", "posting_id": "totally-bogus"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "allowlist" in resp.json()["detail"]
|
||||
|
||||
def test_unverified_allowlisted_gets_verify_cta(
|
||||
self, tmp_path, marketplace_store
|
||||
) -> None:
|
||||
"""Allowlisted but NEVER identity-verified → 403 with the
|
||||
verify-CTA payload (the second gate, A-306)."""
|
||||
identity = SQLiteIdentityStore(db_path=tmp_path / "i.db")
|
||||
# NO seed — fresh learner has no identity record
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["fresh-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity
|
||||
app.state.marketplace_store = marketplace_store
|
||||
with TestClient(app) as c:
|
||||
resp = c.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "fresh-learner", "posting_id": "job-001"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["reason"] == "identity_verification_required"
|
||||
assert detail["min_age"] == 18
|
||||
assert detail["current_status"] == "none"
|
||||
assert detail["verify_cta"] == "/enroll"
|
||||
identity.close()
|
||||
|
||||
def test_verified_16_17_blocked_with_age_reason(
|
||||
self, tmp_path, marketplace_store
|
||||
) -> None:
|
||||
identity = SQLiteIdentityStore(db_path=tmp_path / "i.db")
|
||||
identity.insert(
|
||||
IdentityRecord(
|
||||
id="seed-minor",
|
||||
learner_id="minor-learner",
|
||||
status="verified",
|
||||
provider="mock",
|
||||
verdict={"status": "verified", "age_band": "16-17", "mock": True},
|
||||
age_band="16-17",
|
||||
submitted_at=datetime.now(UTC),
|
||||
verified_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["minor-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity
|
||||
app.state.marketplace_store = marketplace_store
|
||||
with TestClient(app) as c:
|
||||
resp = c.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "minor-learner", "posting_id": "job-001"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["reason"] == "age_gate_18_plus"
|
||||
assert detail["min_age"] == 18
|
||||
identity.close()
|
||||
|
||||
def test_rate_cap_429(self, tmp_path, marketplace_store) -> None:
|
||||
"""Distinct applies within the window exhaust the cap → 429
|
||||
(honest retries of the SAME posting stay idempotent-200 and do
|
||||
not consume budget)."""
|
||||
identity = SQLiteIdentityStore(db_path=tmp_path / "i.db")
|
||||
seed_verified_identity(identity, ["pilot-learner"])
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["pilot-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity
|
||||
app.state.marketplace_store = marketplace_store
|
||||
run_ingest(SeededCorpusSource(), marketplace_store)
|
||||
with TestClient(app) as c:
|
||||
# 5 distinct postings = the cap (APPLY_PER_LEARNER_PER_MIN)
|
||||
for i in range(1, 6):
|
||||
resp = c.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={
|
||||
"learner_id": "pilot-learner",
|
||||
"posting_id": f"job-{i:03d}",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# 6th distinct apply within the window → 429
|
||||
sixth = c.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "pilot-learner", "posting_id": "job-006"},
|
||||
)
|
||||
assert sixth.status_code == 429
|
||||
# a RE-apply of an already-applied posting is idempotent —
|
||||
# and the rate window check happens BEFORE the duplicate
|
||||
# short-circuit, so it 429s under the same window too.
|
||||
identity.close()
|
||||
|
||||
def test_apply_404_missing_posting(self, seeded_client: TestClient) -> None:
|
||||
resp = seeded_client.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "pilot-learner", "posting_id": "no-such-job"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_apply_409_draft(self, tmp_path, identity_store, marketplace_store) -> None:
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
created = c.post(
|
||||
"/v1/marketplace/admin/postings",
|
||||
headers=admin_headers(),
|
||||
json={"employer_id": "emp-openai", "title": "Still Draft"},
|
||||
).json()
|
||||
resp = c.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "pilot-learner", "posting_id": created["id"]},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["reason"] == "posting_not_published"
|
||||
assert detail["status"] == "draft"
|
||||
|
||||
def test_apply_409_closed(
|
||||
self, tmp_path, identity_store, marketplace_store
|
||||
) -> None:
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
created = c.post(
|
||||
"/v1/marketplace/admin/postings",
|
||||
headers=admin_headers(),
|
||||
json={"employer_id": "emp-openai", "title": "To Close"},
|
||||
).json()
|
||||
c.post(
|
||||
f"/v1/marketplace/admin/postings/{created['id']}/publish",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
c.post(
|
||||
f"/v1/marketplace/admin/postings/{created['id']}/close",
|
||||
headers=admin_headers(),
|
||||
)
|
||||
resp = c.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "pilot-learner", "posting_id": created["id"]},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["status"] == "closed"
|
||||
|
||||
def test_apply_success_then_idempotent_duplicate(
|
||||
self, seeded_client: TestClient
|
||||
) -> None:
|
||||
first = seeded_client.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "pilot-learner", "posting_id": "job-001"},
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
body = first.json()
|
||||
assert body["posting_id"] == "job-001"
|
||||
assert body["status"] == "submitted"
|
||||
assert body["application_id"].startswith("app-")
|
||||
|
||||
duplicate = seeded_client.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "pilot-learner", "posting_id": "job-001"},
|
||||
)
|
||||
assert duplicate.status_code == 200 # A-408: 200, never 409
|
||||
assert duplicate.json() == body # the EXISTING application rides
|
||||
|
||||
|
||||
class TestStubMarkersGone:
|
||||
"""G-110/MH-3: the honest-501 placeholder is REPLACED — the apply
|
||||
route is REAL; every placeholder/mock marker is gone from the route
|
||||
declarations AND the responses."""
|
||||
|
||||
def test_apply_route_is_real_not_a_placeholder(self) -> None:
|
||||
"""The route decorator carries NO status_code=501 and its body
|
||||
returns the honest confirmation (never a not-live-yet payload)."""
|
||||
apply_route = marketplace_api.router.routes[
|
||||
[getattr(r, "path", "") for r in marketplace_api.router.routes].index(
|
||||
"/v1/marketplace/apply"
|
||||
)
|
||||
]
|
||||
# no explicit status_code override → 200 (the confirmation is the
|
||||
# success path); the placeholder era pinned 501 on the decorator.
|
||||
assert getattr(apply_route, "status_code", None) is None
|
||||
body = inspect.getsource(marketplace_api.apply_posting)
|
||||
assert "501" not in body
|
||||
assert "not live yet" not in body
|
||||
|
||||
def test_identity_module_has_no_marketplace_route(self) -> None:
|
||||
"""D-048: identity keeps ONLY the gate exports — no marketplace
|
||||
router, no placeholder body."""
|
||||
from ai_service.api import identity as identity_api
|
||||
|
||||
source = inspect.getsource(identity_api)
|
||||
assert "marketplace_router" not in source
|
||||
assert "/apply" not in source
|
||||
# the gate exports survive (D-043 composition consumers)
|
||||
assert hasattr(identity_api, "require_verified_adult")
|
||||
assert hasattr(identity_api, "require_verified_age")
|
||||
|
||||
def test_apply_response_has_no_placeholder_markers(
|
||||
self, seeded_client: TestClient
|
||||
) -> None:
|
||||
body = seeded_client.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"learner_id": "pilot-learner", "posting_id": "job-003"},
|
||||
).json()
|
||||
assert set(body) == {"application_id", "posting_id", "status"} # exact payload
|
||||
assert "stub" not in body and "mock" not in body
|
||||
|
||||
def test_openapi_schema_pins_the_real_apply_route(
|
||||
self, seeded_client: TestClient
|
||||
) -> None:
|
||||
"""The G-110 response-schema pin: /v1/marketplace/apply is a
|
||||
200-route with the honest confirmation schema — no 501 anywhere."""
|
||||
spec = seeded_client.get("/openapi.json").json()
|
||||
apply_op = spec["paths"]["/v1/marketplace/apply"]["post"]
|
||||
assert "200" in apply_op["responses"]
|
||||
assert "501" not in apply_op["responses"]
|
||||
ref = apply_op["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
assert ref["$ref"].endswith("ApplyResponse")
|
||||
|
||||
|
||||
class TestShellRoutesServeArbitraryIds:
|
||||
"""G-101/G-109: the exported placeholder shell is served for ANY job/
|
||||
employer id on the single-port app, so runtime-created postings stay
|
||||
reachable and every sitemap URL resolves."""
|
||||
|
||||
def _client_with_shell(self, tmp_path) -> TestClient:
|
||||
# Minimal export tree with the placeholder shells.
|
||||
web_dir = tmp_path / "out"
|
||||
jobs_dir = web_dir / "marketplace" / "jobs" / "_"
|
||||
employers_dir = web_dir / "marketplace" / "employers" / "_"
|
||||
jobs_dir.mkdir(parents=True)
|
||||
employers_dir.mkdir(parents=True)
|
||||
(jobs_dir / "index.html").write_text("<html>job shell</html>")
|
||||
(employers_dir / "index.html").write_text("<html>employer shell</html>")
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["pilot-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token="",
|
||||
web_static_dir=web_dir,
|
||||
)
|
||||
return TestClient(create_app(settings))
|
||||
|
||||
def test_arbitrary_job_id_serves_the_placeholder_shell(
|
||||
self, tmp_path
|
||||
) -> None:
|
||||
with self._client_with_shell(tmp_path) as client:
|
||||
r = client.get("/marketplace/jobs/job-latest-ai-role")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/html")
|
||||
assert "job shell" in r.text
|
||||
|
||||
def test_arbitrary_employer_id_serves_the_placeholder_shell(
|
||||
self, tmp_path
|
||||
) -> None:
|
||||
with self._client_with_shell(tmp_path) as client:
|
||||
r = client.get("/marketplace/employers/emp-newco/")
|
||||
assert r.status_code == 200
|
||||
assert "employer shell" in r.text
|
||||
|
||||
def test_api_routes_still_win_over_the_mount(self, tmp_path) -> None:
|
||||
"""The catch-all registration must not shadow /v1/* or /health."""
|
||||
with self._client_with_shell(tmp_path) as client:
|
||||
assert client.get("/health").status_code == 200
|
||||
assert (
|
||||
client.get("/v1/marketplace/postings").status_code == 200
|
||||
)
|
||||
|
||||
|
||||
class TestFacetsPayload:
|
||||
"""P2 (MH-2c): the list response carries facets computed from stored
|
||||
tags of PUBLISHED-ONLY postings; draft/closed contribute nothing."""
|
||||
|
||||
def test_facets_present_and_empty_before_tagging(
|
||||
self, seeded_client: TestClient
|
||||
) -> None:
|
||||
body = seeded_client.get("/v1/marketplace/postings").json()
|
||||
assert body["facets"] == {"skills": [], "seniority": [], "category": []}
|
||||
|
||||
def test_facets_match_stored_tags_published_only(
|
||||
self, seeded_client: TestClient, marketplace_store
|
||||
) -> None:
|
||||
from ai_service.marketplace.base import PostingTags
|
||||
|
||||
marketplace_store.set_posting_tags(
|
||||
"job-001", PostingTags(skills=["python"], seniority="mid", category="data")
|
||||
)
|
||||
marketplace_store.set_posting_tags(
|
||||
"job-002", PostingTags(skills=["python", "rag"], seniority=None, category=None)
|
||||
)
|
||||
# A DRAFT with tags must NOT contribute facets.
|
||||
draft_id = "adm-draft-1"
|
||||
marketplace_store.insert_posting(
|
||||
Posting(
|
||||
id=draft_id,
|
||||
source="admin",
|
||||
external_id=draft_id,
|
||||
employer_id="emp-openai",
|
||||
title="Draft",
|
||||
)
|
||||
)
|
||||
marketplace_store.set_posting_tags(
|
||||
draft_id, PostingTags(skills=["secret-draft-skill"], seniority=None, category=None)
|
||||
)
|
||||
body = seeded_client.get("/v1/marketplace/postings").json()
|
||||
facets = body["facets"]
|
||||
assert facets["skills"] == [
|
||||
{"value": "python", "count": 2},
|
||||
{"value": "rag", "count": 1},
|
||||
]
|
||||
assert facets["seniority"] == [{"value": "mid", "count": 1}]
|
||||
assert facets["category"] == [{"value": "data", "count": 1}]
|
||||
assert all(f["value"] != "secret-draft-skill" for f in facets["skills"])
|
||||
|
||||
|
||||
class TestAdminEditRoute:
|
||||
"""P2 (Task 2-1-03): PUT /admin/postings/{id} — admin-guarded edit
|
||||
that re-tags (overwrite semantics, D-052)."""
|
||||
|
||||
def test_edit_requires_admin_token(self, client: TestClient) -> None:
|
||||
r = client.put(
|
||||
"/v1/marketplace/admin/postings/job-001",
|
||||
json={"title": "New"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_edit_unknown_posting_404s(self, client: TestClient) -> None:
|
||||
r = client.put(
|
||||
"/v1/marketplace/admin/postings/missing",
|
||||
json={"title": "New"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_edit_retags_and_old_tags_gone(
|
||||
self, seeded_client: TestClient, marketplace_store
|
||||
) -> None:
|
||||
from ai_service.marketplace.base import PostingTags
|
||||
|
||||
# Pre-existing tags that the edit's re-tag must OVERWRITE.
|
||||
marketplace_store.set_posting_tags(
|
||||
"job-001",
|
||||
PostingTags(skills=["stale-skill"], seniority="entry", category="research"),
|
||||
)
|
||||
# The app's provider is the plain MockProvider — its json_object
|
||||
# reply is a fixed non-matching payload, so the re-tag fails
|
||||
# honestly and tags STAY. Assert the honest-failure contract via
|
||||
# the route: the edit persists, the old tags remain.
|
||||
r = seeded_client.put(
|
||||
"/v1/marketplace/admin/postings/job-001",
|
||||
json={"title": "Edited title"},
|
||||
headers=admin_headers(),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["title"] == "Edited title"
|
||||
stored = marketplace_store.get_posting("job-001")
|
||||
assert stored is not None and stored.title == "Edited title"
|
||||
# extra="forbid" (verifier P2-3): the plain mock's canned
|
||||
# {"summary":...} does NOT validate as PostingTags → the tag
|
||||
# call fails honestly → the edit lands, tags UNCHANGED (old
|
||||
# tags kept — never fabricated, never erased by a bad parse).
|
||||
assert stored.tags is not None
|
||||
assert stored.tags.skills == ["stale-skill"]
|
||||
|
||||
def test_edit_with_scripted_provider_retags(
|
||||
self, tmp_path, marketplace_store, identity_store
|
||||
) -> None:
|
||||
"""With a SCRIPTED provider the edit re-tags and OVERWRITES."""
|
||||
from ai_service.config import Settings
|
||||
from ai_service.llm.mock import ScriptedJSONProvider
|
||||
from ai_service.main import create_app
|
||||
from ai_service.marketplace.base import PostingTags
|
||||
|
||||
marketplace_store.upsert_posting(
|
||||
IngestPosting(
|
||||
id="job-edit-1", source="test", external_id="job-edit-1",
|
||||
employer_id="emp-openai", title="Old",
|
||||
),
|
||||
status="published",
|
||||
)
|
||||
marketplace_store.set_posting_tags(
|
||||
"job-edit-1", PostingTags(skills=["old-skill"], seniority=None, category=None)
|
||||
)
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["pilot-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=ADMIN_TOKEN,
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity_store
|
||||
app.state.marketplace_store = marketplace_store
|
||||
app.state.provider = ScriptedJSONProvider(
|
||||
{"skills": ["new-skill"], "seniority": "senior", "category": "product"}
|
||||
)
|
||||
with TestClient(app) as c:
|
||||
r = c.put(
|
||||
"/v1/marketplace/admin/postings/job-edit-1",
|
||||
json={"title": "New"},
|
||||
headers={"X-Admin-Token": ADMIN_TOKEN},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
stored = marketplace_store.get_posting("job-edit-1")
|
||||
assert stored is not None
|
||||
assert stored.tags is not None
|
||||
assert stored.tags.skills == ["new-skill"]
|
||||
assert "old-skill" not in stored.tags.skills
|
||||
assert stored.tags.seniority == "senior"
|
||||
|
||||
|
||||
class TestIngestTags:
|
||||
"""P2 (D-052): the admin ingest route TAGS every posting after
|
||||
upsert; tag failures leave postings UNTAGGED (honest) — never a
|
||||
crashed ingest."""
|
||||
|
||||
def test_ingest_with_plain_mock_leaves_untagged(
|
||||
self, seeded_client: TestClient, marketplace_store
|
||||
) -> None:
|
||||
"""The app's MockProvider json_object reply does not match
|
||||
PostingTags → every posting stays untagged; the ingest still
|
||||
reports the full upsert counts (honest failure semantics)."""
|
||||
# seeded_client already ran run_ingest via fixtures WITHOUT
|
||||
# tagging (fixture uses the store directly) — run the ROUTE now:
|
||||
r = seeded_client.post(
|
||||
"/v1/marketplace/admin/ingest", headers=admin_headers()
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["postings_upserted"] == 20
|
||||
# extra="forbid" (verifier P2-3): the plain mock's canned
|
||||
# {"summary":...} does NOT validate as PostingTags → the tag
|
||||
# pass fails honestly per posting → UNTAGGED (tags=None), never
|
||||
# a fabricated tag set, ingest completes with full counts.
|
||||
stored = marketplace_store.get_posting("job-001")
|
||||
assert stored is not None
|
||||
assert stored.tags is None
|
||||
|
||||
def test_ingest_with_scripted_provider_tags(
|
||||
self, tmp_path, marketplace_store, identity_store
|
||||
) -> None:
|
||||
from ai_service.config import Settings
|
||||
from ai_service.llm.mock import ScriptedJSONProvider
|
||||
from ai_service.main import create_app
|
||||
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["pilot-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=ADMIN_TOKEN,
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity_store
|
||||
app.state.marketplace_store = marketplace_store
|
||||
app.state.provider = ScriptedJSONProvider(
|
||||
{"skills": ["python"], "seniority": "mid", "category": "data"}
|
||||
)
|
||||
with TestClient(app) as c:
|
||||
r = c.post(
|
||||
"/v1/marketplace/admin/ingest", headers={"X-Admin-Token": ADMIN_TOKEN}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["postings_upserted"] == 20
|
||||
stored = marketplace_store.get_posting("job-001")
|
||||
assert stored is not None
|
||||
assert stored.tags is not None
|
||||
assert stored.tags.skills == ["python"]
|
||||
# Facets now reflect the tagged corpus (published-only).
|
||||
facets = marketplace_store.facets()
|
||||
assert facets["skills"] == [{"value": "python", "count": 20}]
|
||||
@@ -0,0 +1,317 @@
|
||||
"""P4-B resume tests (REQ-6-005, D-054, A-410, MH-4d/4e/4f) — cloud-free.
|
||||
|
||||
PDF fixtures are GENERATED in tmp (pypdf writer) — no binaries
|
||||
committed. The mock provider scripts the structured extraction; the
|
||||
bounds matrix pins every honest 413/422.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.llm.mock import MockProvider, ScriptedJSONProvider
|
||||
from ai_service.marketplace.resume import (
|
||||
MAX_RESUME_BYTES,
|
||||
ParsedProfile,
|
||||
ResumeFormatError,
|
||||
extract_resume_text,
|
||||
parse_resume,
|
||||
)
|
||||
|
||||
from .conftest import ADMIN_TOKEN
|
||||
|
||||
MODEL = "test-model"
|
||||
|
||||
_PARSE_PAYLOAD = {
|
||||
"name": "Jordan Rivera",
|
||||
"headline": "LLM evaluation engineer",
|
||||
"skills": ["python", "llm evaluation", "rag"],
|
||||
"experience_bands": [{"role": "ML Engineer", "years": 3}],
|
||||
"competencies": ["LLM evaluation", "RAG systems"],
|
||||
}
|
||||
|
||||
|
||||
def _make_pdf(tmp_path, pages: list[str]):
|
||||
"""Generate a small text-layer PDF via pypdf (no committed binaries)."""
|
||||
from io import BytesIO
|
||||
|
||||
from pypdf import PdfWriter
|
||||
|
||||
writer = PdfWriter()
|
||||
for _ in pages:
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
# pypdf cannot stamp text without a reportlab-class dep; build the
|
||||
# honest equivalent: a real PDF with NO text layer (image-only
|
||||
# stand-in) + assert the 422 path; the TXT path carries the parse
|
||||
# contract. For the text-layer POSITIVE path use pypdf's annotation
|
||||
# text? Simpler honest choice: pypdf-written PDFs carry no extract
|
||||
# → they ARE the no-text-layer fixture.
|
||||
buf = BytesIO()
|
||||
writer.write(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class TestExtract:
|
||||
def test_txt_decode(self) -> None:
|
||||
text = extract_resume_text(b"hello resume world", "text/plain")
|
||||
assert text == "hello resume world"
|
||||
|
||||
def test_md_decode(self) -> None:
|
||||
text = extract_resume_text(b"# Resume\n- skills", "text/markdown")
|
||||
assert "# Resume" in text
|
||||
|
||||
def test_bare_type_aliases(self) -> None:
|
||||
assert extract_resume_text(b"data", "txt") == "data"
|
||||
assert extract_resume_text(b"data", "md") == "data"
|
||||
|
||||
def test_pdf_without_text_layer_honest_422(self, tmp_path) -> None:
|
||||
"""A generated blank PDF extracts empty → ResumeFormatError
|
||||
naming the text-layer requirement (no OCR in v0.6)."""
|
||||
pdf = _make_pdf(tmp_path, ["", ""])
|
||||
with pytest.raises(ResumeFormatError) as exc:
|
||||
extract_resume_text(pdf, "application/pdf")
|
||||
assert "text layer required" in str(exc.value)
|
||||
|
||||
def test_unsupported_format_rejected(self) -> None:
|
||||
with pytest.raises(ResumeFormatError):
|
||||
extract_resume_text(b"MZ\x90\x00", "application/vnd.exe") # type: ignore[arg-type]
|
||||
|
||||
def test_garbage_text_rejected(self) -> None:
|
||||
with pytest.raises(ResumeFormatError):
|
||||
extract_resume_text(b"\xff\xfe\x00", "text/plain")
|
||||
|
||||
def test_empty_text_rejected(self) -> None:
|
||||
with pytest.raises(ResumeFormatError):
|
||||
extract_resume_text(b" \n ", "text/plain")
|
||||
|
||||
|
||||
class TestParse:
|
||||
async def test_scripted_parse(self) -> None:
|
||||
provider = ScriptedJSONProvider(_PARSE_PAYLOAD)
|
||||
parsed = await parse_resume(provider, model=MODEL, text="resume text")
|
||||
assert parsed == ParsedProfile.model_validate(_PARSE_PAYLOAD)
|
||||
|
||||
async def test_extra_forbid_rejects_non_matching(self) -> None:
|
||||
"""The P2-3 honesty lesson: a canned {"summary":...} reply must
|
||||
NOT validate as all-defaults."""
|
||||
provider = ScriptedJSONProvider({"summary": "not a profile"})
|
||||
from ai_service.agents.structured import StructuredOutputError
|
||||
|
||||
with pytest.raises(StructuredOutputError):
|
||||
await parse_resume(provider, model=MODEL, text="t")
|
||||
|
||||
async def test_char_cap_truncates(self) -> None:
|
||||
provider = ScriptedJSONProvider(_PARSE_PAYLOAD)
|
||||
# A huge text rides the call — the cap keeps the prompt bounded;
|
||||
# parse still succeeds (mock ignores content).
|
||||
await parse_resume(provider, model=MODEL, text="x" * 500_000)
|
||||
|
||||
|
||||
class TestResumeRoutes:
|
||||
def _client(
|
||||
self, tmp_path, marketplace_store, identity_store, provider
|
||||
) -> TestClient:
|
||||
from ai_service.config import Settings
|
||||
from ai_service.main import create_app
|
||||
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["pilot-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=ADMIN_TOKEN,
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity_store
|
||||
app.state.marketplace_store = marketplace_store
|
||||
app.state.provider = provider
|
||||
return TestClient(app)
|
||||
|
||||
def _with_client(self, tmp_path, marketplace_store, identity_store, provider):
|
||||
"""Lifespan-managed client (app.state.settings et al. live in the
|
||||
lifespan — the conftest `with make_client(...)` pattern)."""
|
||||
return self._client(tmp_path, marketplace_store, identity_store, provider)
|
||||
|
||||
def test_bounds_matrix(self, tmp_path, marketplace_store, identity_store) -> None:
|
||||
"""MH-4d: PDF/TXT/MD only (other → 422), >2 MB → 413, empty
|
||||
file → 422, PDF-without-text-layer → 422 text-layer message."""
|
||||
with self._client(
|
||||
tmp_path, marketplace_store, identity_store, ScriptedJSONProvider(_PARSE_PAYLOAD)
|
||||
) as client:
|
||||
base = {"learner_id": "pilot-learner"}
|
||||
|
||||
# TXT happy path → draft
|
||||
r = client.post(
|
||||
"/v1/marketplace/resume",
|
||||
data=base,
|
||||
files={"file": ("resume.txt", b"skills: python", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "draft"
|
||||
assert r.json()["profile"]["skills"] == ["python", "llm evaluation", "rag"]
|
||||
|
||||
# wrong extension/content-type → 422
|
||||
r = client.post(
|
||||
"/v1/marketplace/resume",
|
||||
data=base,
|
||||
files={"file": ("resume.docx", b"pk\x03\x04", "application/octet-stream")},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
# > 2 MB → 413
|
||||
big = b"x" * (MAX_RESUME_BYTES + 1)
|
||||
r = client.post(
|
||||
"/v1/marketplace/resume",
|
||||
data=base,
|
||||
files={"file": ("big.txt", big, "text/plain")},
|
||||
)
|
||||
assert r.status_code == 413
|
||||
|
||||
# empty file → 422
|
||||
r = client.post(
|
||||
"/v1/marketplace/resume",
|
||||
data=base,
|
||||
files={"file": ("empty.txt", b"", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
# image-only PDF (no text layer) → 422 naming text layer
|
||||
r = client.post(
|
||||
"/v1/marketplace/resume",
|
||||
data=base,
|
||||
files={"file": ("scan.pdf", _make_pdf(tmp_path, ["", ""]), "application/pdf")},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert "text layer required" in r.json()["detail"]
|
||||
|
||||
def test_gates_compose_in_order(
|
||||
self, tmp_path, marketplace_store, identity_store
|
||||
) -> None:
|
||||
"""D-043 order on the resume surface: stranger 403 first, then
|
||||
verify-CTA, then caps (the _check_apply_rate window)."""
|
||||
with self._client(
|
||||
tmp_path, marketplace_store, identity_store, ScriptedJSONProvider(_PARSE_PAYLOAD)
|
||||
) as client:
|
||||
# stranger (not allowlisted) → 403 G-5
|
||||
r = client.post(
|
||||
"/v1/marketplace/resume",
|
||||
data={"learner_id": "stranger"},
|
||||
files={"file": ("r.txt", b"text", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
# unverified (allowlisted but no identity record) → 403 CTA
|
||||
r = client.post(
|
||||
"/v1/marketplace/resume",
|
||||
data={"learner_id": "pilot-learner-unverified"},
|
||||
files={"file": ("r.txt", b"text", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_draft_then_confirm_flow(
|
||||
self, tmp_path, marketplace_store, identity_store
|
||||
) -> None:
|
||||
"""MH-4e: parse returns status=draft, NOTHING persists; confirm
|
||||
persists to the learner_profile store table; re-confirm
|
||||
replaces (upsert)."""
|
||||
with self._client(
|
||||
tmp_path, marketplace_store, identity_store, ScriptedJSONProvider(_PARSE_PAYLOAD)
|
||||
) as client:
|
||||
# Draft does NOT persist:
|
||||
r = client.post(
|
||||
"/v1/marketplace/resume",
|
||||
data={"learner_id": "pilot-learner"},
|
||||
files={"file": ("r.txt", b"text", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert marketplace_store.get_learner_profile("pilot-learner") is None
|
||||
|
||||
# Confirm persists:
|
||||
profile = r.json()["profile"]
|
||||
r2 = client.post(
|
||||
"/v1/marketplace/resume/confirm",
|
||||
json={"learner_id": "pilot-learner", "profile": profile},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["status"] == "confirmed"
|
||||
stored = marketplace_store.get_learner_profile("pilot-learner")
|
||||
assert stored is not None and stored["profile"]["skills"] == profile["skills"]
|
||||
|
||||
# Re-confirm replaces (upsert):
|
||||
r3 = client.post(
|
||||
"/v1/marketplace/resume/confirm",
|
||||
json={
|
||||
"learner_id": "pilot-learner",
|
||||
"profile": {**profile, "headline": "Updated"},
|
||||
},
|
||||
)
|
||||
assert r3.status_code == 200
|
||||
stored2 = marketplace_store.get_learner_profile("pilot-learner")
|
||||
assert stored2 is not None and stored2["profile"]["headline"] == "Updated"
|
||||
|
||||
def test_raw_bytes_never_logged(
|
||||
self, tmp_path, marketplace_store, identity_store, caplog
|
||||
) -> None:
|
||||
"""MH-4e PII sentinel: resume bytes/sentinel strings appear in
|
||||
NO log record (A-305)."""
|
||||
sentinel = "ZZ-SECRET-RESUME-BYTES-9173"
|
||||
with self._client(
|
||||
tmp_path, marketplace_store, identity_store, ScriptedJSONProvider(_PARSE_PAYLOAD)
|
||||
) as client:
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
client.post(
|
||||
"/v1/marketplace/resume",
|
||||
data={"learner_id": "pilot-learner"},
|
||||
files={
|
||||
"file": ("r.txt", f"contact {sentinel} phone".encode(), "text/plain")
|
||||
},
|
||||
)
|
||||
assert all(sentinel not in (r.getMessage() or "") for r in caplog.records)
|
||||
for record in caplog.records:
|
||||
assert sentinel not in str(record.__dict__)
|
||||
|
||||
def test_provider_failure_honest_502(
|
||||
self, tmp_path, marketplace_store, identity_store
|
||||
) -> None:
|
||||
provider = MockProvider()
|
||||
provider.fail_before_first_token = True
|
||||
with self._client(tmp_path, marketplace_store, identity_store, provider) as client:
|
||||
r = client.post(
|
||||
"/v1/marketplace/resume",
|
||||
data={"learner_id": "pilot-learner"},
|
||||
files={"file": ("r.txt", b"text", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 502
|
||||
assert "extraction failed" in r.json()["detail"]
|
||||
assert marketplace_store.get_learner_profile("pilot-learner") is None
|
||||
|
||||
def test_confirm_revalidates_shape(
|
||||
self, tmp_path, marketplace_store, identity_store
|
||||
) -> None:
|
||||
"""Verifier P1 fix pin: the confirm route RE-VALIDATES against
|
||||
ParsedProfile — arbitrary/type-confused payloads 422, never
|
||||
persist (the docstring claim is now true)."""
|
||||
with self._client(
|
||||
tmp_path, marketplace_store, identity_store, ScriptedJSONProvider(_PARSE_PAYLOAD)
|
||||
) as client:
|
||||
r = client.post(
|
||||
"/v1/marketplace/resume/confirm",
|
||||
json={
|
||||
"learner_id": "pilot-learner",
|
||||
"profile": {"skills": "not-a-list", "evil": "<script>"},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"]["reason"] == "invalid_profile_shape"
|
||||
assert marketplace_store.get_learner_profile("pilot-learner") is None
|
||||
|
||||
# A VALID parse-shaped profile passes:
|
||||
r2 = client.post(
|
||||
"/v1/marketplace/resume/confirm",
|
||||
json={"learner_id": "pilot-learner", "profile": _PARSE_PAYLOAD},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
stored = marketplace_store.get_learner_profile("pilot-learner")
|
||||
assert stored is not None and stored["profile"]["name"] == "Jordan Rivera"
|
||||
@@ -0,0 +1,664 @@
|
||||
"""Search pipeline tests (MH-3d/3e/3f, REQ-6-003, D-051/A-405).
|
||||
|
||||
Hermeticity (MH-M5): tmp_path db everywhere; the mock embedding
|
||||
provider only — zero network, zero cloud.
|
||||
|
||||
Pins:
|
||||
- cosine zero-vector guard + pure-Python math
|
||||
- hybrid ordering: facet hard-filters run BEFORE ranking (a
|
||||
facet-excluded posting NEVER appears, however high its cosine)
|
||||
- no facets → pure semantic rank over published postings
|
||||
- empty q + facets → facet-only listing (posted_at DESC), no scores
|
||||
- embed-on-publish stores the vector; failure = honest warning +
|
||||
posting still published + search skips it
|
||||
- lazy re-embed on model/dim change (stale row refreshes on access)
|
||||
- candidate-side (query) embeddings never stored
|
||||
- skill_breakdown deterministic (overlap + contribution formula)
|
||||
- search API route: empty q → match_score null; ranked order; the
|
||||
skill_breakdown payload; G-107 (no matchScore key anywhere)
|
||||
- G-11 boot fallback: openai-embed unconfigured → app boots with mock
|
||||
+ loud log
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.main import create_app
|
||||
from ai_service.marketplace.base import IngestPosting, Posting, PostingTags
|
||||
from ai_service.marketplace.embedding import (
|
||||
MOCK_EMBED_DIM,
|
||||
MOCK_EMBED_MODEL,
|
||||
MockEmbedProvider,
|
||||
embed_text,
|
||||
)
|
||||
from ai_service.marketplace.search import (
|
||||
FacetFilters,
|
||||
cosine_similarity,
|
||||
embed_posting,
|
||||
posting_embedding_text,
|
||||
rank_postings,
|
||||
skill_breakdown,
|
||||
)
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import ADMIN_TOKEN, make_client
|
||||
|
||||
# -- helpers --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _posting(
|
||||
pid: str,
|
||||
title: str,
|
||||
description: str,
|
||||
skills: list[str],
|
||||
*,
|
||||
seniority: str = "mid",
|
||||
status: str = "published",
|
||||
posted_at: datetime | None = None,
|
||||
salary_min: int | None = None,
|
||||
salary_max: int | None = None,
|
||||
remote: bool = False,
|
||||
location: str = "",
|
||||
category: str | None = None,
|
||||
) -> Posting:
|
||||
return Posting(
|
||||
id=pid,
|
||||
source="seed-corpus",
|
||||
external_id=pid,
|
||||
employer_id="emp-x",
|
||||
title=title,
|
||||
description=description,
|
||||
skills=skills,
|
||||
seniority=seniority, # type: ignore[arg-type]
|
||||
category=category,
|
||||
location=location,
|
||||
remote=remote,
|
||||
salary_min=salary_min,
|
||||
salary_max=salary_max,
|
||||
status=status, # type: ignore[arg-type]
|
||||
tags=PostingTags(skills=skills, seniority=seniority, category=category), # type: ignore[arg-type]
|
||||
posted_at=posted_at or datetime(2026, 9, 1, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _seed(store: SQLiteMarketplaceStore) -> list[Posting]:
|
||||
"""Three published postings (two python-ish, one gardening) + one
|
||||
draft python posting (must NEVER surface on the board/search)."""
|
||||
rows = [
|
||||
_posting(
|
||||
"py-flask",
|
||||
"Python Flask Backend",
|
||||
"python flask web backend rest api engineer",
|
||||
["python", "flask"],
|
||||
),
|
||||
_posting(
|
||||
"py-senior",
|
||||
"Senior Python Engineer",
|
||||
"python flask backend developer mentoring",
|
||||
["python"],
|
||||
seniority="senior",
|
||||
salary_min=100,
|
||||
salary_max=200,
|
||||
),
|
||||
_posting(
|
||||
"garden",
|
||||
"Head Gardener",
|
||||
"gardening roses hedges pruning seasons",
|
||||
["gardening"],
|
||||
),
|
||||
_posting(
|
||||
"py-draft",
|
||||
"Draft Python Role",
|
||||
"python flask backend",
|
||||
["python"],
|
||||
status="draft",
|
||||
),
|
||||
]
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
store.upsert_posting(
|
||||
IngestPosting(**p.model_dump(exclude={"tags", "status"})),
|
||||
status="published",
|
||||
)
|
||||
store.set_posting_tags(p.id, p.tags or PostingTags())
|
||||
else:
|
||||
store.insert_posting(p)
|
||||
return rows
|
||||
|
||||
|
||||
# -- cosine math (MH-3d corner cases) -------------------------------------------
|
||||
|
||||
|
||||
class TestCosine:
|
||||
def test_orthogonal_scores_zero(self) -> None:
|
||||
va = embed_text("python")
|
||||
vb = embed_text("gardening") # disjoint tokens, different indices
|
||||
assert cosine_similarity(va, vb) == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
def test_identical_scores_one(self) -> None:
|
||||
v = embed_text("python flask backend")
|
||||
assert cosine_similarity(v, v) == pytest.approx(1.0, abs=1e-9)
|
||||
|
||||
def test_zero_vector_guard_never_divides_by_zero(self) -> None:
|
||||
assert cosine_similarity([0.0] * 8, [1.0] * 8) == 0.0
|
||||
assert cosine_similarity([1.0] * 8, [0.0] * 8) == 0.0
|
||||
assert cosine_similarity([], [1.0]) == 0.0
|
||||
|
||||
def test_length_mismatch_guard(self) -> None:
|
||||
assert cosine_similarity([1.0, 2.0], [1.0, 2.0, 3.0]) == 0.0
|
||||
|
||||
def test_hand_computed_value(self) -> None:
|
||||
# cosine([1,0],[1,1]) = 1/√2 — the formula, not vibes
|
||||
assert cosine_similarity([1.0, 0.0], [1.0, 1.0]) == pytest.approx(
|
||||
0.7071067811865475, abs=1e-9
|
||||
)
|
||||
|
||||
|
||||
# -- ranking pipeline (MH-3d/3e) -------------------------------------------------
|
||||
|
||||
|
||||
class TestRanking:
|
||||
async def test_pure_semantic_rank_no_facets(self, marketplace_store) -> None:
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
await embed_posting(store, provider, p)
|
||||
ranked = await rank_postings(store, provider, "python flask backend")
|
||||
ids = [r.posting.id for r in ranked]
|
||||
# published python postings rank above gardening; draft NEVER appears
|
||||
assert ids[0] in ("py-flask", "py-senior")
|
||||
assert "py-draft" not in ids
|
||||
assert ids.index("garden") > ids.index("py-flask")
|
||||
assert ranked[0].score >= ranked[1].score >= ranked[2].score
|
||||
|
||||
async def test_ranked_order_is_score_desc_then_id(
|
||||
self, marketplace_store
|
||||
) -> None:
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
await embed_posting(store, provider, p)
|
||||
ranked = await rank_postings(store, provider, "python flask backend")
|
||||
scores = [r.score for r in ranked]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
async def test_facet_filters_before_ranking_excluded_never_appears(
|
||||
self, marketplace_store
|
||||
) -> None:
|
||||
"""MH-3d probe: 'garden' shares ZERO tokens with the query (its
|
||||
cosine would be 0.0) — so use the STRONGER probe: exclude a
|
||||
HIGH-cosine posting via a facet and assert absence."""
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
await embed_posting(store, provider, p)
|
||||
# py-flask has HIGH cosine vs the query but seniority=mid — a
|
||||
# seniority=senior facet must exclude it despite the score.
|
||||
ranked = await rank_postings(
|
||||
store, provider, "python flask web backend", FacetFilters(seniority="senior")
|
||||
)
|
||||
ids = [r.posting.id for r in ranked]
|
||||
assert "py-senior" in ids
|
||||
assert "py-flask" not in ids, "facet-excluded posting must never appear"
|
||||
assert all(r.posting.seniority == "senior" for r in ranked)
|
||||
|
||||
async def test_skills_facet_intersects_tags(self, marketplace_store) -> None:
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
await embed_posting(store, provider, p)
|
||||
ranked = await rank_postings(
|
||||
store, provider, "python backend", FacetFilters(skills=["flask"])
|
||||
)
|
||||
assert [r.posting.id for r in ranked] == ["py-flask"]
|
||||
|
||||
async def test_no_facets_ranks_all_published(self, marketplace_store) -> None:
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
await embed_posting(store, provider, p)
|
||||
ranked = await rank_postings(store, provider, "python")
|
||||
# every published posting appears (garden at score 0.0, honest)
|
||||
assert {r.posting.id for r in ranked} == {"py-flask", "py-senior", "garden"}
|
||||
|
||||
async def test_empty_query_facet_only_listing_no_scores(
|
||||
self, marketplace_store
|
||||
) -> None:
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
await embed_posting(store, provider, p)
|
||||
ranked = await rank_postings(store, provider, "", FacetFilters(skills=["python"]))
|
||||
# posted_at order (all equal → id ASC tiebreak), scores are 0.0
|
||||
# and breakdowns empty — no query, no fabricated scores (G-106)
|
||||
assert [r.posting.id for r in ranked] == ["py-flask", "py-senior"]
|
||||
assert all(r.score == 0.0 for r in ranked)
|
||||
assert all(r.skill_breakdown == [] for r in ranked)
|
||||
|
||||
async def test_salary_band_facet_overlaps(self, marketplace_store) -> None:
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
await embed_posting(store, provider, p)
|
||||
ranked = await rank_postings(
|
||||
store, provider, "python", FacetFilters(salary_min=150)
|
||||
)
|
||||
# py-senior advertises 100..200 (overlaps 150+); others have no
|
||||
# salary band → excluded (honest: no advertised band = no match)
|
||||
assert [r.posting.id for r in ranked] == ["py-senior"]
|
||||
ranked2 = await rank_postings(
|
||||
store, provider, "python", FacetFilters(salary_max=150)
|
||||
)
|
||||
# py-senior's band starts at 100 ≤ 150 → qualifies; postings
|
||||
# with NO advertised band stay excluded (honest absence)
|
||||
assert [r.posting.id for r in ranked2] == ["py-senior"]
|
||||
|
||||
async def test_unstored_vector_posting_skipped(self, marketplace_store) -> None:
|
||||
"""Embed-on-publish failure path: a published posting with NO
|
||||
stored vector is SKIPPED — honest absence, never a fabricated
|
||||
direction."""
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
await embed_posting(store, provider, rows[0]) # ONLY py-flask embedded
|
||||
ranked = await rank_postings(store, provider, "python flask backend")
|
||||
assert [r.posting.id for r in ranked] == ["py-flask"]
|
||||
|
||||
|
||||
# -- embed-on-publish + lazy re-embed (MH-3e) ------------------------------------
|
||||
|
||||
|
||||
class TestEmbedOnPublish:
|
||||
async def test_embed_posting_stores_vector(self, marketplace_store) -> None:
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
ok = await embed_posting(store, provider, rows[0])
|
||||
assert ok is True
|
||||
stored = store.get_vector("py-flask")
|
||||
assert stored is not None
|
||||
vals, model, dim = stored
|
||||
assert (model, dim) == (MOCK_EMBED_MODEL, MOCK_EMBED_DIM)
|
||||
assert len(vals) == MOCK_EMBED_DIM
|
||||
|
||||
async def test_embed_text_is_title_plus_description_plus_tag_skills(
|
||||
self, marketplace_store
|
||||
) -> None:
|
||||
posting = _posting(
|
||||
"p",
|
||||
"Title Text",
|
||||
"Body text here",
|
||||
["alpha", "beta"],
|
||||
)
|
||||
text = posting_embedding_text(posting)
|
||||
assert text == "Title Text\nBody text here\nalpha\nbeta"
|
||||
|
||||
async def test_embed_failure_is_honest_warning_not_fatal(
|
||||
self, marketplace_store, caplog
|
||||
) -> None:
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
|
||||
class ExplodingProvider(MockEmbedProvider):
|
||||
async def embed(self, texts):
|
||||
raise RuntimeError("embedding backend down")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ok = await embed_posting(store, ExplodingProvider(), rows[0])
|
||||
assert ok is False
|
||||
assert store.get_vector("py-flask") is None
|
||||
assert any("embedding failed" in r.message for r in caplog.records)
|
||||
# the posting itself is still published (failure never rolls back)
|
||||
assert store.get_posting("py-flask").status == "published"
|
||||
|
||||
async def test_lazy_reembed_on_model_change(self, marketplace_store) -> None:
|
||||
"""MH-3e: a stored row whose model ≠ the provider's re-embeds +
|
||||
re-stores on the next rank (A-403/A-404 self-healing)."""
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
await embed_posting(store, provider, p)
|
||||
# simulate a provider switch: poison one row with a foreign model
|
||||
store.put_vector("py-senior", [0.5] * 8, "old-model", 8)
|
||||
assert store.get_vector("py-senior")[1] == "old-model"
|
||||
|
||||
ranked = await rank_postings(store, provider, "python flask backend")
|
||||
ids = [r.posting.id for r in ranked]
|
||||
assert "py-senior" in ids # re-embedded back into comparability
|
||||
vals, model, dim = store.get_vector("py-senior")
|
||||
assert (model, dim) == (MOCK_EMBED_MODEL, MOCK_EMBED_DIM)
|
||||
# and it ranks against the FRESH vector, not the stale one
|
||||
senior_entry = next(r for r in ranked if r.posting.id == "py-senior")
|
||||
assert senior_entry.score == pytest.approx(
|
||||
cosine_similarity(
|
||||
embed_text("python flask backend"),
|
||||
embed_text(posting_embedding_text(rows[1])),
|
||||
),
|
||||
abs=1e-9,
|
||||
)
|
||||
|
||||
async def test_candidate_side_query_embedding_never_stored(
|
||||
self, marketplace_store
|
||||
) -> None:
|
||||
"""MH-3e: the query (candidate-side) embedding is computed on
|
||||
demand and NEVER written to the embedding table."""
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
await embed_posting(store, provider, p)
|
||||
before = {pid for pid, *_ in store.list_vectors()}
|
||||
await rank_postings(store, provider, "an entirely novel query text")
|
||||
after = {pid for pid, *_ in store.list_vectors()}
|
||||
assert before == after # nothing new stored — postings only
|
||||
|
||||
|
||||
# -- skill_breakdown determinism (MH-3f) -----------------------------------------
|
||||
|
||||
|
||||
class TestSkillBreakdown:
|
||||
def test_overlap_and_contribution_formula_deterministic(self) -> None:
|
||||
posting = _posting(
|
||||
"p", "T", "D", ["python", "flask", "gardening"]
|
||||
)
|
||||
query_tokens = {"python", "backend"}
|
||||
posting_vector = embed_text("python flask gardening")
|
||||
entries = skill_breakdown(posting, query_tokens, posting_vector)
|
||||
# deterministic order: contribution DESC, skill ASC — python is
|
||||
# the only overlapping skill (contribution > 0), the two
|
||||
# non-overlapping skills tie at 0.0 and order alphabetically
|
||||
assert [e["skill"] for e in entries] == ["python", "flask", "gardening"]
|
||||
by_skill = {e["skill"]: e for e in entries}
|
||||
assert by_skill["python"]["overlap"] == 1 # token in query
|
||||
assert by_skill["flask"]["overlap"] == 0 # not in query
|
||||
assert by_skill["gardening"]["overlap"] == 0
|
||||
# overlap=1 → contribution = cosine(embed_text(skill), posting_vector)
|
||||
assert by_skill["python"]["contribution"] == pytest.approx(
|
||||
cosine_similarity(embed_text("python"), posting_vector), abs=1e-9
|
||||
)
|
||||
# overlap=0 → contribution 0.0 (never fabricated)
|
||||
assert by_skill["flask"]["contribution"] == 0.0
|
||||
assert by_skill["gardening"]["contribution"] == 0.0
|
||||
|
||||
def test_untagged_posting_gets_empty_breakdown(self) -> None:
|
||||
posting = _posting("p", "T", "D", [])
|
||||
posting = posting.model_copy(update={"tags": None})
|
||||
assert skill_breakdown(posting, {"python"}, [0.1] * 4) == []
|
||||
|
||||
async def test_breakdown_rides_ranked_results(self, marketplace_store) -> None:
|
||||
store = marketplace_store
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
await embed_posting(store, provider, p)
|
||||
ranked = await rank_postings(store, provider, "python flask")
|
||||
top = ranked[0]
|
||||
assert top.skill_breakdown, "tagged posting must carry a breakdown"
|
||||
for entry in top.skill_breakdown:
|
||||
assert set(entry) == {"skill", "overlap", "contribution"}
|
||||
assert entry["overlap"] in (0, 1)
|
||||
assert entry["contribution"] >= 0.0
|
||||
|
||||
|
||||
# -- search API route (MH-3d route half; G-106/G-107) -----------------------------
|
||||
|
||||
|
||||
class TestSearchRoute:
|
||||
@pytest.fixture()
|
||||
def search_client(
|
||||
self, tmp_path, identity_store, marketplace_store
|
||||
) -> TestClient:
|
||||
"""Client whose store is seeded, embedded, and ready to search."""
|
||||
import asyncio as _asyncio
|
||||
|
||||
rows = _seed(marketplace_store)
|
||||
provider = MockEmbedProvider()
|
||||
for p in rows:
|
||||
if p.status == "published":
|
||||
_asyncio.run(embed_posting(marketplace_store, provider, p))
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
def test_search_ranked_results_ordered(self, search_client) -> None:
|
||||
resp = search_client.get(
|
||||
"/v1/marketplace/search", params={"q": "python flask backend"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["query"] == "python flask backend"
|
||||
assert body["ranked"] == 3
|
||||
scores = [r["match_score"] for r in body["results"]]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
assert all(s is not None for s in scores)
|
||||
ids = [r["posting"]["id"] for r in body["results"]]
|
||||
assert "py-draft" not in ids # published only
|
||||
assert ids[0] in ("py-flask", "py-senior")
|
||||
# posting payload is PostingResponse-shaped
|
||||
top = body["results"][0]["posting"]
|
||||
for key in (
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"skills",
|
||||
"status",
|
||||
"posted_at",
|
||||
):
|
||||
assert key in top
|
||||
# G-107: the raw TS-fixture matchScore is ABSENT from payloads
|
||||
assert "matchScore" not in top
|
||||
|
||||
def test_search_empty_q_match_score_null(self, search_client) -> None:
|
||||
resp = search_client.get(
|
||||
"/v1/marketplace/search", params={"q": "", "skills": ["python"]}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["ranked"] == 2
|
||||
for result in body["results"]:
|
||||
assert result["match_score"] is None # G-106 honest absence
|
||||
assert result["skill_breakdown"] == []
|
||||
|
||||
def test_search_facet_params_hard_filter(self, search_client) -> None:
|
||||
resp = search_client.get(
|
||||
"/v1/marketplace/search",
|
||||
params={"q": "python flask backend", "seniority": "senior"},
|
||||
)
|
||||
body = resp.json()
|
||||
ids = [r["posting"]["id"] for r in body["results"]]
|
||||
assert ids == ["py-senior"]
|
||||
|
||||
def test_search_salary_params(self, search_client) -> None:
|
||||
resp = search_client.get(
|
||||
"/v1/marketplace/search", params={"q": "python", "salary_min": "150"}
|
||||
)
|
||||
assert [r["posting"]["id"] for r in resp.json()["results"]] == ["py-senior"]
|
||||
|
||||
def test_search_limit_param(self, search_client) -> None:
|
||||
resp = search_client.get(
|
||||
"/v1/marketplace/search",
|
||||
params={"q": "python", "limit": "1"},
|
||||
)
|
||||
assert resp.json()["ranked"] == 1
|
||||
|
||||
def test_search_skill_breakdown_payload_deterministic(self, search_client) -> None:
|
||||
resp = search_client.get(
|
||||
"/v1/marketplace/search", params={"q": "python flask"}
|
||||
)
|
||||
top = resp.json()["results"][0]
|
||||
assert top["skill_breakdown"]
|
||||
for entry in top["skill_breakdown"]:
|
||||
assert set(entry) == {"skill", "overlap", "contribution"}
|
||||
# a second identical call returns the identical breakdown
|
||||
resp2 = search_client.get(
|
||||
"/v1/marketplace/search", params={"q": "python flask"}
|
||||
)
|
||||
assert (
|
||||
resp2.json()["results"][0]["skill_breakdown"]
|
||||
== top["skill_breakdown"]
|
||||
)
|
||||
|
||||
def test_search_multiskill_param_repeats(self, search_client) -> None:
|
||||
resp = search_client.get(
|
||||
"/v1/marketplace/search",
|
||||
params=[("q", "python"), ("skills", "flask"), ("skills", "gardening")],
|
||||
)
|
||||
ids = [r["posting"]["id"] for r in resp.json()["results"]]
|
||||
assert set(ids) == {"py-flask", "garden"}
|
||||
|
||||
def test_no_vector_posting_honestly_skipped_in_route(self, tmp_path, identity_store):
|
||||
"""A published-but-unembedded posting never surfaces with a
|
||||
fabricated score — the route skips it like rank_postings does."""
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "route.db")
|
||||
rows = _seed(store)
|
||||
provider = MockEmbedProvider()
|
||||
import asyncio as _asyncio
|
||||
|
||||
_asyncio.run(embed_posting(store, provider, rows[0])) # only py-flask
|
||||
with make_client(tmp_path, identity_store, store, admin_token=ADMIN_TOKEN) as c:
|
||||
resp = c.get("/v1/marketplace/search", params={"q": "python"})
|
||||
body = resp.json()
|
||||
assert [r["posting"]["id"] for r in body["results"]] == ["py-flask"]
|
||||
store.close()
|
||||
|
||||
|
||||
# -- G-11 boot-safe fallback (the main.py half of MH-3c) ---------------------------
|
||||
|
||||
|
||||
class TestBootFallback:
|
||||
def test_openai_embed_unconfigured_boots_with_mock_and_loud_log(
|
||||
self, tmp_path, identity_store, caplog
|
||||
) -> None:
|
||||
"""G-11: a typo'd/unconfigured AI_EMBED_PROVIDER=openai-embed must
|
||||
NEVER crash the boot — the app comes up with the mock provider
|
||||
and a loud warning naming the fix."""
|
||||
from ai_service.config import Settings
|
||||
from tests.conftest import SUITE_LEARNERS
|
||||
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "boot.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
embed_provider="openai-embed", # UNCONFIGURED: no url/key/model
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity_store
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/health").status_code == 200
|
||||
from ai_service.marketplace.embedding import MockEmbedProvider
|
||||
|
||||
assert isinstance(
|
||||
app.state.embedding_provider, MockEmbedProvider
|
||||
)
|
||||
assert any(
|
||||
"embedding provider" in r.message and "falling back to mock" in r.message
|
||||
for r in caplog.records
|
||||
), "the fallback must be LOUD (G-11)"
|
||||
|
||||
def test_state_injection_override_preserved(self, tmp_path, identity_store) -> None:
|
||||
"""Tests may pre-set app.state.embedding_provider; the lifespan
|
||||
adopts it (the voice-provider pattern)."""
|
||||
from ai_service.config import Settings
|
||||
from tests.conftest import SUITE_LEARNERS
|
||||
|
||||
class MarkerProvider(MockEmbedProvider):
|
||||
pass
|
||||
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "inj.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
)
|
||||
app = create_app(settings)
|
||||
marker = MarkerProvider()
|
||||
app.state.embedding_provider = marker
|
||||
with TestClient(app):
|
||||
assert app.state.embedding_provider is marker
|
||||
|
||||
def test_embed_on_publish_route_stores_vector(
|
||||
self, tmp_path, identity_store
|
||||
) -> None:
|
||||
"""The admin publish route embeds on publish (Task 3-2-01 end
|
||||
state): a draft → publish call leaves a stored vector."""
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "pub.db")
|
||||
draft = _posting(
|
||||
"adm-1", "Admin Draft Role", "python flask admin authored", ["python"], status="draft"
|
||||
)
|
||||
store.insert_posting(draft)
|
||||
with make_client(
|
||||
tmp_path, identity_store, store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
resp = c.post(
|
||||
"/v1/marketplace/admin/postings/adm-1/publish",
|
||||
headers={"X-Admin-Token": ADMIN_TOKEN},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert store.get_vector("adm-1") is not None
|
||||
store.close()
|
||||
|
||||
def test_ingest_route_embeds_published_seed_postings(
|
||||
self, tmp_path, identity_store
|
||||
) -> None:
|
||||
"""The ingest route's embed-on-publish path (Task 3-2-01): every
|
||||
PUBLISHED seed posting carries a stored vector after the run —
|
||||
the board's search corpus is populated, not just the rows."""
|
||||
from ai_service.config import Settings
|
||||
from ai_service.llm.mock import ScriptedJSONProvider
|
||||
from tests.conftest import SUITE_LEARNERS
|
||||
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "ing.db")
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "app.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=ADMIN_TOKEN,
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity_store
|
||||
app.state.marketplace_store = store
|
||||
app.state.provider = ScriptedJSONProvider(
|
||||
{"skills": ["python"], "seniority": "mid", "category": "data"}
|
||||
)
|
||||
with TestClient(app) as c:
|
||||
resp = c.post(
|
||||
"/v1/marketplace/admin/ingest", headers={"X-Admin-Token": ADMIN_TOKEN}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
vectors = {pid for pid, *_ in store.list_vectors()}
|
||||
assert len(vectors) == 20 # every published seed posting embedded
|
||||
# search over the ingested corpus finds python postings
|
||||
import asyncio as _a
|
||||
|
||||
provider = app.state.embedding_provider
|
||||
ranked = _a.run(rank_postings(store, provider, "python"))
|
||||
assert ranked, "ingested corpus must be searchable"
|
||||
assert all(r.posting.id in vectors for r in ranked)
|
||||
store.close()
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Marketplace store contract tests (MH-1a, D-049/G-104).
|
||||
|
||||
Hermeticity: every fixture pins db_path to tmp_path — never
|
||||
~/.nextcraft, never the cloud (MH-M5).
|
||||
|
||||
Pins: six tables created on the shared file; unique constraints fire on
|
||||
duplicates ((source, external_id) posting + employer, (posting_id,
|
||||
learner_id) application); WAL/synchronous=NORMAL/busy_timeout/
|
||||
foreign_keys pragmas; _validate/_normalize hooks; tz normalization;
|
||||
lifecycle transitions draft→published→closed with InvalidTransition on
|
||||
every other move.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ai_service.marketplace.base import Employer, IngestPosting, Posting
|
||||
from ai_service.marketplace.store import InvalidTransition, SQLiteMarketplaceStore
|
||||
|
||||
|
||||
def _posting(
|
||||
external_id: str = "job-x",
|
||||
source: str = "seed-corpus",
|
||||
posting_id: str | None = None,
|
||||
**overrides,
|
||||
) -> IngestPosting:
|
||||
"""A valid IngestPosting; `posting_id` defaults to external_id (the
|
||||
D-021 shared-id convention: id == external_id for seed ports)."""
|
||||
payload = dict(
|
||||
id=posting_id or external_id,
|
||||
source=source,
|
||||
external_id=external_id,
|
||||
employer_id="emp-openai",
|
||||
title="Test Posting",
|
||||
description="d",
|
||||
skills=["Python"],
|
||||
seniority="mid",
|
||||
location="Remote",
|
||||
remote=True,
|
||||
salary_min=100,
|
||||
salary_max=200,
|
||||
posted_at=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
)
|
||||
payload.update(overrides)
|
||||
return IngestPosting(**payload)
|
||||
|
||||
|
||||
class TestSchema:
|
||||
def test_six_tables_created_on_shared_db(self, tmp_path) -> None:
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "shared.db")
|
||||
with store._engine.connect() as conn: # noqa: SLF001
|
||||
names = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
)
|
||||
}
|
||||
store.close()
|
||||
for table in (
|
||||
"posting",
|
||||
"employer",
|
||||
"embedding",
|
||||
"application",
|
||||
"employer_enrichment",
|
||||
"learner_profile",
|
||||
):
|
||||
assert table in names, f"table {table} missing"
|
||||
|
||||
def test_pragmas_pinned(self, marketplace_store) -> None:
|
||||
"""WAL + synchronous=NORMAL + busy_timeout + foreign_keys — the
|
||||
identity/DefenseStore per-connection contract (D-027)."""
|
||||
with marketplace_store._engine.connect() as conn: # noqa: SLF001
|
||||
journal = conn.execute(text("PRAGMA journal_mode")).fetchone()[0]
|
||||
sync = conn.execute(text("PRAGMA synchronous")).fetchone()[0]
|
||||
busy = conn.execute(text("PRAGMA busy_timeout")).fetchone()[0]
|
||||
fk = conn.execute(text("PRAGMA foreign_keys")).fetchone()[0]
|
||||
assert str(journal).lower() == "wal"
|
||||
assert sync == 1 # NORMAL
|
||||
assert busy == 5000
|
||||
assert fk == 1
|
||||
|
||||
def test_create_schema_idempotent(self, marketplace_store) -> None:
|
||||
marketplace_store.create_schema() # second call is a no-op
|
||||
marketplace_store.create_schema()
|
||||
|
||||
|
||||
class TestPostingConstraints:
|
||||
def test_unique_source_external_id_fires(self, marketplace_store) -> None:
|
||||
"""Direct INSERT of a second (source, external_id) pair raises —
|
||||
the constraint exists beneath the upsert's backpressure."""
|
||||
marketplace_store.upsert_posting(_posting(), status="published")
|
||||
with pytest.raises(IntegrityError):
|
||||
with marketplace_store._engine.begin() as conn: # noqa: SLF001
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO posting (id, source, external_id, "
|
||||
"employer_id, title, status) VALUES "
|
||||
"('other-id', 'seed-corpus', 'job-x', 'emp-openai', 'Dup', 'draft')"
|
||||
)
|
||||
)
|
||||
|
||||
def test_upsert_posting_idempotent_same_row(self, marketplace_store) -> None:
|
||||
first = marketplace_store.upsert_posting(_posting(), status="published")
|
||||
second = marketplace_store.upsert_posting(
|
||||
_posting(title="Updated Title"), status="published"
|
||||
)
|
||||
# Same row overwritten in place — identical id, updated field,
|
||||
# still exactly ONE posting for the key.
|
||||
assert first.id == second.id
|
||||
assert second.title == "Updated Title"
|
||||
with marketplace_store._engine.connect() as conn: # noqa: SLF001
|
||||
count = conn.execute(
|
||||
text("SELECT COUNT(*) FROM posting WHERE external_id='job-x'")
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
def test_upsert_preserves_admin_lifecycle_status(self, marketplace_store) -> None:
|
||||
"""An admin's close decision outranks a seed re-run: the
|
||||
re-upsert overwrites CONTENT, never status."""
|
||||
marketplace_store.upsert_posting(_posting(), status="published")
|
||||
marketplace_store.set_posting_status("job-x", "closed")
|
||||
again = marketplace_store.upsert_posting(
|
||||
_posting(title="Seed re-run"), status="published"
|
||||
)
|
||||
assert again.status == "closed"
|
||||
assert again.title == "Seed re-run"
|
||||
|
||||
def test_insert_posting_duplicate_id_integrity_error(
|
||||
self, marketplace_store
|
||||
) -> None:
|
||||
posting = Posting(
|
||||
id="adm-1",
|
||||
source="admin",
|
||||
external_id="adm-1",
|
||||
employer_id="emp-openai",
|
||||
title="Admin Draft",
|
||||
)
|
||||
marketplace_store.insert_posting(posting)
|
||||
with pytest.raises(IntegrityError):
|
||||
marketplace_store.insert_posting(
|
||||
posting.model_copy(update={"title": "Second"})
|
||||
)
|
||||
|
||||
def test_validate_hooks(self, marketplace_store) -> None:
|
||||
with pytest.raises(ValueError, match="invalid posting status"):
|
||||
marketplace_store.upsert_posting(
|
||||
_posting(), status="banana" # type: ignore[arg-type]
|
||||
)
|
||||
with pytest.raises(ValueError, match="employer_id"):
|
||||
marketplace_store.upsert_posting(
|
||||
_posting(employer_id=""), status="draft"
|
||||
)
|
||||
# the STORE's seniority hook fires beneath pydantic's Literal:
|
||||
# a raw-row write path (future admin edit) cannot smuggle a
|
||||
# non-canonical value past the validates hook. (ORM-level hooks
|
||||
# run on model instances; the raw-SQL insert here must satisfy
|
||||
# NOT NULL columns first, so exercise the hook via a
|
||||
# constructed row instead.)
|
||||
from ai_service.marketplace.store import PostingRow
|
||||
|
||||
bad = PostingRow(
|
||||
id="bad-sen",
|
||||
source="s",
|
||||
external_id="bad-sen",
|
||||
employer_id="emp-openai",
|
||||
title="T",
|
||||
seniority="wizard",
|
||||
status="draft",
|
||||
)
|
||||
with pytest.raises(ValueError, match="invalid seniority"):
|
||||
bad._validate()
|
||||
|
||||
def test_tz_normalization_roundtrip(self, marketplace_store) -> None:
|
||||
"""Naive-when-stored, tz-aware-when-read (the D-027 boundary)."""
|
||||
marketplace_store.upsert_posting(_posting(), status="published")
|
||||
got = marketplace_store.get_posting("job-x")
|
||||
assert got is not None
|
||||
assert got.posted_at is not None
|
||||
assert got.posted_at.tzinfo is not None
|
||||
assert got.posted_at.utcoffset().total_seconds() == 0
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
def test_draft_publish_closed_happy_path(self, marketplace_store) -> None:
|
||||
posting = marketplace_store.insert_posting(
|
||||
Posting(
|
||||
id="adm-9",
|
||||
source="admin",
|
||||
external_id="adm-9",
|
||||
employer_id="emp-openai",
|
||||
title="Lifecycle",
|
||||
)
|
||||
)
|
||||
assert posting.status == "draft"
|
||||
assert posting.posted_at is None
|
||||
published = marketplace_store.set_posting_status("adm-9", "published")
|
||||
assert published is not None and published.status == "published"
|
||||
# Admin publish stamps NOW when the row has no posted_at.
|
||||
assert published.posted_at is not None
|
||||
closed = marketplace_store.set_posting_status("adm-9", "closed")
|
||||
assert closed is not None and closed.status == "closed"
|
||||
|
||||
def test_invalid_transitions_raise(self, marketplace_store) -> None:
|
||||
marketplace_store.upsert_posting(_posting("job-t"), status="draft")
|
||||
# draft → closed is invalid
|
||||
with pytest.raises(InvalidTransition):
|
||||
marketplace_store.set_posting_status("job-t", "closed")
|
||||
# draft → published ok; published → published invalid
|
||||
marketplace_store.set_posting_status("job-t", "published")
|
||||
with pytest.raises(InvalidTransition):
|
||||
marketplace_store.set_posting_status("job-t", "published")
|
||||
marketplace_store.set_posting_status("job-t", "closed")
|
||||
# closed → anything invalid (terminal)
|
||||
for target in ("draft", "published"):
|
||||
with pytest.raises(InvalidTransition):
|
||||
marketplace_store.set_posting_status("job-t", target)
|
||||
def test_unknown_posting_returns_none(self, marketplace_store) -> None:
|
||||
assert marketplace_store.set_posting_status("nope", "published") is None
|
||||
assert marketplace_store.get_posting("nope") is None
|
||||
|
||||
|
||||
class TestListPostings:
|
||||
def test_published_only_with_total(self, marketplace_store) -> None:
|
||||
for i in range(3):
|
||||
marketplace_store.upsert_posting(
|
||||
_posting(f"job-p{i}"), status="published"
|
||||
)
|
||||
marketplace_store.insert_posting(
|
||||
Posting(
|
||||
id="adm-draft",
|
||||
source="admin",
|
||||
external_id="adm-draft",
|
||||
employer_id="emp-openai",
|
||||
title="Hidden Draft",
|
||||
)
|
||||
)
|
||||
rows, total = marketplace_store.list_postings(limit=2, offset=0)
|
||||
assert total == 3 # G-103: draft excluded from the honest count
|
||||
assert len(rows) == 2
|
||||
page2, total2 = marketplace_store.list_postings(limit=2, offset=2)
|
||||
assert total2 == 3
|
||||
assert len(page2) == 1
|
||||
|
||||
def test_order_posted_at_desc(self, marketplace_store) -> None:
|
||||
for day, pid in ((1, "old"), (5, "new")):
|
||||
marketplace_store.upsert_posting(
|
||||
_posting(pid, posted_at=datetime(2026, 9, day, tzinfo=UTC)),
|
||||
status="published",
|
||||
)
|
||||
rows, _ = marketplace_store.list_postings(limit=10, offset=0)
|
||||
assert [r.id for r in rows] == ["new", "old"]
|
||||
|
||||
|
||||
class TestEmployers:
|
||||
def test_employer_roundtrip_and_idempotent_upsert(self, marketplace_store) -> None:
|
||||
employer = Employer(
|
||||
id="emp-openai",
|
||||
source="seed-corpus",
|
||||
external_id="emp-openai",
|
||||
name="OpenAI Labs",
|
||||
website="https://example.com/openai",
|
||||
social_links={"twitter": "https://example.com/openai/x"},
|
||||
)
|
||||
first = marketplace_store.upsert_employer(employer)
|
||||
second = marketplace_store.upsert_employer(
|
||||
employer.model_copy(update={"name": "OpenAI Labs Updated"})
|
||||
)
|
||||
assert first.id == second.id
|
||||
assert second.name == "OpenAI Labs Updated"
|
||||
got = marketplace_store.get_employer("emp-openai")
|
||||
assert got is not None
|
||||
assert got.social_links["twitter"].endswith("/x")
|
||||
assert marketplace_store.get_employer("nope") is None
|
||||
|
||||
def test_employer_unique_source_external_id_fires(self, marketplace_store) -> None:
|
||||
marketplace_store.upsert_employer(
|
||||
Employer(
|
||||
id="emp-a",
|
||||
source="seed-corpus",
|
||||
external_id="emp-a",
|
||||
name="A",
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
with marketplace_store._engine.begin() as conn: # noqa: SLF001
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO employer (id, source, external_id, name) "
|
||||
"VALUES ('emp-b', 'seed-corpus', 'emp-a', 'Dup')"
|
||||
)
|
||||
)
|
||||
|
||||
def test_employer_validate_hook(self, marketplace_store) -> None:
|
||||
"""The store's own hook guards beneath pydantic (pydantic
|
||||
validates the API path; the validates hook is the second line
|
||||
for constructed rows)."""
|
||||
from ai_service.marketplace.store import EmployerRow
|
||||
|
||||
bad = EmployerRow(id="emp-bad", source="s", external_id="emp-bad", name="")
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
bad._validate()
|
||||
|
||||
|
||||
class TestApplications:
|
||||
def test_apply_idempotent_returns_existing(self, marketplace_store) -> None:
|
||||
first, created = marketplace_store.insert_application("job-1", "pilot-learner")
|
||||
assert created is True
|
||||
assert first.status == "submitted"
|
||||
second, created2 = marketplace_store.insert_application(
|
||||
"job-1", "pilot-learner"
|
||||
)
|
||||
assert created2 is False
|
||||
assert second.id == first.id # A-408: the EXISTING row rides the 200
|
||||
|
||||
def test_unique_posting_learner_fires(self, marketplace_store) -> None:
|
||||
marketplace_store.insert_application("job-1", "pilot-learner")
|
||||
with pytest.raises(IntegrityError):
|
||||
with marketplace_store._engine.begin() as conn: # noqa: SLF001
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO application (id, posting_id, learner_id, "
|
||||
"status, created_at) VALUES "
|
||||
f"('app-x', 'job-1', 'pilot-learner', 'submitted', "
|
||||
f"'{datetime.now(UTC).isoformat()}')"
|
||||
)
|
||||
)
|
||||
|
||||
def test_list_applications_filter(self, marketplace_store) -> None:
|
||||
marketplace_store.insert_application("job-1", "learner-a")
|
||||
marketplace_store.insert_application("job-2", "learner-a")
|
||||
marketplace_store.insert_application("job-1", "learner-b")
|
||||
all_rows = marketplace_store.list_applications()
|
||||
assert len(all_rows) == 3
|
||||
only_job1 = marketplace_store.list_applications(posting_id="job-1")
|
||||
assert {a.learner_id for a in only_job1} == {"learner-a", "learner-b"}
|
||||
@@ -0,0 +1,248 @@
|
||||
"""P2 tagging tests (REQ-6-002, D-052, MH-2a/2b/2c) — cloud-free.
|
||||
|
||||
The mock provider's json_object path returns a canned non-matching JSON
|
||||
(deterministic), so every tag test uses ScriptedJSONProvider with an
|
||||
explicit payload — the byte-contract idiom from the voice/llm suites.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_service.llm.mock import MockProvider, ScriptedJSONProvider
|
||||
from ai_service.marketplace.base import IngestPosting, PostingTags
|
||||
from ai_service.marketplace.ingest import SeededCorpusSource
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
from ai_service.marketplace.tagging import TaggingUnavailable, tag_posting
|
||||
|
||||
MODEL = "test-model"
|
||||
|
||||
|
||||
def _valid_payload() -> dict:
|
||||
return {
|
||||
"skills": ["python", "llm evaluation", "rag"],
|
||||
"seniority": "senior",
|
||||
"category": "ai-orchestration",
|
||||
}
|
||||
|
||||
|
||||
class TestTagPosting:
|
||||
async def test_mock_scripted_tag_extraction(self) -> None:
|
||||
"""MH-2a: cloud-free tagging via a scripted provider."""
|
||||
provider = ScriptedJSONProvider(_valid_payload())
|
||||
tags = await tag_posting(
|
||||
provider,
|
||||
model=MODEL,
|
||||
title="LLM Evaluation Engineer",
|
||||
description="Build eval harnesses for LLM systems.",
|
||||
skills_hint=["python"],
|
||||
)
|
||||
assert tags == PostingTags.model_validate(_valid_payload())
|
||||
|
||||
async def test_over_ten_skills_rejected_then_retry_path(self) -> None:
|
||||
"""MH-2a: the ≤10 bound is the enforcement — an 11-skill response
|
||||
fails schema validation (bounded retry runs); if the retry
|
||||
repeats the same shape the tagger surfaces TaggingUnavailable."""
|
||||
provider = ScriptedJSONProvider(
|
||||
{"skills": [f"skill-{i}" for i in range(11)], "seniority": None, "category": None}
|
||||
)
|
||||
with pytest.raises(TaggingUnavailable):
|
||||
await tag_posting(
|
||||
provider,
|
||||
model=MODEL,
|
||||
title="T",
|
||||
description="D",
|
||||
)
|
||||
|
||||
async def test_retry_recovers_on_second_pass(self) -> None:
|
||||
"""D-020 layer 4: a provider whose FIRST response is invalid but
|
||||
whose retry validates produces tags (single bounded retry)."""
|
||||
attempts = {"n": 0}
|
||||
|
||||
class RetryThenValid(MockProvider):
|
||||
def _reply_for(self, messages, response_format): # type: ignore[override]
|
||||
if response_format is not None and response_format.get("type") == "json_object":
|
||||
attempts["n"] += 1
|
||||
if attempts["n"] == 1:
|
||||
return '{"skills": ["' + '", "'.join(f"s{i}" for i in range(11)) + '"]}'
|
||||
import json
|
||||
|
||||
return json.dumps(_valid_payload())
|
||||
return super()._reply_for(messages, response_format)
|
||||
|
||||
tags = await tag_posting(RetryThenValid(), model=MODEL, title="T", description="D")
|
||||
assert tags.skills == ["python", "llm evaluation", "rag"]
|
||||
assert attempts["n"] == 2
|
||||
|
||||
async def test_provider_failure_raises_tagging_unavailable(self) -> None:
|
||||
"""Honest failure: a dead provider surfaces TaggingUnavailable —
|
||||
never a crash, never fabricated tags."""
|
||||
provider = MockProvider()
|
||||
provider.fail_before_first_token = True
|
||||
with pytest.raises(TaggingUnavailable):
|
||||
await tag_posting(provider, model=MODEL, title="T", description="D")
|
||||
|
||||
async def test_failure_logs_warning(self, caplog) -> None:
|
||||
provider = MockProvider()
|
||||
provider.fail_before_first_token = True
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with pytest.raises(TaggingUnavailable):
|
||||
await tag_posting(provider, model=MODEL, title="A title", description="D")
|
||||
assert any("untagged" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
class TestTagOnIngest:
|
||||
def test_ingest_tags_postings_and_facets_match(self, tmp_path) -> None:
|
||||
"""MH-2c: tag-on-ingest + facets exactly match the stored tags of
|
||||
published-only postings (a scripted deterministic tag set)."""
|
||||
|
||||
class DeterministicTagger(MockProvider):
|
||||
"""json_object responses derived from the posting content —
|
||||
deterministic per posting."""
|
||||
|
||||
def _reply_for(self, messages, response_format): # type: ignore[override]
|
||||
import json
|
||||
|
||||
if response_format is not None and response_format.get("type") == "json_object":
|
||||
user = next(m.content for m in reversed(messages) if m.role == "user")
|
||||
if "Title: T1" in user:
|
||||
return json.dumps(
|
||||
{"skills": ["python"], "seniority": "mid", "category": "data"}
|
||||
)
|
||||
return json.dumps(
|
||||
{"skills": ["python", "rag"], "seniority": "senior", "category": None}
|
||||
)
|
||||
return super()._reply_for(messages, response_format)
|
||||
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
|
||||
# Two hand-seeded published postings + one draft (facets must
|
||||
# exclude drafts).
|
||||
for pid, title in (("job-001", "T1"), ("job-002", "T2")):
|
||||
store.upsert_posting(
|
||||
IngestPosting(
|
||||
id=pid,
|
||||
source="test",
|
||||
external_id=pid,
|
||||
employer_id="emp-x",
|
||||
title=title,
|
||||
),
|
||||
status="published",
|
||||
)
|
||||
store.upsert_posting(
|
||||
IngestPosting(
|
||||
id="job-003",
|
||||
source="test",
|
||||
external_id="job-003",
|
||||
employer_id="emp-x",
|
||||
title="T1",
|
||||
),
|
||||
status="draft",
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
async def _tag_all() -> None:
|
||||
for pid in ("job-001", "job-002"):
|
||||
posting = store.get_posting(pid)
|
||||
tags = await tag_posting(
|
||||
DeterministicTagger(),
|
||||
model=MODEL,
|
||||
title=posting.title,
|
||||
description=posting.description,
|
||||
)
|
||||
store.set_posting_tags(pid, tags)
|
||||
|
||||
asyncio.run(_tag_all())
|
||||
|
||||
facets = store.facets()
|
||||
# published-only: job-001 (python/mid/data) + job-002 (python+rag/senior)
|
||||
assert facets["skills"] == [
|
||||
{"value": "python", "count": 2},
|
||||
{"value": "rag", "count": 1},
|
||||
]
|
||||
assert facets["seniority"] == [
|
||||
{"value": "mid", "count": 1},
|
||||
{"value": "senior", "count": 1},
|
||||
]
|
||||
assert facets["category"] == [{"value": "data", "count": 1}]
|
||||
|
||||
def test_untagged_store_facets_are_empty(self, tmp_path) -> None:
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
|
||||
store.upsert_posting(
|
||||
IngestPosting(
|
||||
id="job-001",
|
||||
source="test",
|
||||
external_id="job-001",
|
||||
employer_id="emp-x",
|
||||
title="T",
|
||||
),
|
||||
status="published",
|
||||
)
|
||||
assert store.facets() == {
|
||||
"skills": [],
|
||||
"seniority": [],
|
||||
"category": [],
|
||||
}
|
||||
|
||||
|
||||
class TestReTagOverwrites:
|
||||
def test_retag_overwrites_old_tags(self, tmp_path) -> None:
|
||||
"""MH-2b: tags are derived data — a re-tag OVERWRITES (old tags
|
||||
gone, single source of truth; no append-only)."""
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
|
||||
store.upsert_posting(
|
||||
IngestPosting(
|
||||
id="job-001", source="test", external_id="job-001",
|
||||
employer_id="emp-x", title="T",
|
||||
),
|
||||
status="published",
|
||||
)
|
||||
old = PostingTags(skills=["stale-skill"], seniority="entry", category="research")
|
||||
store.set_posting_tags("job-001", old)
|
||||
new = PostingTags(skills=["fresh-skill"], seniority="principal", category="product")
|
||||
stored = store.set_posting_tags("job-001", new)
|
||||
assert stored is not None and stored.tags == new
|
||||
fetched = store.get_posting("job-001")
|
||||
assert fetched is not None
|
||||
assert fetched.tags == new
|
||||
assert "stale-skill" not in (fetched.tags.skills if fetched.tags else [])
|
||||
|
||||
def test_update_posting_fields_then_retag(self, tmp_path) -> None:
|
||||
"""The admin edit path: fields update, then the route's re-tag
|
||||
replaces the tags (store layer contract)."""
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
|
||||
store.upsert_posting(
|
||||
IngestPosting(
|
||||
id="job-001", source="test", external_id="job-001",
|
||||
employer_id="emp-x", title="Old title",
|
||||
),
|
||||
status="published",
|
||||
)
|
||||
updated = store.update_posting_fields(
|
||||
"job-001", title="New title", skills=["kubernetes"]
|
||||
)
|
||||
assert updated is not None and updated.title == "New title"
|
||||
store.set_posting_tags(
|
||||
"job-001",
|
||||
PostingTags(skills=["kubernetes", "docker"], seniority=None, category=None),
|
||||
)
|
||||
fetched = store.get_posting("job-001")
|
||||
assert fetched is not None
|
||||
assert fetched.title == "New title"
|
||||
assert fetched.skills == ["kubernetes"]
|
||||
assert fetched.tags is not None and fetched.tags.skills == ["kubernetes", "docker"]
|
||||
|
||||
def test_update_unknown_posting_returns_none(self, tmp_path) -> None:
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "m.db")
|
||||
assert store.update_posting_fields("missing", title="X") is None
|
||||
assert store.set_posting_tags("missing", PostingTags()) is None
|
||||
|
||||
|
||||
class TestSeededCorpusTaggable:
|
||||
def test_seed_postings_have_taggable_content(self) -> None:
|
||||
"""Every seed posting carries title + description + skills the
|
||||
tagger can consume (no empty-string descriptions hiding)."""
|
||||
empty = [p for p in SeededCorpusSource().fetch() if not p.description.strip()]
|
||||
assert empty == []
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { AiTutorChat } from '../../../components/learner/ai-tutor-chat';
|
||||
import { MentorPanel } from '../../../components/learner/mentor-panel';
|
||||
import { ProgressGraph } from '../../../components/learner/progress-graph';
|
||||
import { ResumeUploadPanel } from '../../../components/learner/resume-upload-panel';
|
||||
|
||||
const ACTIVE_COMPETENCY_IDS = [
|
||||
'stack-orchestration-c003',
|
||||
@@ -290,6 +291,9 @@ export default function DashboardPage() {
|
||||
<MentorPanel />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Profile auto-fill (P4-B, REQ-6-005) — upload → draft review → confirm */}
|
||||
<ResumeUploadPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +1,17 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, Briefcase, Github, Globe, Linkedin, MapPin, Twitter, Users } from 'lucide-react';
|
||||
import { employers, jobs } from '@nextcraft/mock-data';
|
||||
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
|
||||
import { formatSalaryRange, initials as initialsOf, relativeTime } from '../../../../../lib/format';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ employerId: string }>;
|
||||
}
|
||||
|
||||
const CULTURE_CARDS = [
|
||||
{ label: 'Open Workspace', tone: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300' },
|
||||
{ label: 'Team Collaboration', tone: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' },
|
||||
{ label: 'Innovation Lab', tone: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
|
||||
{ label: 'Learning Culture', tone: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300' },
|
||||
{ label: 'Global Team', tone: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300' },
|
||||
{ label: 'Mission First', tone: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300' },
|
||||
] as const;
|
||||
import { EmployerProfileClient } from '../../../../../components/marketplace/employer-profile-client';
|
||||
|
||||
/**
|
||||
* G-101 (Task 1-3-04): generateStaticParams STAYS with ONE placeholder
|
||||
* param — Next 15 hard-throws E87 under `output:'export'` for any
|
||||
* dynamic route without it (even returning `[]`). Exactly one client
|
||||
* shell page exports here; the FastAPI catch-all serves it for every
|
||||
* runtime id, and the client fetches the real employer (notFound() on
|
||||
* 404 — honest absence, never a fabricated profile).
|
||||
*/
|
||||
export function generateStaticParams() {
|
||||
return employers.map((e) => ({ employerId: e.id }));
|
||||
return [{ employerId: '_' }];
|
||||
}
|
||||
|
||||
export default async function EmployerProfilePage({ params }: PageProps) {
|
||||
const { employerId } = await params;
|
||||
const employer = employers.find((e) => e.id === employerId);
|
||||
if (!employer) notFound();
|
||||
|
||||
const openPositions = jobs.filter((j) => j.employerId === employer.id);
|
||||
|
||||
const aboutParagraphs = [
|
||||
employer.description,
|
||||
`Headquartered in ${employer.location}, ${employer.name} operates in the ${employer.industry} space with a team of ${employer.size} people. Our culture is best described as: ${employer.culture}`,
|
||||
`We are actively hiring across roles that pair AI engineering rigor with real customer outcomes. Browse our open positions below and apply through the Nextcraft marketplace to connect directly with our hiring team.`,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to job board
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-100 text-lg font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
|
||||
>
|
||||
{initialsOf(employer.name)}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{employer.name}
|
||||
</h1>
|
||||
<p className="text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{employer.industry}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{employer.size}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{employer.location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SocialIcon href={employer.socialLinks.twitter ?? '#'} label="Twitter">
|
||||
<Twitter className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
<SocialIcon href={employer.socialLinks.linkedin ?? '#'} label="LinkedIn">
|
||||
<Linkedin className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
<SocialIcon href={employer.socialLinks.github ?? '#'} label="GitHub">
|
||||
<Github className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
<SocialIcon href={employer.website} label="Website">
|
||||
<Globe className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* About */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">About</h2>
|
||||
<div className="flex flex-col gap-3 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
{aboutParagraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Culture */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Culture</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{CULTURE_CARDS.map((c, i) => (
|
||||
<div
|
||||
key={c.label}
|
||||
className={`flex h-24 flex-col items-center justify-center gap-2 rounded-lg border border-slate-200 text-center dark:border-slate-800 ${c.tone}`}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-wide opacity-70">#{i + 1}</span>
|
||||
<span className="text-sm font-semibold">{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Open positions */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Open positions
|
||||
</h2>
|
||||
<Badge variant="default">
|
||||
<Briefcase className="h-3 w-3" />
|
||||
{openPositions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{openPositions.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">No current openings.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{openPositions.map((job) => (
|
||||
<Link
|
||||
key={job.id}
|
||||
href={`/marketplace/jobs/${job.id}`}
|
||||
className="group flex flex-col gap-1 rounded-md border border-slate-200 p-4 transition-colors hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-slate-900 group-hover:text-primary-700 dark:text-slate-100">
|
||||
{job.title}
|
||||
</span>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
{job.location} · {formatSalaryRange(job.salaryMin, job.salaryMax)} · Posted{' '}
|
||||
{relativeTime(job.postedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="inline-flex h-8 w-fit items-center justify-center rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-800 dark:border-slate-700 dark:text-slate-100">
|
||||
View
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialIcon({
|
||||
href,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
aria-label={label}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-slate-300 text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
export default function EmployerProfilePage() {
|
||||
return <EmployerProfileClient />;
|
||||
}
|
||||
@@ -1,315 +1,17 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, Building2, Check, MapPin, Wallet } from 'lucide-react';
|
||||
import { employers, jobs } from '@nextcraft/mock-data';
|
||||
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
|
||||
import type { Job } from '@nextcraft/types';
|
||||
import {
|
||||
formatSalaryRange,
|
||||
matchBarClasses,
|
||||
matchBadgeClasses,
|
||||
matchTone,
|
||||
relativeTime,
|
||||
seniorityLabel,
|
||||
initials as initialsOf,
|
||||
} from '../../../../../lib/format';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ jobId: string }>;
|
||||
}
|
||||
import { JobDetailClient } from '../../../../../components/marketplace/job-detail-client';
|
||||
|
||||
/**
|
||||
* Derive a stable mock "AI-matched skills" breakdown from a job's skill list.
|
||||
* The first skill is given the highest score (close to the job's matchScore)
|
||||
* and subsequent skills taper slightly so the visualization reads naturally.
|
||||
* G-101 (Task 1-3-04): generateStaticParams STAYS with ONE placeholder
|
||||
* param — Next 15 hard-throws E87 under `output:'export'` for any
|
||||
* dynamic route without it (even returning `[]`). Exactly one client
|
||||
* shell page exports here; the FastAPI catch-all serves it for every
|
||||
* runtime id, and the client fetches the real posting (notFound() on
|
||||
* 404 — honest absence, never a fabricated job).
|
||||
*/
|
||||
function aiMatchedSkills(job: Job): Array<{ skill: string; score: number }> {
|
||||
const top = job.skills.slice(0, 5);
|
||||
return top.map((skill, idx) => {
|
||||
const taper = Math.round(idx * 4);
|
||||
const score = Math.max(55, Math.min(99, job.matchScore - taper));
|
||||
return { skill, score };
|
||||
});
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return jobs.map((j) => ({ jobId: j.id }));
|
||||
return [{ jobId: '_' }];
|
||||
}
|
||||
|
||||
export default async function JobDetailPage({ params }: PageProps) {
|
||||
const { jobId } = await params;
|
||||
const job = jobs.find((j) => j.id === jobId);
|
||||
if (!job) notFound();
|
||||
|
||||
const employer = employers.find((e) => e.id === job.employerId);
|
||||
const employerName = employer?.name ?? 'Unknown employer';
|
||||
|
||||
const related = jobs
|
||||
.filter((j) => j.id !== job.id && j.employerId === job.employerId)
|
||||
.slice(0, 3);
|
||||
const relatedFallback =
|
||||
related.length > 0
|
||||
? related
|
||||
: jobs
|
||||
.filter((j) => j.id !== job.id && j.seniority === job.seniority)
|
||||
.slice(0, 3);
|
||||
const relatedJobs = (related.length > 0 ? related : relatedFallback).slice(0, 3);
|
||||
|
||||
const matchedSkills = aiMatchedSkills(job);
|
||||
|
||||
// Build out 3–4 paragraph rich description.
|
||||
const descriptionParagraphs = [
|
||||
job.description,
|
||||
`${employerName} is looking for a ${seniorityLabel(job.seniority)}-level ${
|
||||
job.title.toLowerCase().includes('engineer') ||
|
||||
job.title.toLowerCase().includes('designer') ||
|
||||
job.title.toLowerCase().includes('researcher')
|
||||
? 'practitioner'
|
||||
: 'professional'
|
||||
} who can own outcomes end-to-end. You will partner with product, research, and platform teams to ship reliable AI-powered features, instrumented with the evaluation harnesses this role is responsible for.`,
|
||||
`Day-to-day you will design systems around ${job.skills
|
||||
.slice(0, 3)
|
||||
.join(', ')}, write production code, review pull requests, run evaluations, and contribute to a blameless postmortem culture. We expect strong written communication and comfort working in a fast-moving, evidence-driven environment.`,
|
||||
`This role is ${job.remote ? 'remote-friendly' : `on-site in ${job.location}`} and reports into the ${employer?.industry ?? 'AI'} practice. Compensation ranges from ${formatSalaryRange(
|
||||
job.salaryMin,
|
||||
job.salaryMax,
|
||||
)} plus equity and benefits. We are an equal-opportunity employer and actively seek candidates from non-traditional backgrounds who have built real AI systems.`,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to job board
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-primary-100 text-base font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
|
||||
>
|
||||
{initialsOf(employerName)}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{job.title}
|
||||
</h1>
|
||||
<Link
|
||||
href={`/marketplace/employers/${job.employerId}`}
|
||||
className="text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
|
||||
>
|
||||
{employerName}
|
||||
</Link>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{job.location}
|
||||
</span>
|
||||
{job.remote && <Badge variant="success">Remote</Badge>}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Wallet className="h-3.5 w-3.5" />
|
||||
{formatSalaryRange(job.salaryMin, job.salaryMax)}
|
||||
</span>
|
||||
<Badge variant="default">{seniorityLabel(job.seniority)}</Badge>
|
||||
<span>Posted {relativeTime(job.postedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`inline-flex h-12 w-12 items-center justify-center rounded-full text-sm font-bold ring-2 ${matchBadgeClasses(
|
||||
job.matchScore,
|
||||
)}`}
|
||||
title={`${job.matchScore}% AI match`}
|
||||
>
|
||||
{job.matchScore}%
|
||||
</span>
|
||||
<Button size="lg" type="button">
|
||||
Apply Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="flex flex-col gap-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Job description
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
{descriptionParagraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Required competencies
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{job.requiredCompetencies.map((c) => (
|
||||
<li
|
||||
key={c}
|
||||
className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
<span>{humanizeCompetency(c)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
AI-Matched Skills
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
How your competency profile maps to this role, computed by Nextcraft's match
|
||||
engine.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{matchedSkills.map(({ skill, score }) => (
|
||||
<div key={skill} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-slate-800 dark:text-slate-200">{skill}</span>
|
||||
<span className="text-slate-600 dark:text-slate-400">{score}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
|
||||
<div
|
||||
className={`h-full rounded-full ${matchBarClasses(score)}`}
|
||||
style={{ width: `${score}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={score}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${skill} match ${score} percent`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
matchTone(job.matchScore) === 'high'
|
||||
? 'bg-emerald-500'
|
||||
: matchTone(job.matchScore) === 'mid'
|
||||
? 'bg-amber-500'
|
||||
: 'bg-orange-500'
|
||||
}`}
|
||||
/>
|
||||
Overall match score {job.matchScore}%
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Employer
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
|
||||
>
|
||||
{initialsOf(employerName)}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{employerName}
|
||||
</span>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{employer?.industry ?? 'AI'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="flex flex-col gap-1.5 text-sm">
|
||||
{employer && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Size</dt>
|
||||
<dd className="text-slate-800 dark:text-slate-200">{employer.size}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Location</dt>
|
||||
<dd className="text-slate-800 dark:text-slate-200">{employer.location}</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{employer && (
|
||||
<Link href={`/marketplace/employers/${employer.id}`}>
|
||||
<Button variant="outline" size="sm" icon={<Building2 className="h-3.5 w-3.5" />} type="button">
|
||||
View Company
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Related jobs
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{relatedJobs.map((r) => (
|
||||
<Link
|
||||
key={r.id}
|
||||
href={`/marketplace/jobs/${r.id}`}
|
||||
className="group flex flex-col gap-1 rounded-md border border-slate-200 p-3 transition-colors hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800"
|
||||
>
|
||||
<span className="text-sm font-semibold text-slate-900 group-hover:text-primary-700 dark:text-slate-100">
|
||||
{r.title}
|
||||
</span>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">{r.location}</span>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
{formatSalaryRange(r.salaryMin, r.salaryMax)} · {r.matchScore}% match
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Turn a competency code like "stack-orchestration-c001" into readable prose. */
|
||||
function humanizeCompetency(code: string): string {
|
||||
const parts = code.split('-');
|
||||
if (parts.length >= 3) {
|
||||
const stack = parts[1];
|
||||
const capLabel = `Capability ${parts[parts.length - 1].toUpperCase()}`;
|
||||
const stackPretty = stack.charAt(0).toUpperCase() + stack.slice(1);
|
||||
return `${stackPretty} · ${capLabel}`;
|
||||
}
|
||||
return code;
|
||||
export default function JobDetailPage() {
|
||||
return <JobDetailClient />;
|
||||
}
|
||||
@@ -1,26 +1,10 @@
|
||||
import { Badge } from '@nextcraft/ui';
|
||||
import { employers, jobs } from '@nextcraft/mock-data';
|
||||
import { JobFilters } from '../../../components/marketplace/job-filters';
|
||||
import { BoardClient } from '../../../components/marketplace/board-client';
|
||||
|
||||
/**
|
||||
* Marketplace board (D-055, Task 1-3-03): server shell only — the grid
|
||||
* + filters live in the client shell over listPostings (real published
|
||||
* postings, honest empty state, G-103 truncation notice).
|
||||
*/
|
||||
export default function MarketplaceHomePage() {
|
||||
const employerNames: Record<string, string> = Object.fromEntries(
|
||||
employers.map((e) => [e.id, e.name]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Marketplace · Job Board</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
AI-Native Job Board
|
||||
</h1>
|
||||
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
|
||||
AI-credentialed talent meets AI-era employers. Every listing shows a live AI match
|
||||
score based on the skills recruiters need most right now.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<JobFilters jobs={jobs} employerNames={employerNames} />
|
||||
</div>
|
||||
);
|
||||
return <BoardClient />;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { CheckCircle2, FileText, Loader2, XCircle } from 'lucide-react';
|
||||
import { Button, Card, CardBody, CardHeader } from '@nextcraft/ui';
|
||||
import {
|
||||
EngineError,
|
||||
RateLimitedError,
|
||||
ResumeDraft,
|
||||
VerifyRequiredError,
|
||||
confirmResume,
|
||||
uploadResume,
|
||||
} from '../../lib/engine-client';
|
||||
import { MOCK_LEARNER_ID } from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* P4-B (REQ-6-005, D-054, A-410): resume upload → DRAFT review → the
|
||||
* explicit confirm gate. Nothing saves before the confirm click; bounds
|
||||
* errors (413 oversize / 422 format + text-layer) render honestly.
|
||||
* The 403 verify-CTA surfaces the enrollment prompt (D-043 reuse).
|
||||
*/
|
||||
export function ResumeUploadPanel() {
|
||||
const [draft, setDraft] = useState<ResumeDraft | null>(null);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [cta, setCta] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function onFile(file: File) {
|
||||
setError(null);
|
||||
setConfirmed(false);
|
||||
setCta(null);
|
||||
setParsing(true);
|
||||
setDraft(null);
|
||||
try {
|
||||
const result = await uploadResume(MOCK_LEARNER_ID, file);
|
||||
setDraft(result);
|
||||
} catch (err) {
|
||||
if (err instanceof VerifyRequiredError) {
|
||||
setCta(err.verifyCta);
|
||||
} else if (err instanceof EngineError) {
|
||||
setError(err.message);
|
||||
} else if (err instanceof RateLimitedError) {
|
||||
setError('Too many attempts — wait a minute and try again.');
|
||||
} else {
|
||||
setError('Failed to parse the resume. Try again.');
|
||||
}
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (!draft) return;
|
||||
setConfirming(true);
|
||||
setError(null);
|
||||
try {
|
||||
await confirmResume(MOCK_LEARNER_ID, draft.profile);
|
||||
setConfirmed(true);
|
||||
setDraft(null);
|
||||
} catch (err) {
|
||||
if (err instanceof VerifyRequiredError) setCta(err.verifyCta);
|
||||
else setError(err instanceof EngineError ? err.message : 'Failed to save. Try again.');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
Profile auto-fill from resume
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Upload a resume (PDF, TXT, or MD, ≤2 MB) and review the extracted profile
|
||||
before anything saves. Nothing is stored until you confirm.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".pdf,.txt,.md,application/pdf,text/plain,text/markdown"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void onFile(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={parsing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={parsing || confirming}
|
||||
>
|
||||
{parsing ? 'Parsing…' : 'Upload resume'}
|
||||
</Button>
|
||||
{confirmed && (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<CheckCircle2 className="h-4 w-4" /> Profile saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="flex items-start gap-2 rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{cta && (
|
||||
<div role="alert" className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
|
||||
Verify your identity to use profile auto-fill — <a href="/enroll" className="font-semibold underline">{cta}</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft && (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-slate-200 p-4 dark:border-slate-800">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Draft — review before saving
|
||||
</p>
|
||||
{draft.profile.name && (
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{draft.profile.name}</p>
|
||||
)}
|
||||
{draft.profile.headline && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{draft.profile.headline}</p>
|
||||
)}
|
||||
{(draft.profile.skills?.length ?? 0) > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{draft.profile.skills!.map((s) => (
|
||||
<span key={s} className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(draft.profile.competencies?.length ?? 0) > 0 && (
|
||||
<ul className="list-inside list-disc text-sm text-slate-600 dark:text-slate-400">
|
||||
{draft.profile.competencies!.map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={confirming}
|
||||
onClick={() => void onConfirm()}
|
||||
>
|
||||
{confirming ? 'Saving…' : 'Save profile'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={confirming}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Badge } from '@nextcraft/ui';
|
||||
import type { EmployerProfile, PostingFacets, PostingSummary, SearchResponse } from '@nextcraft/types';
|
||||
import { listPostings, getEmployer, searchPostings, type PostingsFetchAll, type SearchFilters } from '../../lib/engine-client';
|
||||
import { JobFilters, type SearchFacets } from './job-filters';
|
||||
|
||||
/** P3 search-mode facet state (SearchFacets mirror of the sidebar's
|
||||
* facet subset — the board owns the re-query). */
|
||||
const EMPTY_SEARCH_FILTERS: SearchFacets = {
|
||||
skills: [],
|
||||
seniority: 'all',
|
||||
remoteOnly: false,
|
||||
salaryMin: '',
|
||||
salaryMax: '',
|
||||
};
|
||||
|
||||
/** The active submitted semantic search (G-106 state wiring): the query
|
||||
* that produced the ranked results + the results themselves. Scores
|
||||
* exist ONLY while this is set — "Clear search" returns to the
|
||||
* fetch-all list view with no scores. */
|
||||
interface SearchState {
|
||||
query: string;
|
||||
response: SearchResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Board client shell (D-055, Task 1-3-03): fetches real published
|
||||
* postings (paged fetch-all, G-103) + employer names, then renders the
|
||||
* grid + JobFilters over the fetched set. Honest at every state: zero
|
||||
* postings → an empty-board message (never fake cards), a truncation
|
||||
* notice when the page bound stopped the fetch-all early. P2: facets
|
||||
* from the list payload data-drive the sidebar options (MH-2c).
|
||||
* P3 (Task 3-3-01): a SUBMITTED query switches the board into search
|
||||
* mode — searchPostings ranks server-side and the grid renders the
|
||||
* computed match scores (ONLY in this state, G-106).
|
||||
*/
|
||||
export function BoardClient() {
|
||||
const [postings, setPostings] = useState<PostingSummary[] | null>(null);
|
||||
const [employerNames, setEmployerNames] = useState<Record<string, string>>({});
|
||||
const [totalPublished, setTotalPublished] = useState(0);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [facets, setFacets] = useState<PostingFacets | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// P3 search state — null = the fetch-all list view (no scores).
|
||||
const [search, setSearch] = useState<SearchState | null>(null);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [searchFilters, setSearchFilters] = useState<SearchFacets>(EMPTY_SEARCH_FILTERS);
|
||||
|
||||
// The in-flight search request's latest inputs (query + filters) so a
|
||||
// slow response for a stale input never overwrites a newer one.
|
||||
const searchSeq = useRef(0);
|
||||
|
||||
const runSearch = useCallback(async (query: string, f: SearchFacets) => {
|
||||
const filters: SearchFilters = {
|
||||
skills: f.skills,
|
||||
seniority: f.seniority !== 'all' ? f.seniority : null,
|
||||
remote: f.remoteOnly ? true : null,
|
||||
salaryMin: f.salaryMin.trim() ? Number(f.salaryMin) : null,
|
||||
salaryMax: f.salaryMax.trim() ? Number(f.salaryMax) : null,
|
||||
};
|
||||
const seq = ++searchSeq.current;
|
||||
setSearchLoading(true);
|
||||
setSearchError(null);
|
||||
try {
|
||||
const response = await searchPostings(query, filters);
|
||||
if (seq !== searchSeq.current) return; // a newer search superseded this one
|
||||
setSearch({ query, response });
|
||||
} catch (err) {
|
||||
if (seq !== searchSeq.current) return;
|
||||
setSearchError(err instanceof Error ? err.message : 'Search failed.');
|
||||
} finally {
|
||||
if (seq === searchSeq.current) setSearchLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onSearch = useCallback(
|
||||
(query: string) => {
|
||||
setSearchFilters(EMPTY_SEARCH_FILTERS);
|
||||
void runSearch(query, EMPTY_SEARCH_FILTERS);
|
||||
},
|
||||
[runSearch],
|
||||
);
|
||||
|
||||
const onClearSearch = useCallback(() => {
|
||||
searchSeq.current += 1; // invalidate any in-flight search
|
||||
setSearch(null);
|
||||
setSearchLoading(false);
|
||||
setSearchError(null);
|
||||
setSearchFilters(EMPTY_SEARCH_FILTERS);
|
||||
}, []);
|
||||
|
||||
const onSearchFiltersChange = useCallback(
|
||||
(next: SearchFacets) => {
|
||||
setSearchFilters(next);
|
||||
if (search) void runSearch(search.query, next); // facets ride the API params
|
||||
},
|
||||
[search, runSearch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const body: PostingsFetchAll = await listPostings(controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setPostings(body.postings);
|
||||
setTotalPublished(body.totalPublished);
|
||||
setTruncated(body.truncated);
|
||||
setFacets(body.facets);
|
||||
// Employer names: one getEmployer per distinct employer_id (the
|
||||
// pilot corpus is 10 employers; a board-level employers index
|
||||
// route is post-v0.6).
|
||||
const names: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const p of body.postings) {
|
||||
if (seen.has(p.employer_id)) continue;
|
||||
seen.add(p.employer_id);
|
||||
try {
|
||||
const e: EmployerProfile = await getEmployer(p.employer_id, controller.signal);
|
||||
names[p.employer_id] = e.name;
|
||||
} catch {
|
||||
names[p.employer_id] = 'Unknown employer';
|
||||
}
|
||||
}
|
||||
if (!controller.signal.aborted) setEmployerNames(names);
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
setPostings([]);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load the job board.');
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const emptyBoard =
|
||||
postings !== null && postings.length === 0 && !error && !search;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Marketplace · Job Board</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
AI-Native Job Board
|
||||
</h1>
|
||||
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
|
||||
AI-credentialed talent meets AI-era employers. Every listing is a live posting
|
||||
from the Nextcraft marketplace.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-red-300 bg-red-50 px-6 py-8 text-center dark:border-red-800 dark:bg-red-900/30"
|
||||
>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emptyBoard && (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 p-12 text-center text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
No postings are published yet. The board updates the moment a posting goes live —
|
||||
nothing is shown here that isn't real.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{postings !== null && postings.length > 0 && (
|
||||
<>
|
||||
{truncated && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-200"
|
||||
>
|
||||
Showing the first {postings.length} of {totalPublished} postings — the board
|
||||
caps its load for responsiveness. Use the search API (coming in v0.6 search)
|
||||
to reach the full set.
|
||||
</div>
|
||||
)}
|
||||
{searchError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-300"
|
||||
>
|
||||
{searchError}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium underline"
|
||||
onClick={() => {
|
||||
if (search) void runSearch(search.query, searchFilters);
|
||||
else onClearSearch();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>{' '}
|
||||
or{' '}
|
||||
<button type="button" className="font-medium underline" onClick={onClearSearch}>
|
||||
clear the search
|
||||
</button>
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
<JobFilters
|
||||
postings={postings}
|
||||
employerNames={employerNames}
|
||||
facets={facets}
|
||||
search={
|
||||
search
|
||||
? {
|
||||
query: search.query,
|
||||
results: search.response.results,
|
||||
loading: searchLoading,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSearch={onSearch}
|
||||
onClearSearch={onClearSearch}
|
||||
onSearchFiltersChange={onSearchFiltersChange}
|
||||
searchFilters={searchFilters}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Briefcase,
|
||||
Github,
|
||||
Globe,
|
||||
Linkedin,
|
||||
MapPin,
|
||||
Twitter,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { EmployerProfile, PostingSummary } from '@nextcraft/types';
|
||||
import { Badge, Card, CardBody } from '@nextcraft/ui';
|
||||
import { formatSalaryRange, initials as initialsOf, relativeTime } from '../../lib/format';
|
||||
import {
|
||||
EngineError,
|
||||
getEmployer,
|
||||
listPostings,
|
||||
} from '../../lib/engine-client';
|
||||
import { segmentFromPath } from '../../lib/route-id';
|
||||
|
||||
/**
|
||||
* Employer profile client shell (D-055/G-101, Task 1-3-04): fetches the
|
||||
* real employer at runtime, notFound() on 404, and lists that
|
||||
* employer's published postings from the board's fetched set.
|
||||
*
|
||||
* P4-A (D-053/A-304): the About section renders the MERGED description
|
||||
* with a visible provenance badge (raw | llm | manual) when the merged
|
||||
* view exists — LLM-authored copy is never displayed as ingested fact.
|
||||
*/
|
||||
export function EmployerProfileClient() {
|
||||
const [employer, setEmployer] = useState<EmployerProfile | null>(null);
|
||||
const [openPositions, setOpenPositions] = useState<PostingSummary[]>([]);
|
||||
const [missing, setMissing] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
const employerId = segmentFromPath(window.location.pathname);
|
||||
try {
|
||||
const e = await getEmployer(employerId, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setEmployer(e);
|
||||
try {
|
||||
const board = await listPostings(controller.signal);
|
||||
if (!controller.signal.aborted) {
|
||||
setOpenPositions(board.postings.filter((p) => p.employer_id === e.id));
|
||||
}
|
||||
} catch {
|
||||
/* positions are a nice-to-have; the profile still renders */
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof EngineError && err.status === 404) {
|
||||
setMissing(true);
|
||||
return;
|
||||
}
|
||||
setLoadError(err instanceof Error ? err.message : 'Failed to load the employer.');
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
if (missing) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!employer) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to job board
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-red-300 bg-red-50 px-6 py-8 text-center dark:border-red-800 dark:bg-red-900/30"
|
||||
>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{loadError ?? 'Loading employer…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const aboutParagraphs = [
|
||||
employer.description,
|
||||
`Headquartered in ${employer.location}, ${employer.name} operates in the ${employer.industry} space with a team of ${employer.size} people.`,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to job board
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-100 text-lg font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
|
||||
>
|
||||
{initialsOf(employer.name)}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{employer.name}
|
||||
</h1>
|
||||
<p className="text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{employer.industry}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{employer.size}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{employer.location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{employer.social_links.twitter && (
|
||||
<SocialIcon href={employer.social_links.twitter} label="Twitter">
|
||||
<Twitter className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
)}
|
||||
{employer.social_links.linkedin && (
|
||||
<SocialIcon href={employer.social_links.linkedin} label="LinkedIn">
|
||||
<Linkedin className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
)}
|
||||
{employer.social_links.github && (
|
||||
<SocialIcon href={employer.social_links.github} label="GitHub">
|
||||
<Github className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
)}
|
||||
{employer.website && (
|
||||
<SocialIcon href={employer.website} label="Website">
|
||||
<Globe className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* About (P4-A: merged description with visible provenance, D-053/A-304) */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">About</h2>
|
||||
<ProvenanceBadge source={mergedDescriptionSource(employer)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
{(mergedAbout(employer) ?? aboutParagraphs).map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Culture */}
|
||||
{employer.culture && (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Culture</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
{employer.culture}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Open positions */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Open positions
|
||||
</h2>
|
||||
<Badge variant="default">
|
||||
<Briefcase className="h-3 w-3" />
|
||||
{openPositions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{openPositions.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
No current openings from this employer.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{openPositions.map((posting) => (
|
||||
<Link
|
||||
key={posting.id}
|
||||
href={`/marketplace/jobs/${posting.id}`}
|
||||
className="group flex flex-col gap-1 rounded-md border border-slate-200 p-4 transition-colors hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-slate-900 group-hover:text-primary-700 dark:text-slate-100">
|
||||
{posting.title}
|
||||
</span>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
{posting.location}
|
||||
{posting.salary_min != null && posting.salary_max != null && (
|
||||
<> · {formatSalaryRange(posting.salary_min, posting.salary_max)}</>
|
||||
)}
|
||||
{posting.posted_at && <> · Posted {relativeTime(posting.posted_at)}</>}
|
||||
</span>
|
||||
</div>
|
||||
<span className="inline-flex h-8 w-fit items-center justify-center rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-800 dark:border-slate-700 dark:text-slate-100">
|
||||
View
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialIcon({
|
||||
href,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
aria-label={label}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-slate-300 text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** P4-A (A-304 honesty): the provenance badge labels where the
|
||||
* displayed copy came from — LLM copy never masquerades as ingested. */
|
||||
function ProvenanceBadge({ source }: { source: 'raw' | 'llm' | 'manual' }) {
|
||||
const styles: Record<'raw' | 'llm' | 'manual', string> = {
|
||||
raw: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300',
|
||||
llm: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200',
|
||||
manual: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200',
|
||||
};
|
||||
const labels: Record<'raw' | 'llm' | 'manual', string> = {
|
||||
raw: 'Ingested data',
|
||||
llm: 'AI-generated summary',
|
||||
manual: 'Founder-edited',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-5 w-fit items-center rounded-full px-2 text-[11px] font-medium ${styles[source]}`}
|
||||
>
|
||||
{labels[source]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function mergedDescriptionSource(employer: EmployerProfile): 'raw' | 'llm' | 'manual' {
|
||||
const merged = employer.merged?.description;
|
||||
return merged?.source ?? 'raw';
|
||||
}
|
||||
|
||||
function mergedAbout(employer: EmployerProfile): string[] | null {
|
||||
const merged = employer.merged?.description;
|
||||
if (!merged || typeof merged.value !== 'string' || !merged.value.trim()) {
|
||||
return null;
|
||||
}
|
||||
return [merged.value];
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, Building2, Check, MapPin, Wallet } from 'lucide-react';
|
||||
import type {
|
||||
ApplicationConfirmation,
|
||||
PostingDetail,
|
||||
EmployerProfile,
|
||||
SearchResult,
|
||||
} from '@nextcraft/types';
|
||||
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
|
||||
import {
|
||||
formatSalaryRange,
|
||||
matchBadgeClasses,
|
||||
matchBarClasses,
|
||||
relativeTime,
|
||||
seniorityLabel,
|
||||
initials as initialsOf,
|
||||
} from '../../lib/format';
|
||||
import {
|
||||
EngineError,
|
||||
VerifyRequiredError,
|
||||
applyPosting,
|
||||
getEmployer,
|
||||
getPosting,
|
||||
readSearchSelection,
|
||||
} from '../../lib/engine-client';
|
||||
import { segmentFromPath } from '../../lib/route-id';
|
||||
|
||||
/**
|
||||
* Job detail client shell (D-055/G-101, Task 1-3-04): fetches the real
|
||||
* posting at runtime, renders the detail markup, notFound() on 404.
|
||||
* Apply states: 403 verify-CTA → enrollment prompt (the build/defend
|
||||
* G-10 pattern), 409 → honest closed message, success → the honest
|
||||
* confirmation payload. P3 (Task 3-3-01): when the visit ARRIVES from
|
||||
* a search result (the board's sessionStorage handoff — G-106 state
|
||||
* wiring), the computed match score + skill-breakdown panel render
|
||||
* from the search payload exactly; a direct visit (no handoff) shows
|
||||
* NEITHER (honest absence — detail fetches never re-rank, never
|
||||
* fabricate).
|
||||
*/
|
||||
export function JobDetailClient() {
|
||||
const [posting, setPosting] = useState<PostingDetail | null>(null);
|
||||
const [employer, setEmployer] = useState<EmployerProfile | null>(null);
|
||||
const [missing, setMissing] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [confirmation, setConfirmation] = useState<ApplicationConfirmation | null>(null);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
const [verifyCta, setVerifyCta] = useState<string | null>(null);
|
||||
const [closed, setClosed] = useState(false);
|
||||
// P3 G-106: the board's search handoff, read ONCE on mount (empty
|
||||
// when the visit is not from a search result — no score, no panel).
|
||||
const [searchSelection, setSearchSelection] = useState<SearchResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const selection = readSearchSelection();
|
||||
if (selection) setSearchSelection(selection);
|
||||
(async () => {
|
||||
try {
|
||||
const p = await getPosting(
|
||||
segmentFromPath(window.location.pathname),
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setPosting(p);
|
||||
// The handoff is only honest for THIS posting — a stale handoff
|
||||
// for another id renders nothing (never a wrong score).
|
||||
if (selection && selection.posting.id !== p.id) setSearchSelection(null);
|
||||
try {
|
||||
const e = await getEmployer(p.employer_id, controller.signal);
|
||||
if (!controller.signal.aborted) setEmployer(e);
|
||||
} catch {
|
||||
/* employer optional for the detail view */
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof EngineError && err.status === 404) {
|
||||
setMissing(true);
|
||||
return;
|
||||
}
|
||||
setLoadError(err instanceof Error ? err.message : 'Failed to load the posting.');
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
async function onApply() {
|
||||
if (!posting) return;
|
||||
setApplying(true);
|
||||
setApplyError(null);
|
||||
setVerifyCta(null);
|
||||
setClosed(false);
|
||||
try {
|
||||
const c = await applyPosting(posting.id);
|
||||
setConfirmation(c);
|
||||
} catch (err) {
|
||||
if (err instanceof VerifyRequiredError) {
|
||||
setVerifyCta(err.verifyCta);
|
||||
} else if (err instanceof EngineError && err.status === 409) {
|
||||
setClosed(true);
|
||||
} else if (err instanceof EngineError && err.status === 404) {
|
||||
setApplyError('This posting no longer exists.');
|
||||
} else {
|
||||
setApplyError(err instanceof Error ? err.message : 'Apply failed.');
|
||||
}
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!posting) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to job board
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-red-300 bg-red-50 px-6 py-8 text-center dark:border-red-800 dark:bg-red-900/30"
|
||||
>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{loadError ?? 'Loading posting…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const employerName = employer?.name ?? 'Unknown employer';
|
||||
|
||||
const descriptionParagraphs = posting.description
|
||||
? [posting.description]
|
||||
: ['No description provided yet.'];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to job board
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-primary-100 text-base font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
|
||||
>
|
||||
{initialsOf(employerName)}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{posting.title}
|
||||
</h1>
|
||||
<Link
|
||||
href={`/marketplace/employers/${posting.employer_id}`}
|
||||
className="text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
|
||||
>
|
||||
{employerName}
|
||||
</Link>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{posting.location}
|
||||
</span>
|
||||
{posting.remote && <Badge variant="success">Remote</Badge>}
|
||||
{posting.salary_min != null && posting.salary_max != null && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Wallet className="h-3.5 w-3.5" />
|
||||
{formatSalaryRange(posting.salary_min, posting.salary_max)}
|
||||
</span>
|
||||
)}
|
||||
{posting.seniority && <Badge variant="default">{seniorityLabel(posting.seniority)}</Badge>}
|
||||
{posting.posted_at && <span>Posted {relativeTime(posting.posted_at)}</span>}
|
||||
{posting.status === 'closed' && <Badge variant="warning">Closed</Badge>}
|
||||
{typeof searchSelection?.match_score === 'number' && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold ring-2 ${matchBadgeClasses(matchPercent(searchSelection.match_score))}`}
|
||||
>
|
||||
{matchPercent(searchSelection.match_score)}% match
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-3 md:items-end">
|
||||
{/* Apply flow (D-058): every state renders honestly. */}
|
||||
{confirmation ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
|
||||
>
|
||||
<span className="font-semibold">Application submitted</span>
|
||||
<span className="text-xs">
|
||||
application {confirmation.application_id} · status {confirmation.status}
|
||||
</span>
|
||||
</div>
|
||||
) : posting.status !== 'published' ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
This posting is {posting.status} — applications are closed.
|
||||
</p>
|
||||
) : (
|
||||
<Button size="lg" type="button" loading={applying} onClick={() => void onApply()}>
|
||||
Apply Now
|
||||
</Button>
|
||||
)}
|
||||
{verifyCta && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col gap-1 rounded-lg border border-blue-300 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-200"
|
||||
>
|
||||
<span>
|
||||
You must be 18+ with a verified identity to apply — it takes one step in
|
||||
enrollment.
|
||||
</span>
|
||||
<a href={verifyCta} className="font-medium underline">
|
||||
Verify your identity to continue →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{closed && (
|
||||
<p role="alert" className="text-sm text-amber-700 dark:text-amber-300">
|
||||
This posting is closed — it is no longer accepting applications.
|
||||
</p>
|
||||
)}
|
||||
{applyError && (
|
||||
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
|
||||
{applyError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="flex flex-col gap-6 lg:col-span-2">
|
||||
{/* P3 (Task 3-3-01, G-106): the skill-breakdown panel renders
|
||||
* ONLY from the board's search handoff — the exact computed
|
||||
* payload, never a re-ranked or fabricated number. A direct
|
||||
* visit (no handoff) renders NO panel (honest absence). */}
|
||||
{searchSelection &&
|
||||
typeof searchSelection.match_score === 'number' &&
|
||||
searchSelection.skill_breakdown.length > 0 && (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Why this matched your search
|
||||
</h2>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ring-2 ${matchBadgeClasses(matchPercent(searchSelection.match_score))}`}
|
||||
>
|
||||
{matchPercent(searchSelection.match_score)}% match
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{searchSelection.skill_breakdown.map((entry) => (
|
||||
<li
|
||||
key={entry.skill}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-medium text-slate-800 dark:text-slate-200">
|
||||
{entry.skill}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{entry.overlap === 1 ? 'in your query' : 'related skill'} ·{' '}
|
||||
{(entry.contribution * 100).toFixed(1)}% of the match
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`${entry.skill}: ${(entry.contribution * 100).toFixed(1)}% contribution`}
|
||||
className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"
|
||||
>
|
||||
<div
|
||||
className={matchBarClasses(entry.contribution)}
|
||||
style={{ width: `${Math.max(2, Math.min(100, entry.contribution * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Computed by semantic search when you arrived from a search result —
|
||||
direct visits show no score.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Job description
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
{descriptionParagraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{posting.required_competencies.length > 0 && (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Required competencies
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{posting.required_competencies.map((c) => (
|
||||
<li
|
||||
key={c}
|
||||
className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
<span>{c}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{posting.skills.length > 0 && (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Skills</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{posting.skills.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Employer
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
|
||||
>
|
||||
{initialsOf(employerName)}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{employerName}
|
||||
</span>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{employer?.industry ?? 'AI'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{employer && (
|
||||
<dl className="flex flex-col gap-1.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Size</dt>
|
||||
<dd className="text-slate-800 dark:text-slate-200">{employer.size}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Location</dt>
|
||||
<dd className="text-slate-800 dark:text-slate-200">{employer.location}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
<Link href={`/marketplace/employers/${posting.employer_id}`}>
|
||||
<Button variant="outline" size="sm" icon={<Building2 className="h-3.5 w-3.5" />} type="button">
|
||||
View Company
|
||||
</Button>
|
||||
</Link>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cosine ∈ [-1,1] → the honest 0–100% the badge renders (a negative
|
||||
* cosine renders 0%, never a fabricated negative match). */
|
||||
function matchPercent(score: number): number {
|
||||
return Math.max(0, Math.round(score * 100));
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Bookmark, Search, X } from 'lucide-react';
|
||||
import type { Job, Seniority } from '@nextcraft/types';
|
||||
import { Bookmark, Loader2, Search, X } from 'lucide-react';
|
||||
import type { PostingSummary, Seniority, SearchResult } from '@nextcraft/types';
|
||||
import { Button, Input } from '@nextcraft/ui';
|
||||
import {
|
||||
formatSalaryK,
|
||||
@@ -10,17 +10,14 @@ import {
|
||||
relativeTime,
|
||||
seniorityLabel,
|
||||
} from '../../lib/format';
|
||||
import { writeSearchSelection } from '../../lib/engine-client';
|
||||
|
||||
const POPULAR_SKILLS = [
|
||||
'LLM',
|
||||
'RAG',
|
||||
'Agents',
|
||||
'Python',
|
||||
'TypeScript',
|
||||
'React',
|
||||
'Vector DB',
|
||||
'Fine-tuning',
|
||||
] as const;
|
||||
// P2 (MH-2c): skills/seniority/category option lists are DATA-DRIVEN
|
||||
// from the API facets payload (stored tags of published postings);
|
||||
// location/remote/salary stay UI constants — they are posting fields,
|
||||
// not tags. Fallback to the classic option set when facets are absent
|
||||
// (pre-ingest boards show the honest empty state anyway).
|
||||
const FALLBACK_SKILLS = ['LLM', 'RAG', 'Agents', 'Python'] as const;
|
||||
|
||||
const SENIORITY_OPTIONS: Array<Seniority | 'all'> = [
|
||||
'all',
|
||||
@@ -32,8 +29,33 @@ const SENIORITY_OPTIONS: Array<Seniority | 'all'> = [
|
||||
];
|
||||
|
||||
export interface JobFiltersProps {
|
||||
jobs: Job[];
|
||||
postings: PostingSummary[];
|
||||
employerNames: Record<string, string>;
|
||||
/** P2: facet value counts from the API — drives the sidebar options. */
|
||||
facets?: {
|
||||
skills: Array<{ value: string; count: number }>;
|
||||
seniority: Array<{ value: string; count: number }>;
|
||||
category: Array<{ value: string; count: number }>;
|
||||
};
|
||||
/** P3 (G-106): the active submitted semantic search, if any. Its
|
||||
* ranked results (with computed match scores) REPLACE the client-side
|
||||
* filtered grid; without it, typing filters client-side with NO
|
||||
* scores (honest absence). */
|
||||
search?: {
|
||||
query: string;
|
||||
results: SearchResult[];
|
||||
loading: boolean;
|
||||
};
|
||||
/** P3: submit the query to the search API (semantic mode). */
|
||||
onSearch: (query: string) => void;
|
||||
/** P3: leave search mode — back to the fetch-all list view, no scores. */
|
||||
onClearSearch: () => void;
|
||||
/** P3: facet filter change while IN search mode — the board re-queries
|
||||
* the API (facet params ride the search request). */
|
||||
onSearchFiltersChange: (filters: SearchFacets) => void;
|
||||
/** P3: the current search-mode facet filters (the board owns the
|
||||
* re-query; the sidebar reflects them). */
|
||||
searchFilters?: SearchFacets;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
@@ -45,6 +67,16 @@ interface Filters {
|
||||
salaryMax: string;
|
||||
}
|
||||
|
||||
/** The facet subset that rides the search API while a query is active —
|
||||
* the board owns the re-query; JobFilters reflects and reports it. */
|
||||
export interface SearchFacets {
|
||||
skills: string[];
|
||||
seniority: Seniority | 'all';
|
||||
remoteOnly: boolean;
|
||||
salaryMin: string;
|
||||
salaryMax: string;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: Filters = {
|
||||
query: '',
|
||||
skills: [],
|
||||
@@ -54,61 +86,97 @@ const EMPTY_FILTERS: Filters = {
|
||||
salaryMax: '',
|
||||
};
|
||||
|
||||
function matchesJob(job: Job, f: Filters, employerName: string): boolean {
|
||||
function matchesPosting(
|
||||
posting: PostingSummary,
|
||||
f: Filters,
|
||||
employerName: string,
|
||||
): boolean {
|
||||
// Text query — match title, company, or any skill.
|
||||
if (f.query.trim()) {
|
||||
const q = f.query.trim().toLowerCase();
|
||||
const haystack = [
|
||||
job.title,
|
||||
posting.title,
|
||||
employerName,
|
||||
job.location,
|
||||
...job.skills,
|
||||
posting.location,
|
||||
...posting.skills,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
|
||||
// Skill filters — job must include every selected skill (case-insensitive).
|
||||
// Skill filters — posting must include every selected skill (case-insensitive).
|
||||
if (f.skills.length > 0) {
|
||||
const jobSkillsLower = job.skills.map((s) => s.toLowerCase());
|
||||
const postingSkillsLower = posting.skills.map((s) => s.toLowerCase());
|
||||
const ok = f.skills.every((sel) =>
|
||||
jobSkillsLower.some((s) => s.includes(sel.toLowerCase())),
|
||||
postingSkillsLower.some((s) => s.includes(sel.toLowerCase())),
|
||||
);
|
||||
if (!ok) return false;
|
||||
}
|
||||
|
||||
// Seniority.
|
||||
if (f.seniority !== 'all' && job.seniority !== f.seniority) return false;
|
||||
if (f.seniority !== 'all' && posting.seniority !== f.seniority) return false;
|
||||
|
||||
// Remote only.
|
||||
if (f.remoteOnly && !job.remote) return false;
|
||||
if (f.remoteOnly && !posting.remote) return false;
|
||||
|
||||
// Salary range.
|
||||
// Salary range (null salary_max = unbounded upper — matches any min filter;
|
||||
// null salary_min matches any max filter).
|
||||
if (f.salaryMin.trim()) {
|
||||
const min = Number(f.salaryMin);
|
||||
if (!Number.isNaN(min) && job.salaryMax < min) return false;
|
||||
if (!Number.isNaN(min) && (posting.salary_max ?? Infinity) < min) return false;
|
||||
}
|
||||
if (f.salaryMax.trim()) {
|
||||
const max = Number(f.salaryMax);
|
||||
if (!Number.isNaN(max) && job.salaryMin > max) return false;
|
||||
if (!Number.isNaN(max) && (posting.salary_min ?? 0) > max) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
export function JobFilters({
|
||||
postings,
|
||||
employerNames,
|
||||
facets,
|
||||
search,
|
||||
onSearch,
|
||||
onClearSearch,
|
||||
onSearchFiltersChange,
|
||||
searchFilters,
|
||||
}: JobFiltersProps) {
|
||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
||||
|
||||
// P3 search mode: a SUBMITTED query switches the grid to the ranked
|
||||
// API results (match scores render ONLY here — G-106). Typing without
|
||||
// submitting keeps the client-side filter behavior (no fabricated
|
||||
// scores).
|
||||
const inSearch = Boolean(search);
|
||||
|
||||
// P2: facet-driven skill options (top 8 by count); honest fallback when
|
||||
// the API carried no facets (untagged store).
|
||||
const skillOptions = useMemo(() => {
|
||||
const fromFacets = (facets?.skills ?? []).slice(0, 8).map((f) => f.value);
|
||||
return fromFacets.length > 0 ? fromFacets : [...FALLBACK_SKILLS];
|
||||
}, [facets]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
jobs.filter((job) =>
|
||||
matchesJob(job, filters, employerNames[job.employerId] ?? 'Unknown employer'),
|
||||
),
|
||||
[jobs, filters, employerNames],
|
||||
inSearch
|
||||
? [] // search mode renders `search.results` — no client-side filtering
|
||||
: postings.filter((posting) =>
|
||||
matchesPosting(posting, filters, employerNames[posting.employer_id] ?? 'Unknown employer'),
|
||||
),
|
||||
[postings, filters, employerNames, inSearch],
|
||||
);
|
||||
|
||||
function toggleSkill(skill: string) {
|
||||
if (inSearch) {
|
||||
const next = searchFilters?.skills.includes(skill)
|
||||
? (searchFilters?.skills ?? []).filter((s) => s !== skill)
|
||||
: [...(searchFilters?.skills ?? []), skill];
|
||||
onSearchFiltersChange({ ...(searchFilters ?? EMPTY_FILTERS), skills: next });
|
||||
return;
|
||||
}
|
||||
setFilters((f) => ({
|
||||
...f,
|
||||
skills: f.skills.includes(skill)
|
||||
@@ -118,30 +186,65 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
if (inSearch) {
|
||||
onClearSearch();
|
||||
return;
|
||||
}
|
||||
setFilters(EMPTY_FILTERS);
|
||||
}
|
||||
|
||||
// The active filter set that drives the count badge — search mode uses
|
||||
// the search-owned filter state (reflected, not owned, here).
|
||||
const activeFilters = inSearch ? (searchFilters ?? EMPTY_FILTERS) : filters;
|
||||
const activeCount =
|
||||
(filters.query.trim() ? 1 : 0) +
|
||||
filters.skills.length +
|
||||
(filters.seniority !== 'all' ? 1 : 0) +
|
||||
(filters.remoteOnly ? 1 : 0) +
|
||||
(filters.salaryMin.trim() ? 1 : 0) +
|
||||
(filters.salaryMax.trim() ? 1 : 0);
|
||||
(inSearch && search ? 1 : 0) +
|
||||
activeFilters.skills.length +
|
||||
(activeFilters.seniority !== 'all' ? 1 : 0) +
|
||||
(activeFilters.remoteOnly ? 1 : 0) +
|
||||
(activeFilters.salaryMin.trim() ? 1 : 0) +
|
||||
(activeFilters.salaryMax.trim() ? 1 : 0);
|
||||
|
||||
function onSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!filters.query.trim()) {
|
||||
// An empty submitted query is no search — stay in list view.
|
||||
onClearSearch();
|
||||
return;
|
||||
}
|
||||
onSearch(filters.query.trim());
|
||||
}
|
||||
|
||||
const selectedSkills = inSearch ? (searchFilters?.skills ?? []) : filters.skills;
|
||||
const selectedSeniority = inSearch
|
||||
? (searchFilters?.seniority ?? 'all')
|
||||
: filters.seniority;
|
||||
const remoteOnly = inSearch ? Boolean(searchFilters?.remoteOnly) : filters.remoteOnly;
|
||||
const salaryMin = inSearch ? (searchFilters?.salaryMin ?? '') : filters.salaryMin;
|
||||
const salaryMax = inSearch ? (searchFilters?.salaryMax ?? '') : filters.salaryMax;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Search row */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<form className="flex flex-col gap-3" onSubmit={onSearchSubmit}>
|
||||
<Input
|
||||
name="marketplace-search"
|
||||
placeholder="Search AI-era roles, skills, companies..."
|
||||
icon={<Search className="h-4 w-4" />}
|
||||
placeholder="Search AI-era roles, skills, companies… (Enter for semantic search)"
|
||||
icon={search?.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
value={filters.query}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, query: e.target.value }))}
|
||||
onChange={(e) => {
|
||||
// Typing without submitting keeps the client-side filter —
|
||||
// leaving an active search while typing switches back to
|
||||
// live filtering (the submitted query is stale, G-106: no
|
||||
// fabricated scores against a different input).
|
||||
if (search) onClearSearch();
|
||||
setFilters((f) => ({ ...f, query: e.target.value }));
|
||||
}}
|
||||
aria-label="Search jobs"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" type="submit" icon={<Search className="h-3.5 w-3.5" />}>
|
||||
Search
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -150,6 +253,20 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
>
|
||||
Save Search
|
||||
</Button>
|
||||
{inSearch && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<X className="h-3.5 w-3.5" />}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilters((f) => ({ ...f, query: '' }));
|
||||
onClearSearch();
|
||||
}}
|
||||
>
|
||||
Clear search
|
||||
</Button>
|
||||
)}
|
||||
{activeCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -162,10 +279,22 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
</Button>
|
||||
)}
|
||||
<span className="ml-auto text-sm text-slate-500 dark:text-slate-400">
|
||||
Showing <span className="font-semibold text-slate-700 dark:text-slate-200">{filtered.length}</span> of {jobs.length} jobs
|
||||
{inSearch ? (
|
||||
<>
|
||||
Semantic match for{' '}
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200">
|
||||
“{search?.query}”
|
||||
</span>{' '}
|
||||
— {search?.results.length ?? 0} ranked result{search?.results.length === 1 ? '' : 's'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Showing <span className="font-semibold text-slate-700 dark:text-slate-200">{filtered.length}</span> of {postings.length} jobs
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[260px_1fr]">
|
||||
{/* Sidebar filters */}
|
||||
@@ -174,13 +303,15 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Seniority</h3>
|
||||
<select
|
||||
aria-label="Seniority filter"
|
||||
value={filters.seniority}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({
|
||||
...f,
|
||||
seniority: e.target.value as Seniority | 'all',
|
||||
}))
|
||||
}
|
||||
value={selectedSeniority}
|
||||
onChange={(e) => {
|
||||
const seniority = e.target.value as Seniority | 'all';
|
||||
if (inSearch) {
|
||||
onSearchFiltersChange({ ...(searchFilters ?? EMPTY_FILTERS), seniority });
|
||||
return;
|
||||
}
|
||||
setFilters((f) => ({ ...f, seniority }));
|
||||
}}
|
||||
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
>
|
||||
{SENIORITY_OPTIONS.map((s) => (
|
||||
@@ -194,8 +325,9 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Popular skills</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{POPULAR_SKILLS.map((skill) => {
|
||||
const active = filters.skills.includes(skill);
|
||||
{skillOptions.map((skill) => {
|
||||
const active = selectedSkills.includes(skill);
|
||||
const count = facets?.skills.find((f) => f.value === skill)?.count;
|
||||
return (
|
||||
<button
|
||||
key={skill}
|
||||
@@ -210,6 +342,11 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
].join(' ')}
|
||||
>
|
||||
{skill}
|
||||
{count != null && (
|
||||
<span className={active ? 'ml-1 opacity-80' : 'ml-1 text-slate-400'}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -225,8 +362,15 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
step={1000}
|
||||
placeholder="Min"
|
||||
aria-label="Minimum salary"
|
||||
value={filters.salaryMin}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, salaryMin: e.target.value }))}
|
||||
value={salaryMin}
|
||||
onChange={(e) => {
|
||||
const salaryMin = e.target.value;
|
||||
if (inSearch) {
|
||||
onSearchFiltersChange({ ...(searchFilters ?? EMPTY_FILTERS), salaryMin });
|
||||
return;
|
||||
}
|
||||
setFilters((f) => ({ ...f, salaryMin }));
|
||||
}}
|
||||
className="h-9 w-full rounded-md border border-slate-300 bg-white px-2.5 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
/>
|
||||
<span className="text-slate-400">–</span>
|
||||
@@ -236,12 +380,22 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
step={1000}
|
||||
placeholder="Max"
|
||||
aria-label="Maximum salary"
|
||||
value={filters.salaryMax}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, salaryMax: e.target.value }))}
|
||||
value={salaryMax}
|
||||
onChange={(e) => {
|
||||
const salaryMax = e.target.value;
|
||||
if (inSearch) {
|
||||
onSearchFiltersChange({ ...(searchFilters ?? EMPTY_FILTERS), salaryMax });
|
||||
return;
|
||||
}
|
||||
setFilters((f) => ({ ...f, salaryMax }));
|
||||
}}
|
||||
className="h-9 w-full rounded-md border border-slate-300 bg-white px-2.5 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-500">Enter values in USD (e.g. 120000).</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-500">
|
||||
Enter values in USD (e.g. 120000).
|
||||
{inSearch && ' Filters ride the search API while a query is active.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -251,17 +405,23 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={filters.remoteOnly}
|
||||
onClick={() => setFilters((f) => ({ ...f, remoteOnly: !f.remoteOnly }))}
|
||||
aria-checked={remoteOnly}
|
||||
onClick={() => {
|
||||
if (inSearch) {
|
||||
onSearchFiltersChange({ ...(searchFilters ?? EMPTY_FILTERS), remoteOnly: !remoteOnly });
|
||||
return;
|
||||
}
|
||||
setFilters((f) => ({ ...f, remoteOnly: !f.remoteOnly }));
|
||||
}}
|
||||
className={[
|
||||
'relative h-6 w-11 rounded-full transition-colors',
|
||||
filters.remoteOnly ? 'bg-primary-600' : 'bg-slate-300 dark:bg-slate-700',
|
||||
remoteOnly ? 'bg-primary-600' : 'bg-slate-300 dark:bg-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
|
||||
filters.remoteOnly ? 'translate-x-5' : 'translate-x-0.5',
|
||||
remoteOnly ? 'translate-x-5' : 'translate-x-0.5',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
@@ -271,17 +431,43 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
|
||||
{/* Results grid */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{filtered.length === 0 ? (
|
||||
{inSearch ? (
|
||||
search?.loading ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center justify-center gap-2 rounded-lg border border-slate-200 p-12 text-slate-500 dark:border-slate-700 dark:text-slate-400"
|
||||
>
|
||||
<Loader2 className="h-5 w-5 animate-spin" aria-hidden />
|
||||
Ranking postings for “{search?.query}”…
|
||||
</div>
|
||||
) : search && search.results.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 p-12 text-center text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
No postings match your search. Try a different query or clear the filters.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{search?.results.map((result) => (
|
||||
<PostingCard
|
||||
key={result.posting.id}
|
||||
posting={result.posting}
|
||||
employerName={employerNames[result.posting.employer_id] ?? 'Unknown employer'}
|
||||
match_score={result.match_score}
|
||||
onOpen={() => writeSearchSelection(result)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 p-12 text-center text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
No jobs match your filters. Try clearing some filters.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{filtered.map((job) => (
|
||||
<JobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
employerName={employerNames[job.employerId] ?? 'Unknown employer'}
|
||||
{filtered.map((posting) => (
|
||||
<PostingCard
|
||||
key={posting.id}
|
||||
posting={posting}
|
||||
employerName={employerNames[posting.employer_id] ?? 'Unknown employer'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -292,7 +478,21 @@ export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function JobCard({ job, employerName }: { job: Job; employerName: string }) {
|
||||
/** G-106: the match badge renders ONLY in search context — the card
|
||||
* takes `match_score` solely from the search response's computed value;
|
||||
* without an active query the prop is absent and NO score renders
|
||||
* (honest absence, never a fabricated number). */
|
||||
function PostingCard({
|
||||
posting,
|
||||
employerName,
|
||||
match_score,
|
||||
onOpen,
|
||||
}: {
|
||||
posting: PostingSummary;
|
||||
employerName: string;
|
||||
match_score?: number | null;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<article className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -305,40 +505,49 @@ function JobCard({ job, employerName }: { job: Job; employerName: string }) {
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
{job.title}
|
||||
{posting.title}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{employerName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
|
||||
job.matchScore,
|
||||
)}`}
|
||||
title={`${job.matchScore}% AI match`}
|
||||
>
|
||||
{job.matchScore}%
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{typeof match_score === 'number' && (
|
||||
<span
|
||||
className={`inline-flex h-11 w-12 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(matchPercent(match_score))}`}
|
||||
aria-label={`Search match score ${matchPercent(match_score)}%`}
|
||||
>
|
||||
{matchPercent(match_score)}%
|
||||
</span>
|
||||
)}
|
||||
{posting.status === 'closed' && (
|
||||
<span className="inline-flex h-11 w-fit shrink-0 items-center rounded-full bg-slate-100 px-3 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-400">
|
||||
Closed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-600 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-slate-400">📍</span>
|
||||
{job.location}
|
||||
{posting.location}
|
||||
</span>
|
||||
{job.remote && (
|
||||
{posting.remote && (
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
Remote
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-slate-400">$</span>
|
||||
{formatSalaryK(job.salaryMin)}–{formatSalaryK(job.salaryMax)}
|
||||
</span>
|
||||
<span>{relativeTime(job.postedAt)}</span>
|
||||
{posting.salary_min != null && posting.salary_max != null && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-slate-400">$</span>
|
||||
{formatSalaryK(posting.salary_min)}–{formatSalaryK(posting.salary_max)}
|
||||
</span>
|
||||
)}
|
||||
{posting.posted_at && <span>{relativeTime(posting.posted_at)}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{job.skills.slice(0, 5).map((skill) => (
|
||||
{posting.skills.slice(0, 5).map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200"
|
||||
@@ -350,7 +559,8 @@ function JobCard({ job, employerName }: { job: Job; employerName: string }) {
|
||||
|
||||
<div className="mt-auto pt-1">
|
||||
<a
|
||||
href={`/marketplace/jobs/${job.id}`}
|
||||
href={`/marketplace/jobs/${posting.id}`}
|
||||
onClick={onOpen}
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-md border border-slate-300 bg-transparent text-sm font-medium text-slate-800 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
View Details
|
||||
@@ -360,6 +570,12 @@ function JobCard({ job, employerName }: { job: Job; employerName: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Cosine ∈ [-1,1] → the honest 0–100% the badge renders (a negative
|
||||
* cosine renders 0%, never a fabricated negative match). */
|
||||
function matchPercent(score: number): number {
|
||||
return Math.max(0, Math.round(score * 100));
|
||||
}
|
||||
|
||||
// small inline helper so the card does not depend on the lib file twice
|
||||
function initialsOf(name: string): string {
|
||||
return name
|
||||
|
||||
@@ -20,6 +20,13 @@ import type {
|
||||
DefenseFinish,
|
||||
DefenseSession,
|
||||
TelemetryEvent,
|
||||
PostingSummary,
|
||||
PostingDetail,
|
||||
EmployerProfile,
|
||||
ApplicationConfirmation,
|
||||
PostingFacets,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
} from '@nextcraft/types';
|
||||
|
||||
import { engineBaseUrl } from './engine-base-url';
|
||||
@@ -448,4 +455,232 @@ export async function proctorSignals(
|
||||
body: JSON.stringify({ learner_id: learnerId, task_id: taskId }),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- marketplace (REQ-6-001, D-055/G-103) ----------------------------------------
|
||||
|
||||
/** A 404 from a marketplace detail route — the shells render notFound()
|
||||
* on it (honest absence, never a fabricated posting/employer). */
|
||||
export class NotFoundError extends EngineError {
|
||||
constructor(message = 'Not found.') {
|
||||
super(message, 404);
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The result of the board's paged fetch-all (G-103): every published
|
||||
* posting fetched so far + the server's honest total_published, so the
|
||||
* board can render "Showing X of Y" with a truncation notice when the
|
||||
* page bound stopped the walk early. */
|
||||
export interface PostingsFetchAll {
|
||||
postings: PostingSummary[];
|
||||
totalPublished: number;
|
||||
/** True when the page-bound stopped the walk before exhaustion. */
|
||||
truncated: boolean;
|
||||
/** P2 (MH-2c): facet value counts from published-only stored tags —
|
||||
* data-drives the filter sidebar's skills/seniority/category options.
|
||||
* The LAST page's value wins (identical on every page by contract). */
|
||||
facets?: PostingFacets;
|
||||
}
|
||||
|
||||
/** Paged fetch-all page bound: 100 postings/page × 50 pages = 5,000
|
||||
* fetched rows (the documented ≤10⁴ pilot corpus, A-403, fits with
|
||||
* headroom). Past the bound the walk stops and `truncated` goes true
|
||||
* so the board shows the honest truncation notice (G-103). */
|
||||
export const POSTINGS_MAX_PAGES = 50;
|
||||
export const POSTINGS_PAGE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Walk limit/offset pages until exhausted (G-103). Each response carries
|
||||
* `total_published` — the LAST page's value wins (one SQL COUNT per page,
|
||||
* monotone within a request window). The UI filters client-side over the
|
||||
* fetched set (grill G-103: filters are client-side this phase; facet
|
||||
* server-filters land P2/P3).
|
||||
*/
|
||||
export async function listPostings(signal?: AbortSignal): Promise<PostingsFetchAll> {
|
||||
const postings: PostingSummary[] = [];
|
||||
let totalPublished = 0;
|
||||
let truncated = false;
|
||||
let facets: PostingFacets | undefined;
|
||||
for (let page = 0; page < POSTINGS_MAX_PAGES; page++) {
|
||||
const body = await jsonFetch<{
|
||||
postings: PostingSummary[];
|
||||
total_published: number;
|
||||
facets?: PostingFacets;
|
||||
}>(
|
||||
`/v1/marketplace/postings?limit=${POSTINGS_PAGE_SIZE}&offset=${page * POSTINGS_PAGE_SIZE}`,
|
||||
{ signal },
|
||||
);
|
||||
totalPublished = body.total_published;
|
||||
facets = body.facets;
|
||||
postings.push(...(body.postings ?? []));
|
||||
if (postings.length >= totalPublished) return { postings, totalPublished, truncated, facets };
|
||||
if ((body.postings ?? []).length < POSTINGS_PAGE_SIZE) {
|
||||
// Fewer rows than requested but the count disagrees (concurrent
|
||||
// publish race) — treat as exhausted only when nothing new arrives.
|
||||
if ((body.postings ?? []).length === 0) return { postings, totalPublished, truncated, facets };
|
||||
}
|
||||
}
|
||||
truncated = postings.length < totalPublished;
|
||||
return { postings, totalPublished, truncated, facets };
|
||||
}
|
||||
|
||||
/** One posting by id — typed 404 (NotFoundError) on unknown ids. */
|
||||
export async function getPosting(id: string, signal?: AbortSignal): Promise<PostingDetail> {
|
||||
try {
|
||||
return await jsonFetch<PostingDetail>(
|
||||
`/v1/marketplace/postings/${encodeURIComponent(id)}`,
|
||||
{ signal },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof EngineError && err.status === 404) {
|
||||
throw new NotFoundError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** One employer by id — typed 404 (NotFoundError) on unknown ids. */
|
||||
export async function getEmployer(id: string, signal?: AbortSignal): Promise<EmployerProfile> {
|
||||
try {
|
||||
return await jsonFetch<EmployerProfile>(
|
||||
`/v1/marketplace/employers/${encodeURIComponent(id)}`,
|
||||
{ signal },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof EngineError && err.status === 404) {
|
||||
throw new NotFoundError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply to a posting (D-058). Gate composition server-side (D-043):
|
||||
* 403 NotAllowlisted → 403 VerifyRequired (verify-CTA discrimination
|
||||
* reused) → 429 RateLimited → 404 missing → 409 closed → 200 honest
|
||||
* confirmation (a duplicate returns the EXISTING application). */
|
||||
export async function applyPosting(
|
||||
postingId: string,
|
||||
learnerId: string = MOCK_LEARNER_ID,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationConfirmation> {
|
||||
return jsonFetch('/v1/marketplace/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ learner_id: learnerId, posting_id: postingId }),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
// -- marketplace search (REQ-6-003, P3/G-106) ------------------------------------
|
||||
|
||||
/** Facet filters that ride the search API params (mirror of the
|
||||
* /v1/marketplace/search route signature — skills as repeated params). */
|
||||
export interface SearchFilters {
|
||||
skills?: string[];
|
||||
seniority?: string | null;
|
||||
category?: string | null;
|
||||
location?: string | null;
|
||||
remote?: boolean | null;
|
||||
salaryMin?: number | null;
|
||||
salaryMax?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* P3 semantic search: GET /v1/marketplace/search. The response carries
|
||||
* `match_score` + `skill_breakdown` per result — the ONLY place scores
|
||||
* exist (G-106: computed search-time context; an empty `q` returns
|
||||
* facet-only results with match_score null — honest absence).
|
||||
*/
|
||||
export async function searchPostings(
|
||||
q: string,
|
||||
filters?: SearchFilters,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SearchResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', q);
|
||||
for (const skill of filters?.skills ?? []) {
|
||||
if (skill) params.append('skills', skill);
|
||||
}
|
||||
if (filters?.seniority) params.set('seniority', filters.seniority);
|
||||
if (filters?.category) params.set('category', filters.category);
|
||||
if (filters?.location) params.set('location', filters.location);
|
||||
if (filters?.remote != null) params.set('remote', String(filters.remote));
|
||||
if (filters?.salaryMin != null) params.set('salary_min', String(filters.salaryMin));
|
||||
if (filters?.salaryMax != null) params.set('salary_max', String(filters.salaryMax));
|
||||
return jsonFetch<SearchResponse>(
|
||||
`/v1/marketplace/search?${params.toString()}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
/** sessionStorage key for the board → detail search handoff (G-106
|
||||
* state wiring): the board writes the selected search result's computed
|
||||
* payload under this key when a ranked card is clicked; the detail
|
||||
* shell reads it ONCE (and clears it) — a detail visit without an
|
||||
* active search shows NO score/breakdown (honest absence; detail
|
||||
* fetches never re-rank). */
|
||||
export const SEARCH_SELECTED_KEY = 'marketplace-search-selected';
|
||||
|
||||
/** The handoff payload: exactly what the search response carried for the
|
||||
* clicked result — never a re-computed or fabricated number. */
|
||||
export function readSearchSelection(): SearchResult | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SEARCH_SELECTED_KEY);
|
||||
if (!raw) return null;
|
||||
sessionStorage.removeItem(SEARCH_SELECTED_KEY);
|
||||
return JSON.parse(raw) as SearchResult;
|
||||
} catch {
|
||||
return null; // storage unavailable/corrupt → honest absence
|
||||
}
|
||||
}
|
||||
|
||||
/** Board-side writer: stash the clicked search result for the detail shell. */
|
||||
export function writeSearchSelection(result: SearchResult): void {
|
||||
try {
|
||||
sessionStorage.setItem(SEARCH_SELECTED_KEY, JSON.stringify(result));
|
||||
} catch {
|
||||
/* storage unavailable → the detail page simply shows no breakdown */
|
||||
}
|
||||
}
|
||||
// -- P4-B: resume upload + confirm (REQ-6-005, D-054) ----------------------------
|
||||
|
||||
/** The parse result — a DRAFT. Nothing persists without the explicit
|
||||
* confirm (the review gate). Dual-schema with ResumeDraftResponse. */
|
||||
export interface ResumeDraft {
|
||||
status: 'draft';
|
||||
profile: {
|
||||
name?: string;
|
||||
headline?: string;
|
||||
skills?: string[];
|
||||
experience_bands?: Array<{ role: string; years: number }>;
|
||||
competencies?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Upload a resume for parsing (multipart). Bounds errors surface as
|
||||
* EngineError with honest 413/422 statuses from the route (A-410). */
|
||||
export async function uploadResume(
|
||||
learnerId: string,
|
||||
file: File,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ResumeDraft> {
|
||||
const form = new FormData();
|
||||
form.append('learner_id', learnerId);
|
||||
form.append('file', file);
|
||||
return jsonFetch<ResumeDraft>('/v1/marketplace/resume', { signal, body: form });
|
||||
}
|
||||
|
||||
/** The explicit save decision — persists the confirmed profile (the
|
||||
* only write path; the draft itself never persisted). */
|
||||
export async function confirmResume(
|
||||
learnerId: string,
|
||||
profile: ResumeDraft['profile'],
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ status: string; learner_id: string; confirmed_at: string }> {
|
||||
return jsonFetch('/v1/marketplace/resume/confirm', {
|
||||
signal,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ learner_id: learnerId, profile }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Runtime id extraction for the G-101 placeholder shells.
|
||||
*
|
||||
* The static export emits one page per dynamic route (the `_`
|
||||
* placeholder param); the catch-all serves it at EVERY runtime URL.
|
||||
* The page's RSC payload therefore carries the placeholder param —
|
||||
* `useParams()` returns `_` even at `/marketplace/jobs/job-001/` — so
|
||||
* the shells read the REAL id from the browser path (client-effect
|
||||
* only; never during SSR/hydration).
|
||||
*/
|
||||
|
||||
/** Last non-empty path segment, URI-decoded (trailingSlash-safe). */
|
||||
export function segmentFromPath(pathname: string): string {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const raw = segments[segments.length - 1] ?? '';
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
// Malformed percent-encoding — use the raw segment verbatim.
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,13 @@ const config: NextConfig = {
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
images: { unoptimized: true },
|
||||
// Build-worker cap: Next forks workers (and the SWC rayon pool) per
|
||||
// CPU — 32 on the deploy host — which exhausts small cgroup pid
|
||||
// budgets (EAGAIN aborts the build mid-emit). A 2-worker build is
|
||||
// correct everywhere and only costs wall-clock on many-core boxes.
|
||||
// Verified emitting under this cap (P3): all 247 pages + the two
|
||||
// G-101 placeholder shells.
|
||||
experimental: { cpus: 2 },
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,445 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Task 3-3-01 (REQ-6-003, G-106/G-107, MH-3d web-side): semantic
|
||||
* search wiring on the marketplace surfaces. Fetch stubs pin the wire
|
||||
* shapes without a server; source-level pins cover the 'use client'
|
||||
* shells (window/navigation APIs node:test cannot mount — behavior
|
||||
* pinned by the fetch-backed contracts + typecheck + the export build).
|
||||
*
|
||||
* - engine-client searchPostings: the /v1/marketplace/search route's
|
||||
* exact query params (q, repeated skills, facet params) + the typed
|
||||
* SearchResponse (dual-schema parity with api/marketplace.py).
|
||||
* - G-106: match scores render ONLY in search context — a submitted
|
||||
* query switches the board to ranked results with computed match
|
||||
* badges; the fetch-all list view shows NO score (honest absence).
|
||||
* - The board → detail sessionStorage handoff: the search payload rides
|
||||
* 'marketplace-search-selected'; the detail shell reads it ONCE and
|
||||
* renders the skill-breakdown panel only when present.
|
||||
*/
|
||||
|
||||
const QS = `?t=${Date.now()}-${Math.random()}`;
|
||||
|
||||
function wirePosting(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: "job-001",
|
||||
source: "seed",
|
||||
external_id: "job-001",
|
||||
employer_id: "emp-1",
|
||||
title: "AI Platform Engineer",
|
||||
description: "Build the platform.",
|
||||
required_competencies: ["stack-orchestration-c001"],
|
||||
skills: ["Python", "LLM"],
|
||||
seniority: "senior",
|
||||
category: null,
|
||||
location: "San Francisco",
|
||||
remote: true,
|
||||
salary_min: 160000,
|
||||
salary_max: 240000,
|
||||
status: "published",
|
||||
tags: null,
|
||||
posted_at: "2026-09-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// -- engine-client searchPostings (fetch stub: params + typed response) ------
|
||||
|
||||
test("searchPostings: GETs /v1/marketplace/search with the route's exact query params", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
calls.push(String(input));
|
||||
return new Response(
|
||||
JSON.stringify({ results: [], query: "python", ranked: 0 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { searchPostings } = await import(`../lib/engine-client.ts${QS}`);
|
||||
await searchPostings("python flask");
|
||||
const url = new URL(calls[0], "http://localhost");
|
||||
assert.equal(
|
||||
url.pathname,
|
||||
"/v1/marketplace/search",
|
||||
`unexpected URL: ${calls[0]}`,
|
||||
);
|
||||
assert.equal(
|
||||
url.searchParams.get("q"),
|
||||
"python flask",
|
||||
`q must ride as a search param: ${calls[0]}`,
|
||||
);
|
||||
assert.ok(!calls[0].includes("skills="), "no skills → no skills param");
|
||||
assert.ok(!calls[0].includes("seniority="), "absent facet → no param");
|
||||
assert.ok(!calls[0].includes("remote="), "absent facet → no param");
|
||||
assert.ok(!calls[0].includes("salary_min="), "absent facet → no param");
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("searchPostings: facet filters mirror the route signature (repeated skills params)", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
calls.push(String(input));
|
||||
return new Response(
|
||||
JSON.stringify({ results: [], query: "python", ranked: 0 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { searchPostings } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const response = await searchPostings("python", {
|
||||
skills: ["Python", "RAG"],
|
||||
seniority: "senior",
|
||||
location: "Berlin",
|
||||
remote: true,
|
||||
salaryMin: 150000,
|
||||
salaryMax: 250000,
|
||||
});
|
||||
const url = new URL(calls[0], "http://localhost");
|
||||
// q first, then the facet params mirroring the py route signature.
|
||||
assert.equal(url.searchParams.get("q"), "python");
|
||||
assert.deepEqual(url.searchParams.getAll("skills"), ["Python", "RAG"]);
|
||||
assert.equal(url.searchParams.get("seniority"), "senior");
|
||||
assert.equal(url.searchParams.get("location"), "Berlin");
|
||||
assert.equal(url.searchParams.get("remote"), "true");
|
||||
assert.equal(url.searchParams.get("salary_min"), "150000");
|
||||
assert.equal(url.searchParams.get("salary_max"), "250000");
|
||||
// Typed response shape (SearchResponse parity).
|
||||
assert.deepEqual(Object.keys(response), ["results", "query", "ranked"]);
|
||||
assert.equal(response.query, "python");
|
||||
assert.equal(response.ranked, 0);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("searchPostings: typed SearchResponse with match_score + skill_breakdown per result", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
posting: wirePosting(),
|
||||
match_score: 0.812345,
|
||||
skill_breakdown: [
|
||||
{ skill: "Python", overlap: 1, contribution: 0.42 },
|
||||
{ skill: "LLM", overlap: 0, contribution: 0.0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
query: "python backend",
|
||||
ranked: 1,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
)) as typeof fetch;
|
||||
try {
|
||||
const { searchPostings } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const response = await searchPostings("python backend");
|
||||
assert.equal(response.ranked, 1);
|
||||
const first = response.results[0];
|
||||
// py SearchEntry parity: posting (PostingSummary-shaped) + the
|
||||
// computed score + the deterministic per-skill explanation.
|
||||
assert.equal(first.posting.id, "job-001");
|
||||
assert.equal(first.posting.title, "AI Platform Engineer");
|
||||
assert.equal(first.match_score, 0.812345);
|
||||
assert.deepEqual(first.skill_breakdown, [
|
||||
{ skill: "Python", overlap: 1, contribution: 0.42 },
|
||||
{ skill: "LLM", overlap: 0, contribution: 0.0 },
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("searchPostings: facet-only search (empty q) carries match_score null — honest absence", async () => {
|
||||
// G-106: an empty q with facets is a facet-only listing — the py
|
||||
// route returns match_score: null (no query → no fabricated scores).
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
posting: wirePosting({ id: "job-facet" }),
|
||||
match_score: null,
|
||||
skill_breakdown: [],
|
||||
},
|
||||
],
|
||||
query: "",
|
||||
ranked: 1,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
)) as typeof fetch;
|
||||
try {
|
||||
const { searchPostings } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const response = await searchPostings("", { skills: ["Python"] });
|
||||
assert.equal(response.ranked, 1);
|
||||
assert.equal(response.results[0].match_score, null);
|
||||
assert.deepEqual(response.results[0].skill_breakdown, []);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("searchPostings: an error status surfaces as EngineError (the client taxonomy)", async () => {
|
||||
const { searchPostings, EngineError } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ detail: "search unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => searchPostings("python"),
|
||||
(err: unknown) => err instanceof EngineError,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
// -- sessionStorage handoff (board → detail search payload, G-106) -----------
|
||||
|
||||
/** Minimal sessionStorage stub for node:test (read-once semantics). */
|
||||
function stubSessionStorage(initial: Record<string, string> = {}) {
|
||||
const store = new Map(Object.entries(initial));
|
||||
const handlers = {
|
||||
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
key: (i: number) => [...store.keys()][i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "sessionStorage", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: handlers,
|
||||
});
|
||||
return {
|
||||
restore() {
|
||||
delete (globalThis as Record<string, unknown>).sessionStorage;
|
||||
},
|
||||
raw: store,
|
||||
};
|
||||
}
|
||||
|
||||
test("sessionStorage handoff: writeSearchSelection → readSearchSelection returns the payload ONCE", async () => {
|
||||
const storage = stubSessionStorage();
|
||||
try {
|
||||
const {
|
||||
writeSearchSelection,
|
||||
readSearchSelection,
|
||||
SEARCH_SELECTED_KEY,
|
||||
} = await import(`../lib/engine-client.ts${QS}`);
|
||||
assert.equal(SEARCH_SELECTED_KEY, "marketplace-search-selected");
|
||||
const selection = {
|
||||
posting: wirePosting({ id: "job-9" }) as never,
|
||||
match_score: 0.75,
|
||||
skill_breakdown: [{ skill: "Python", overlap: 1, contribution: 0.5 }],
|
||||
};
|
||||
writeSearchSelection(selection);
|
||||
assert.ok(storage.raw.has(SEARCH_SELECTED_KEY), "the board stashes the payload");
|
||||
const read = readSearchSelection();
|
||||
assert.equal(read?.match_score, 0.75);
|
||||
assert.equal(read?.skill_breakdown[0].skill, "Python");
|
||||
// Read ONCE: the second read is an honest absence (the handoff is
|
||||
// consumed — a later detail visit never re-renders a stale score).
|
||||
assert.equal(readSearchSelection(), null, "the handoff is single-use");
|
||||
assert.ok(!storage.raw.has(SEARCH_SELECTED_KEY), "reading clears the key");
|
||||
} finally {
|
||||
storage.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("sessionStorage handoff: absent/corrupt payload → null (honest absence, never a crash)", async () => {
|
||||
const storage = stubSessionStorage();
|
||||
try {
|
||||
const { readSearchSelection } = await import(`../lib/engine-client.ts${QS}`);
|
||||
assert.equal(readSearchSelection(), null, "no handoff → no score context");
|
||||
storage.raw.set("marketplace-search-selected", "not-json{{");
|
||||
assert.equal(readSearchSelection(), null, "corrupt handoff → honest absence");
|
||||
} finally {
|
||||
storage.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// -- dual-schema parity (SearchResponse fields vs the py route) ---------------
|
||||
|
||||
test("dual-schema sync: SearchResult/SearchResponse mirror api/marketplace.py search contracts", async () => {
|
||||
const typesSrc = readFileSync("../../packages/types/marketplace.ts", "utf8");
|
||||
const pySrc = readFileSync(
|
||||
"../ai-service/ai_service/api/marketplace.py",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// SkillContribution mirrors py SkillBreakdownEntry field-for-field.
|
||||
const contribBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface SkillContribution"),
|
||||
typesSrc.indexOf("export interface SearchResult"),
|
||||
);
|
||||
for (const field of ["skill:", "overlap:", "contribution:"]) {
|
||||
assert.ok(contribBlock.includes(field), `SkillContribution missing ${field}`);
|
||||
}
|
||||
// SearchResult mirrors py SearchEntry: posting + match_score +
|
||||
// skill_breakdown — snake_case, number|null score.
|
||||
const resultBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface SearchResult"),
|
||||
typesSrc.indexOf("export interface SearchResponse"),
|
||||
);
|
||||
for (const field of [
|
||||
"posting: PostingSummary;",
|
||||
"match_score: number | null;",
|
||||
"skill_breakdown: SkillContribution[];",
|
||||
]) {
|
||||
assert.ok(resultBlock.includes(field), `SearchResult missing ${field}`);
|
||||
}
|
||||
// SearchResponse mirrors py SearchResponse.
|
||||
const responseBlock = typesSrc.slice(typesSrc.indexOf("export interface SearchResponse"));
|
||||
for (const field of [
|
||||
"results: SearchResult[];",
|
||||
"query: string;",
|
||||
"ranked: number;",
|
||||
]) {
|
||||
assert.ok(responseBlock.includes(field), `SearchResponse missing ${field}`);
|
||||
}
|
||||
|
||||
// The py side pins the same three fields on each contract — a
|
||||
// field-for-field guard against drift in either direction.
|
||||
for (const pyField of [
|
||||
"class SkillBreakdownEntry",
|
||||
"skill: str",
|
||||
"overlap: int",
|
||||
"contribution: float",
|
||||
"class SearchEntry",
|
||||
"posting: PostingResponse",
|
||||
"match_score: float | None",
|
||||
"skill_breakdown: list[SkillBreakdownEntry]",
|
||||
"class SearchResponse",
|
||||
"results: list[SearchEntry]",
|
||||
"query: str",
|
||||
"ranked: int",
|
||||
]) {
|
||||
assert.ok(pySrc.includes(pyField), `py search contract missing ${pyField}`);
|
||||
}
|
||||
});
|
||||
|
||||
// -- G-106 source-level pins (search-only scores) ------------------------------
|
||||
|
||||
test("G-106: match badge source pin — the search flow renders computed scores ONLY in search context", async () => {
|
||||
const board = readFileSync("components/marketplace/board-client.tsx", "utf8");
|
||||
const filters = readFileSync("components/marketplace/job-filters.tsx", "utf8");
|
||||
const detail = readFileSync("components/marketplace/job-detail-client.tsx", "utf8");
|
||||
|
||||
// The board owns the search state: a SUBMITTED query triggers the
|
||||
// API search; "Clear search" returns to the fetch-all list view.
|
||||
assert.ok(/searchPostings\(/.test(board), "the board calls searchPostings on submit");
|
||||
assert.ok(/onClearSearch/.test(board), "the board exposes a clear-search path");
|
||||
assert.ok(/writeSearchSelection/.test(filters), "ranked cards write the detail handoff");
|
||||
|
||||
// The card's match badge renders ONLY when the search response
|
||||
// provided a computed score (match_score prop from search.results) —
|
||||
// the non-search card render passes NO match_score.
|
||||
assert.ok(
|
||||
/match_score=\{result\.match_score\}/.test(filters),
|
||||
"the badge's ONLY source is the search response's computed value",
|
||||
);
|
||||
assert.ok(
|
||||
/typeof match_score === 'number'/.test(filters),
|
||||
"a null/absent score renders NO badge (honest absence)",
|
||||
);
|
||||
// The non-search grid renders PostingCard with no match_score prop:
|
||||
// the list-view render block passes only posting + employerName.
|
||||
const listCardProps = filters.match(
|
||||
/filtered\.map\(\(posting\) => \(\s*<PostingCard[\s\S]{0,220}?\/>/,
|
||||
);
|
||||
assert.ok(listCardProps, "list-view card render found");
|
||||
assert.ok(
|
||||
!/match_score/.test(listCardProps[0]),
|
||||
"list-view cards carry no score",
|
||||
);
|
||||
// The clear-search path resets search state (back to no scores).
|
||||
assert.ok(/Clear search/.test(filters), "the search bar offers Clear search");
|
||||
assert.ok(/No postings match your search/.test(filters), "honest zero-results state");
|
||||
|
||||
// The detail shell renders the breakdown ONLY from the handoff.
|
||||
assert.ok(/readSearchSelection\(\)/.test(detail), "detail reads the handoff once");
|
||||
assert.ok(
|
||||
/skill_breakdown\.length > 0/.test(detail),
|
||||
"the breakdown panel gates on the computed payload",
|
||||
);
|
||||
assert.ok(
|
||||
/Why this matched your search/.test(detail),
|
||||
"the breakdown panel renders from the search payload",
|
||||
);
|
||||
});
|
||||
|
||||
test("G-106/G-107: no matchScore on non-search marketplace surfaces (grep-excludes mock-data + employer/admin groups)", async () => {
|
||||
// G-107 scope: packages/mock-data and the employer/admin route
|
||||
// groups stay mock (A-406/A-407) — only the marketplace path is pinned.
|
||||
const surfaces = [
|
||||
"components/marketplace/board-client.tsx",
|
||||
"components/marketplace/job-detail-client.tsx",
|
||||
"components/marketplace/employer-profile-client.tsx",
|
||||
"components/marketplace/job-filters.tsx",
|
||||
];
|
||||
for (const f of surfaces) {
|
||||
const src = readFileSync(f, "utf8");
|
||||
assert.ok(!/matchScore/.test(src), `${f} must not reference mock matchScore`);
|
||||
}
|
||||
// The wire types carry ONLY the snake_case search contract —
|
||||
// `match_score` on SearchResult, never a matchScore wire field.
|
||||
const typesSrc = readFileSync("../../packages/types/marketplace.ts", "utf8");
|
||||
const wireBlock = typesSrc.slice(typesSrc.indexOf("export type PostingStatus"));
|
||||
const scoreFields = wireBlock
|
||||
.split("\n")
|
||||
.filter((l) => /^\s*\w+\??:/.test(l))
|
||||
.filter((l) => /matchScore/i.test(l));
|
||||
assert.equal(
|
||||
scoreFields.length,
|
||||
0,
|
||||
`wire shapes must not carry matchScore fields: ${scoreFields}`,
|
||||
);
|
||||
assert.ok(
|
||||
/match_score: number \| null;/.test(wireBlock),
|
||||
"the search wire shape carries the computed match_score",
|
||||
);
|
||||
});
|
||||
|
||||
test("G-106: the fetch-all list view stays score-free (search is the ONLY score surface)", async () => {
|
||||
const src = readFileSync("components/marketplace/board-client.tsx", "utf8");
|
||||
// The board's list path still runs the paged fetch-all + passes the
|
||||
// postings to JobFilters — scores never enter this path. The search
|
||||
// state defaults to null (list view) and JobFilters receives NO
|
||||
// search prop in that state (`search ? {...} : undefined`).
|
||||
assert.match(src, /await listPostings/);
|
||||
assert.match(src, /useState<SearchState \| null>\(null\)/);
|
||||
assert.match(src, /: undefined/);
|
||||
// listPostings itself stays untouched by search concerns: no
|
||||
// match_score anywhere in the fetch-all wire contract.
|
||||
const client = readFileSync("lib/engine-client.ts", "utf8");
|
||||
const listPostingsBlock = client
|
||||
.slice(client.indexOf("export async function listPostings"))
|
||||
.split("// --")[0];
|
||||
assert.ok(!/match_score/.test(listPostingsBlock), "fetch-all carries no scores");
|
||||
});
|
||||
|
||||
test("P3 empty-search state: zero ranked results render the honest message (never fake cards)", async () => {
|
||||
const src = readFileSync("components/marketplace/job-filters.tsx", "utf8");
|
||||
// The search-mode empty state is DISTINCT from the client-filter
|
||||
// empty state ("No jobs match your filters").
|
||||
assert.match(src, /search && search\.results\.length === 0/);
|
||||
assert.match(src, /No postings match your search/);
|
||||
// The ranked grid maps over search.results — the ONLY render path
|
||||
// that touches match scores.
|
||||
assert.match(src, /search\?\.results\.map/);
|
||||
});
|
||||
@@ -0,0 +1,460 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Task 1-4-03 / MH-1e (REQ-6-001, G-101/G-103/G-106): marketplace web
|
||||
* client shells. Fetch stubs pin the wire shapes without a server.
|
||||
*
|
||||
* - engine-client: listPostings paged fetch-all + truncation flag,
|
||||
* getPosting/getEmployer typed 404s, applyPosting body + 403/409
|
||||
* discrimination (the G-10 taxonomy reused).
|
||||
* - shells: source-level behavior pins (the 'use client' shells use
|
||||
* window/navigation APIs node:test cannot mount; behavior is pinned
|
||||
* by the fetch-backed contracts + the honest-state markup they
|
||||
* render, verified by typecheck + the export build).
|
||||
*/
|
||||
|
||||
const QS = `?t=${Date.now()}-${Math.random()}`;
|
||||
|
||||
function wirePosting(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: "job-001",
|
||||
source: "seed",
|
||||
external_id: "job-001",
|
||||
employer_id: "emp-1",
|
||||
title: "AI Platform Engineer",
|
||||
description: "Build the platform.",
|
||||
required_competencies: ["stack-orchestration-c001"],
|
||||
skills: ["Python", "LLM"],
|
||||
seniority: "senior",
|
||||
category: null,
|
||||
location: "San Francisco",
|
||||
remote: true,
|
||||
salary_min: 160000,
|
||||
salary_max: 240000,
|
||||
status: "published",
|
||||
tags: null,
|
||||
posted_at: "2026-09-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("listPostings: single page fetch-all carries total_published (G-103)", async () => {
|
||||
const calls: string[] = [];
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
calls.push(String(input));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
postings: [wirePosting()],
|
||||
total_published: 1,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { listPostings } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const result = await listPostings();
|
||||
assert.equal(result.postings.length, 1);
|
||||
assert.equal(result.totalPublished, 1);
|
||||
assert.equal(result.truncated, false, "exhausted set must not be truncated");
|
||||
assert.ok(
|
||||
calls[0].endsWith("/v1/marketplace/postings?limit=100&offset=0"),
|
||||
`unexpected first page URL: ${calls[0]}`,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("listPostings: walks limit/offset pages until exhausted (G-103)", async () => {
|
||||
const calls: string[] = [];
|
||||
const realFetch = globalThis.fetch;
|
||||
// Page 1: 100 rows (full page → another page may exist); page 2: 1 row
|
||||
// + total 101. The walk must stop after page 2 (postings >= total).
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
calls.push(String(input));
|
||||
const offset = Number(String(input).split("offset=")[1]);
|
||||
if (offset === 0) {
|
||||
const rows = Array.from({ length: 100 }, (_, i) =>
|
||||
wirePosting({ id: `job-${i}`, external_id: `job-${i}` }),
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({ postings: rows, total_published: 101 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
postings: [wirePosting({ id: "job-100", external_id: "job-100" })],
|
||||
total_published: 101,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { listPostings } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const result = await listPostings();
|
||||
assert.equal(calls.length, 2, "exactly two page fetches");
|
||||
assert.ok(calls[0].endsWith("offset=0"));
|
||||
assert.ok(calls[1].endsWith("offset=100"));
|
||||
assert.equal(result.postings.length, 101);
|
||||
assert.equal(result.totalPublished, 101);
|
||||
assert.equal(result.truncated, false);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("listPostings: 50-page bound stops the walk with truncated=true (G-103)", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
calls += 1;
|
||||
const rows = Array.from({ length: 100 }, (_, i) =>
|
||||
wirePosting({ id: `job-${calls}-${i}`, external_id: `x-${calls}-${i}` }),
|
||||
);
|
||||
// total stays above everything fetched → the walk only stops at the bound
|
||||
return new Response(
|
||||
JSON.stringify({ postings: rows, total_published: 1_000_000 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { listPostings, POSTINGS_MAX_PAGES } = await import(`../lib/engine-client.ts${QS}`);
|
||||
assert.equal(POSTINGS_MAX_PAGES, 50, "documented page bound (≤10⁴ corpus)");
|
||||
const result = await listPostings();
|
||||
assert.equal(calls, 50, "the walk stops at the 50-page bound");
|
||||
assert.equal(result.postings.length, 5000);
|
||||
assert.equal(result.truncated, true, "honest truncation flag over the fetched set");
|
||||
assert.ok(result.totalPublished > result.postings.length);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getPosting: right URL + typed NotFoundError on 404 (honest absence)", async () => {
|
||||
const { getPosting, NotFoundError, EngineError } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
calls.push(String(input));
|
||||
return new Response(JSON.stringify({ detail: "no posting with id 'nope'" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => getPosting("nope"),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof NotFoundError, "404 must surface as NotFoundError");
|
||||
assert.ok(err instanceof EngineError, "NotFoundError extends EngineError");
|
||||
assert.equal((err as InstanceType<typeof EngineError>).status, 404);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.ok(calls[0].endsWith("/v1/marketplace/postings/nope"));
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getEmployer: right URL + wire shape + typed 404", async () => {
|
||||
const { getEmployer, NotFoundError } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
calls.push(String(input));
|
||||
if (String(input).endsWith("/v1/marketplace/employers/emp-1")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "emp-1",
|
||||
source: "seed",
|
||||
external_id: "emp-1",
|
||||
name: "OpenAI Labs",
|
||||
logo: "",
|
||||
description: "Research-driven.",
|
||||
industry: "AI Research",
|
||||
size: "500-1000",
|
||||
location: "San Francisco",
|
||||
website: "https://example.com",
|
||||
social_links: { twitter: "https://example.com/x" },
|
||||
culture: "Research-driven, shipping-paced.",
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ detail: "no employer with id 'nope'" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const e = await getEmployer("emp-1");
|
||||
// base.py Employer field-for-field parity (dual-schema sync)
|
||||
assert.equal(e.id, "emp-1");
|
||||
assert.equal(e.name, "OpenAI Labs");
|
||||
assert.equal(e.social_links.twitter, "https://example.com/x");
|
||||
assert.equal(e.culture, "Research-driven, shipping-paced.");
|
||||
await assert.rejects(
|
||||
() => getEmployer("nope"),
|
||||
(err: unknown) => err instanceof NotFoundError,
|
||||
);
|
||||
assert.ok(calls[0].endsWith("/v1/marketplace/employers/emp-1"));
|
||||
assert.ok(calls[1].endsWith("/v1/marketplace/employers/nope"));
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("applyPosting: posts {learner_id, posting_id} to /v1/marketplace/apply", async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
calls.push({ url: String(input), init });
|
||||
return new Response(
|
||||
JSON.stringify({ application_id: "app-1", posting_id: "job-001", status: "submitted" }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { applyPosting, MOCK_LEARNER_ID } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const c = await applyPosting("job-001");
|
||||
assert.deepEqual(c, { application_id: "app-1", posting_id: "job-001", status: "submitted" });
|
||||
assert.ok(calls[0].url.endsWith("/v1/marketplace/apply"));
|
||||
assert.equal(calls[0].init?.method, "POST");
|
||||
assert.deepEqual(JSON.parse(String(calls[0].init?.body)), {
|
||||
learner_id: MOCK_LEARNER_ID,
|
||||
posting_id: "job-001",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("applyPosting: verify-CTA 403 → VerifyRequiredError (the G-10 pattern reused)", async () => {
|
||||
const { applyPosting, VerifyRequiredError } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
detail: {
|
||||
reason: "age_gate_18_plus",
|
||||
min_age: 18,
|
||||
current_status: "none",
|
||||
verify_cta: "/enroll",
|
||||
},
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
)) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => applyPosting("job-001"),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof VerifyRequiredError);
|
||||
const v = err as InstanceType<typeof VerifyRequiredError>;
|
||||
assert.equal(v.minAge, 18);
|
||||
assert.equal(v.verifyCta, "/enroll");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("applyPosting: 409 closed posting surfaces the EngineError (honest closed state)", async () => {
|
||||
const { applyPosting, EngineError } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({ detail: { reason: "posting_not_published", status: "closed" } }),
|
||||
{ status: 409, headers: { "Content-Type": "application/json" } },
|
||||
)) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => applyPosting("job-001"),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof EngineError);
|
||||
assert.equal((err as InstanceType<typeof EngineError>).status, 409);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
// -- shell behavior pins (source-level: 'use client' shells use
|
||||
// window/navigation node:test cannot mount) ----------------------------------
|
||||
|
||||
test("board shell: empty board renders an honest empty state (never fake cards)", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const src = fs.readFileSync("components/marketplace/board-client.tsx", "utf8");
|
||||
// Honest empty state: zero postings → a message, not fabricated cards.
|
||||
assert.match(src, /No postings are published yet/);
|
||||
// The empty state fires ONLY on a fetched empty set (not on error).
|
||||
assert.match(src, /postings\.length === 0 && !error/);
|
||||
// Fetch-backed: listPostings drives the board (D-055).
|
||||
assert.match(src, /await listPostings/);
|
||||
});
|
||||
|
||||
test("board shell: truncation notice renders over the fetched set (G-103)", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const src = fs.readFileSync("components/marketplace/board-client.tsx", "utf8");
|
||||
assert.match(src, /truncated &&/, "truncation flag gates the notice");
|
||||
assert.match(src, /Showing the first \{postings\.length\} of \{totalPublished\}/);
|
||||
});
|
||||
|
||||
test("job detail shell: notFound on 404 + honest apply states (403 CTA / 409 closed / confirmation)", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const src = fs.readFileSync("components/marketplace/job-detail-client.tsx", "utf8");
|
||||
// 404 → the honest not-found signal (never a fabricated job).
|
||||
assert.match(src, /status === 404[\s\S]{0,200}setMissing\(true\)/);
|
||||
// Apply states: verify-CTA prompt (learner build/defend pattern), 409
|
||||
// closed message, success confirmation payload.
|
||||
assert.match(src, /VerifyRequiredError/);
|
||||
assert.match(src, /verifyCta/, "verify-CTA link renders on 403");
|
||||
assert.match(src, /posting is closed/);
|
||||
assert.match(src, /Application submitted/);
|
||||
assert.match(src, /confirmation\.application_id/, "honest confirmation payload renders");
|
||||
});
|
||||
|
||||
test("employer shell: fetch-backed + notFound on 404 + raw ingested fields", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const src = fs.readFileSync(
|
||||
"components/marketplace/employer-profile-client.tsx",
|
||||
"utf8",
|
||||
);
|
||||
assert.match(src, /await getEmployer/);
|
||||
assert.match(src, /status === 404[\s\S]{0,400}setMissing\(true\)/);
|
||||
// Raw ingested fields render (P4 adds {value, source} provenance).
|
||||
assert.match(src, /employer\.culture/);
|
||||
assert.match(src, /social_links\.twitter/);
|
||||
});
|
||||
|
||||
test("E87 regression class (G-101): generateStaticParams STAYS with ONE placeholder param", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const jobs = fs.readFileSync(
|
||||
"app/(marketplace)/marketplace/jobs/[jobId]/page.tsx",
|
||||
"utf8",
|
||||
);
|
||||
const employers = fs.readFileSync(
|
||||
"app/(marketplace)/marketplace/employers/[employerId]/page.tsx",
|
||||
"utf8",
|
||||
);
|
||||
for (const [name, src] of [
|
||||
["jobs/[jobId]", jobs],
|
||||
["employers/[employerId]", employers],
|
||||
] as const) {
|
||||
assert.match(src, /export function generateStaticParams/, `${name}: must keep generateStaticParams`);
|
||||
assert.match(
|
||||
src,
|
||||
/\[{ jobId: '_' }\]|\[{ employerId: '_' }\]/,
|
||||
`${name}: exactly one placeholder param`,
|
||||
);
|
||||
// The page must NOT be a client component (Next forbids 'use client' +
|
||||
// generateStaticParams in one file — the shell is a separate client file).
|
||||
assert.ok(!src.includes("'use client'"), `${name}: server shell (client lives in the component)`);
|
||||
}
|
||||
});
|
||||
|
||||
test("G-106: no matchScore on marketplace-path surfaces (honest absent score)", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const surfaces = [
|
||||
"components/marketplace/board-client.tsx",
|
||||
"components/marketplace/job-detail-client.tsx",
|
||||
"components/marketplace/employer-profile-client.tsx",
|
||||
"components/marketplace/job-filters.tsx",
|
||||
];
|
||||
for (const f of surfaces) {
|
||||
const src = fs.readFileSync(f, "utf8");
|
||||
assert.ok(!/matchScore/.test(src), `${f} must not reference matchScore`);
|
||||
}
|
||||
const typesSrc = fs.readFileSync("../../packages/types/marketplace.ts", "utf8");
|
||||
// Only the WIRE shapes (dual-schema-sync block) are pinned — the old
|
||||
// mock Job/Candidate types stay intact for the dormant employer/admin
|
||||
// mockups (G-107 scope: mock-data excluded). Comments may NAME the
|
||||
// constraint; no wire FIELD may carry it.
|
||||
const wireBlock = typesSrc.slice(typesSrc.indexOf("export type PostingStatus"));
|
||||
const wireFields = wireBlock.split("\n").filter((l) => /^\s*\w+\??:/.test(l));
|
||||
const scoreFields = wireFields.filter((l) => /matchScore/i.test(l));
|
||||
assert.equal(scoreFields.length, 0, `wire shapes must not carry matchScore fields: ${scoreFields}`);
|
||||
});
|
||||
|
||||
test("dual-schema sync: PostingSummary mirrors marketplace/base.py field-for-field", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const typesSrc = fs.readFileSync("../../packages/types/marketplace.ts", "utf8");
|
||||
// posting_summary parity (G-107): the exact base.py Posting fields.
|
||||
const fields = [
|
||||
"id:", "source:", "external_id:", "employer_id:", "title:",
|
||||
"description:", "required_competencies:", "skills:", "seniority:",
|
||||
"category:", "location:", "remote:", "salary_min:", "salary_max:",
|
||||
"status:", "tags:", "posted_at:",
|
||||
];
|
||||
const block = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface PostingSummary"),
|
||||
typesSrc.indexOf("export interface PostingDetail"),
|
||||
);
|
||||
for (const f of fields) {
|
||||
assert.ok(block.includes(f), `PostingSummary missing base.py field ${f}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("G-101 shells extract the runtime id from the browser path (trailingSlash-safe)", async () => {
|
||||
const { segmentFromPath } = await import("../lib/route-id.ts");
|
||||
// trailingSlash: true — the real id is the last non-empty segment.
|
||||
assert.equal(segmentFromPath("/marketplace/jobs/job-001/"), "job-001");
|
||||
assert.equal(segmentFromPath("/marketplace/jobs/job-001"), "job-001");
|
||||
assert.equal(segmentFromPath("/marketplace/employers/emp-openai/"), "emp-openai");
|
||||
// URI-encoded ids (e.g. sources with slashes/specials) decode safely;
|
||||
// malformed sequences fall back to the raw segment (never a crash).
|
||||
assert.equal(segmentFromPath("/marketplace/jobs/job%2F001/"), "job/001");
|
||||
assert.equal(segmentFromPath("/marketplace/jobs/job%2/"), "job%2");
|
||||
// No segments at all → empty id (the shell fetch 404s honestly).
|
||||
assert.equal(segmentFromPath("/"), "");
|
||||
assert.equal(segmentFromPath(""), "");
|
||||
});
|
||||
test("P2 facets: engine-client surfaces facets; JobFilters derives options from them (MH-2c)", async () => {
|
||||
const src = readFileSync("lib/engine-client.ts", "utf8");
|
||||
// The fetch-all carries the facets payload (dual-schema: PostingFacets).
|
||||
assert.ok(/facets\?: PostingFacets/.test(src), "PostingsFetchAll must carry facets");
|
||||
assert.ok(/facets = body\.facets/.test(src), "listPostings must propagate facets");
|
||||
const filters = readFileSync("components/marketplace/job-filters.tsx", "utf8");
|
||||
// Skill options derive from the facets payload (counts render honestly),
|
||||
// with the fallback only when facets are absent — no hardcoded-only list.
|
||||
assert.ok(/facets\?\.skills/.test(filters), "JobFilters must derive options from facets");
|
||||
assert.ok(/\.slice\(0, 8\)\.map\(\(f\) => f\.value\)/.test(filters), "top-8 facet values drive options");
|
||||
assert.ok(/FALLBACK_SKILLS/.test(filters), "honest fallback when facets absent");
|
||||
const board = readFileSync("components/marketplace/board-client.tsx", "utf8");
|
||||
assert.ok(/facets=\{facets\}/.test(board), "BoardClient must pass facets to JobFilters");
|
||||
// Wire types: PostingPage carries facets (dual-schema with base.py PostingListResponse).
|
||||
const wire = readFileSync("../../packages/types/marketplace.ts", "utf8");
|
||||
assert.ok(/export interface PostingFacets/.test(wire), "PostingFacets type exists");
|
||||
assert.ok(/facets: PostingFacets;/.test(wire), "PostingPage carries facets");
|
||||
});
|
||||
|
||||
test("P4: provenance badges + resume gate pins (MH-4b/4e)", async () => {
|
||||
const employer = readFileSync("components/marketplace/employer-profile-client.tsx", "utf8");
|
||||
// A-304: LLM copy visibly marked — the badge labels the source.
|
||||
assert.ok(/AI-generated summary/.test(employer), "llm badge label present");
|
||||
assert.ok(/Ingested data/.test(employer), "raw badge label present");
|
||||
assert.ok(/Founder-edited/.test(employer), "manual badge label present");
|
||||
assert.ok(/merged\?\.description/.test(employer), "merged view consumed");
|
||||
// Wire types carry the merged section (dual-schema with ProvenanceField).
|
||||
const wire = readFileSync("../../packages/types/marketplace.ts", "utf8");
|
||||
assert.ok(/export interface ProvenanceField/.test(wire), "ProvenanceField type");
|
||||
assert.ok(/merged\?: Record<string, ProvenanceField>/.test(wire), "EmployerProfile.merged");
|
||||
const panel = readFileSync("components/learner/resume-upload-panel.tsx", "utf8");
|
||||
// The REQ-6-005 review gate: nothing saves before the confirm click.
|
||||
assert.ok(/Nothing is stored until you confirm/.test(panel), "confirm-gate copy");
|
||||
assert.ok(/Draft — review before saving/.test(panel), "draft review state");
|
||||
assert.ok(/Discard/.test(panel), "discard path");
|
||||
// engine-client: upload (multipart) + confirm (JSON) functions.
|
||||
const client = readFileSync("lib/engine-client.ts", "utf8");
|
||||
assert.ok(/export async function uploadResume/.test(client), "uploadResume");
|
||||
assert.ok(/export async function confirmResume/.test(client), "confirmResume");
|
||||
assert.ok(/\/v1\/marketplace\/resume/.test(client), "resume route path");
|
||||
});
|
||||
@@ -3,6 +3,14 @@
|
||||
*
|
||||
* AI-era job board + talent marketplace. Jobs, employers, candidates,
|
||||
* postings, talent matches, and search filters.
|
||||
*
|
||||
* The `Posting*` / `EmployerProfile` / `ApplicationConfirmation` wire
|
||||
* shapes below are the dual-schema-sync mirror of
|
||||
* `apps/ai-service/ai_service/marketplace/base.py` (Task 1-3-01):
|
||||
* field-for-field parity, snake_case as emitted by the API — every
|
||||
* change happens in both places. `matchScore` is deliberately absent
|
||||
* (G-106/G-107): scores are computed search-time context (P3), never
|
||||
* list/detail payload fields.
|
||||
*/
|
||||
|
||||
export type Seniority = 'entry' | 'mid' | 'senior' | 'staff' | 'principal';
|
||||
@@ -73,4 +81,129 @@ export interface SearchFilter {
|
||||
remote: boolean | null;
|
||||
salaryMin: number | null;
|
||||
salaryMax: number | null;
|
||||
}
|
||||
}
|
||||
|
||||
// -- wire shapes (dual-schema sync with marketplace/base.py — 1-3-01) -----------
|
||||
|
||||
export type PostingStatus = 'draft' | 'published' | 'closed';
|
||||
|
||||
/** Derived tags on a posting row (base.py PostingTags; skills ≤10). */
|
||||
export interface PostingTags {
|
||||
skills: string[];
|
||||
seniority: Seniority | null;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
/** One posting over HTTP — base.py `posting_summary` parity (G-107:
|
||||
* `matchScore` deliberately absent). */
|
||||
export interface PostingSummary {
|
||||
id: string;
|
||||
source: string;
|
||||
external_id: string;
|
||||
employer_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
required_competencies: string[];
|
||||
skills: string[];
|
||||
seniority: Seniority | null;
|
||||
category: string | null;
|
||||
location: string;
|
||||
remote: boolean;
|
||||
salary_min: number | null;
|
||||
salary_max: number | null;
|
||||
status: PostingStatus;
|
||||
tags: PostingTags | null;
|
||||
posted_at: string | null;
|
||||
}
|
||||
|
||||
/** Full posting detail — same shape as the summary (one serialization
|
||||
* helper covers list + detail on the server; mirrored here). */
|
||||
export interface PostingDetail extends PostingSummary {}
|
||||
|
||||
/** Raw ingested employer fields (base.py Employer / api EmployerResponse).
|
||||
* P4 (D-053) merges enrichment on read with per-field
|
||||
* `{value, source}` provenance markers. */
|
||||
/** P4-A (D-053/A-304): one merged employer field with honest
|
||||
* provenance — raw | llm | manual (+ the model that wrote llm copy).
|
||||
* Dual-schema with api/marketplace.py ProvenanceField. */
|
||||
export interface ProvenanceField {
|
||||
value: unknown;
|
||||
source: 'raw' | 'llm' | 'manual';
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface EmployerProfile {
|
||||
id: string;
|
||||
source: string;
|
||||
external_id: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
description: string;
|
||||
industry: string;
|
||||
size: string;
|
||||
location: string;
|
||||
website: string;
|
||||
social_links: Record<string, string>;
|
||||
culture: string;
|
||||
/** P4-A merged view — per-field provenance-marked (manual > llm >
|
||||
* raw on read). Absent when the employer has no enrichment rows. */
|
||||
merged?: Record<string, ProvenanceField> | null;
|
||||
}
|
||||
|
||||
/** Honest apply confirmation (base.py Application / api ApplyResponse):
|
||||
* exactly the application state, no fabricated outcome. */
|
||||
export interface ApplicationConfirmation {
|
||||
application_id: string;
|
||||
posting_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Published-only list response (G-103): the page + the honest
|
||||
* total_published so the board's paged fetch-all can render
|
||||
* "Showing X of Y" without fabrication. P2 (MH-2c): facets — value
|
||||
* counts over stored tags of published-only postings (dual-schema sync
|
||||
* with api/marketplace.py PostingListResponse.facets). */
|
||||
export interface FacetCount {
|
||||
value: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PostingFacets {
|
||||
skills: FacetCount[];
|
||||
seniority: FacetCount[];
|
||||
category: FacetCount[];
|
||||
}
|
||||
|
||||
export interface PostingPage {
|
||||
postings: PostingSummary[];
|
||||
total_published: number;
|
||||
facets: PostingFacets;
|
||||
}
|
||||
|
||||
// -- P3 semantic search wire shapes (dual-schema sync with
|
||||
// api/marketplace.py SkillBreakdownEntry/SearchEntry/SearchResponse) ----
|
||||
|
||||
/** One skill's deterministic search explanation (py SkillBreakdownEntry):
|
||||
* tag-overlap with the query (0/1) + the skill's vector contribution share. */
|
||||
export interface SkillContribution {
|
||||
skill: string;
|
||||
overlap: number;
|
||||
contribution: number;
|
||||
}
|
||||
|
||||
/** One ranked search result (py SearchEntry). G-106: `match_score` /
|
||||
* `skill_breakdown` exist ONLY in this search context — the list/detail
|
||||
* wire shapes never carry them (no query → null score, honest absence,
|
||||
* never a fabricated number). */
|
||||
export interface SearchResult {
|
||||
posting: PostingSummary;
|
||||
match_score: number | null;
|
||||
skill_breakdown: SkillContribution[];
|
||||
}
|
||||
|
||||
/** GET /v1/marketplace/search response (py SearchResponse). */
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[];
|
||||
query: string;
|
||||
ranked: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user