/* RFC-0148: pure projectors that map canonical business-schema data into the semantic model, plus the projection visibility boundary. Framework-agnostic and side-effect-free, so the disk llms path and the Astro JSON-LD path project every business file identically. Lives in @warpgogol/werkstatt-shared/semantic (not @warpgogol/werkstatt-site/pbp) because @warpgogol/werkstatt-site/pbp depends on @warpgogol/site-kernel-content — placing the projectors here keeps the node disk loader cycle-free. Do not read files — pure mapping over already-loaded data. Do not project privacy-sensitive domains (external-services, compliance). RFC-0148: initial projection registry — offer + location projectors and the visibility boundary. RFC-0287: added projectWeb for the Agent Surface knowledge tier. RFC-0373: added projectServices for business service catalog projection. RFC-1076: add projectClaims projector and SemanticClaimProvenance / SemanticEvidenceRef types. */ import type { SemanticOffer, SemanticLocation, SemanticPerson, SemanticService, SemanticWeb, } from "./models.ts"; export interface SemanticClaimProvenance { /** Stable claim ID from PbpClaim.id. */ id: string; /** Claim class: factual, risk, benefit, etc. */ claimClass: string; /** Claim kind: risk, benefit, comparison, fact, limitation, recommendation. */ claimKind: string; /** The claim statement text. */ statement: string; /** ISO 639-1 language tag of the statement (RFC-0706). */ statementLang?: string; /** Verification level N0–N3 (ADR-0028). */ verificationLevel?: string; /** Confidence: high, medium, low. */ confidence?: string; /** Evidence references — resolved from PbpClaim.evidenceRefs. */ evidence: SemanticEvidenceRef[]; } export interface SemanticEvidenceRef { /** EvidenceSource entity ID. */ id: string; /** Evidence kind: verified-record, third-party-registry, certificate, etc. */ kind: string; /** Human-readable label for the evidence source. */ label: string; /** SHA-256 integrity hash of the canonical evidence artifact, if available. */ sha256?: string; /** Canonical URI of the evidence source (RFC-1075), if available. */ canonicalUri?: string; } /** * RFC-0148 privacy boundary. `public` projects to AI (llms) + JSON-LD; `pageMeta` * is build-time only (e.g. legal-page dates → sitemap lastmod); `none` is never * projected to any public output. `externalServices` (vendor names/addresses) * and `compliance` (internal GoBD dates) are deliberately `none`. */ export const BUSINESS_DOMAIN_VISIBILITY = { company: "public", legal: "public", contact: "public", offer: "public", service: "public", location: "public", web: "public", people: "public", trust: "public", faq: "public", meta: "pageMeta", compliance: "none", externalServices: "none", } as const; export type BusinessDomain = keyof typeof BUSINESS_DOMAIN_VISIBILITY; export type BusinessVisibility = (typeof BUSINESS_DOMAIN_VISIBILITY)[BusinessDomain]; const PRICE_LABELS: ReadonlyArray = [ ["monthly", "Monthly"], ["yearly", "Yearly"], ["setup", "Setup"], ]; /** RFC-0147/RFC-0148: project the canonical offer (price + guarantees + growth modules + terms). */ export function projectOffer(data: Record | undefined): SemanticOffer | undefined { if (!data) return undefined; const price = (data["price"] ?? {}) as Record; const currency = typeof data["currency"] === "string" ? (data["currency"] as string) : undefined; const prices = PRICE_LABELS.filter( ([key]) => typeof price[key] === "string" && (price[key] as string).length > 0, ).map(([key, label]) => ({ id: key, label, amount: price[key] as string, ...(currency ? { currency } : {}), })); const rawGuarantees = (data["guarantees"] ?? {}) as Record< string, { label?: string; detail?: string } | undefined >; const guarantees = Object.entries(rawGuarantees) .filter(([, g]) => g && typeof g.label === "string" && g.label.length > 0) .map(([id, g]) => ({ id, label: g!.label as string, ...(g!.detail ? { detail: g!.detail } : {}), })); const rawGrowthModules = (data["growthModules"] ?? {}) as Record< string, { label?: string; description?: string; price?: string } | undefined >; const growthModules = Object.entries(rawGrowthModules) .filter(([, m]) => m && typeof m.label === "string" && m.label.length > 0) .map(([id, m]) => ({ id, label: m!.label as string, ...(m!.description ? { description: m!.description } : {}), ...(m!.price ? { price: m!.price } : {}), })); const changePrice = typeof data["changePrice"] === "string" ? data["changePrice"] : undefined; const hourlyRate = typeof data["hourlyRate"] === "string" ? data["hourlyRate"] : undefined; const billingDay = typeof data["billingDay"] === "string" ? data["billingDay"] : undefined; const rawCapacity = data["capacity"] as Record | undefined; const rawSlotRange = rawCapacity?.["slotRange"] as Record | undefined; const capacity = rawCapacity && rawCapacity["enabled"] === true && typeof rawCapacity["timezone"] === "string" && typeof rawCapacity["startsAt"] === "string" && (rawCapacity["cadence"] === "monthly" || rawCapacity["cadence"] === "fixed-days") && typeof rawSlotRange?.["min"] === "number" && typeof rawSlotRange?.["max"] === "number" && typeof rawCapacity["maxSlotsPerWave"] === "number" ? { enabled: true, timezone: rawCapacity["timezone"], startsAt: rawCapacity["startsAt"], cadence: rawCapacity["cadence"] as "monthly" | "fixed-days", ...(typeof rawCapacity["cadenceDays"] === "number" ? { cadenceDays: rawCapacity["cadenceDays"] } : {}), slotRange: { min: rawSlotRange["min"], max: rawSlotRange["max"] }, maxSlotsPerWave: rawCapacity["maxSlotsPerWave"], availabilityStatus: "unknown" as const, } : undefined; if ( prices.length === 0 && guarantees.length === 0 && growthModules.length === 0 && !capacity && !changePrice && !hourlyRate && !billingDay ) return undefined; return { ...(prices.length ? { prices } : {}), ...(guarantees.length ? { guarantees } : {}), ...(growthModules.length ? { growthModules } : {}), ...(capacity ? { capacity } : {}), ...(changePrice ? { changePrice } : {}), ...(hourlyRate ? { hourlyRate } : {}), ...(billingDay ? { billingDay } : {}), }; } /** RFC-0200: project canonical Person records into SemanticPerson[]. Bio becomes * the person description; `lifespan.died` drives `isDeceased` + `deathDate`; * `affiliations`/`sameAs` are carried for governance derivation + entity grounding. * Records without a name are dropped. Order preserved (loaders pre-sort by `order`). */ export function projectPeople( records: ReadonlyArray> | undefined, ): SemanticPerson[] { if (!records?.length) return []; const people: SemanticPerson[] = []; for (const r of records) { const name = typeof r["name"] === "string" ? (r["name"] as string).trim() : ""; if (!name) continue; const bio = typeof r["bio"] === "string" ? (r["bio"] as string).trim() : ""; const role = typeof r["role"] === "string" ? (r["role"] as string).trim() : ""; const affiliations = Array.isArray(r["affiliations"]) ? (r["affiliations"] as unknown[]).filter((a): a is string => typeof a === "string") : []; const sameAs = Array.isArray(r["sameAs"]) ? (r["sameAs"] as unknown[]).filter((s): s is string => typeof s === "string") : []; const lifespan = (r["lifespan"] ?? undefined) as Record | undefined; const born = lifespan?.["born"]; const died = lifespan?.["died"]; /* RFC-0512: map location string ("City, Region") → SemanticPerson.address. */ const locationRaw = typeof r["location"] === "string" ? (r["location"] as string).trim() : ""; let address: SemanticPerson["address"] | undefined; if (locationRaw) { const parts = locationRaw.split(",").map((s) => s.trim()); address = { addressLocality: parts[0] ?? "", ...(parts[1] ? { addressRegion: parts[1] } : {}), addressCountry: "DE", }; } /* RFC-0512: map capabilities → SemanticPerson.knowsAbout. */ const capabilities = Array.isArray(r["capabilities"]) ? (r["capabilities"] as unknown[]).filter((c): c is string => typeof c === "string") : []; /* RFC-0512: map organization name → SemanticPerson.affiliation. */ const orgName = typeof r["organizationName"] === "string" ? (r["organizationName"] as string).trim() : ""; people.push({ name, ...(role ? { role } : {}), ...(bio ? { description: bio } : {}), ...(affiliations.length ? { affiliations } : {}), ...(born != null ? { birthDate: String(born) } : {}), ...(died != null ? { deathDate: String(died), isDeceased: true } : {}), ...(sameAs.length ? { sameAs } : {}), ...(address ? { address } : {}), ...(capabilities.length ? { knowsAbout: capabilities } : {}), ...(orgName ? { affiliation: { name: orgName } } : {}), }); } return people; } /** RFC-0148: project location (NAP locality/region/country + service area). */ export function projectLocation( data: Record | undefined, ): SemanticLocation | undefined { if (!data) return undefined; const place = (key: string): string | undefined => { const node = data[key] as { name?: unknown } | undefined; return typeof node?.name === "string" && node.name.length > 0 ? node.name : undefined; }; const locality = place("city"); const region = place("region"); const country = place("country"); const serviceAreaRaw = data["serviceArea"]; const serviceArea = Array.isArray(serviceAreaRaw) ? serviceAreaRaw.filter((s): s is string => typeof s === "string" && s.length > 0) : undefined; if (!locality && !region && !country && !(serviceArea && serviceArea.length)) return undefined; return { ...(locality ? { locality } : {}), ...(region ? { region } : {}), ...(country ? { country } : {}), ...(serviceArea && serviceArea.length ? { serviceArea } : {}), }; } /** RFC-0373: project business service catalog entries into SemanticService[]. * Records without a name are dropped. Order preserved (loaders pre-sort by `order`). */ export function projectServices( records: ReadonlyArray> | undefined, ): SemanticService[] { if (!records?.length) return []; const services: SemanticService[] = []; for (const r of records) { const name = typeof r["name"] === "string" ? (r["name"] as string).trim() : ""; if (!name) continue; const slug = typeof r["slug"] === "string" ? (r["slug"] as string) : ""; const description = typeof r["description"] === "string" ? (r["description"] as string).trim() : ""; services.push({ id: slug || name, name, ...(description ? { description } : {}), }); } return services; } /** * RFC-1076: project PBP claims with their evidence references into the semantic model. * Only claims with status "published" are projected. The sha256 is read from the * canonical item (items[key] where canonical === true), falling back to the first * item. Localization is handled by the semantic loader (per-language loading with * default-language fallback), not by this projector. */ export function projectClaims( claims: ReadonlyArray> | undefined, evidenceSources: Record> | undefined, ): SemanticClaimProvenance[] { if (!claims?.length) return []; const result: SemanticClaimProvenance[] = []; for (const claim of claims) { const id = typeof claim["id"] === "string" ? claim["id"] : ""; if (!id) continue; const status = typeof claim["status"] === "string" ? claim["status"] : ""; if (status !== "published") continue; const evidenceRefs = (claim["evidenceRefs"] ?? {}) as Record; const evidence: SemanticEvidenceRef[] = []; for (const ref of Object.values(evidenceRefs)) { const sourceId = ref?.ref; if (!sourceId) continue; const source = evidenceSources?.[sourceId]; if (!source) continue; const items = (source["items"] ?? {}) as Record< string, { sha256?: string; canonical?: boolean } >; const itemValues = Object.values(items); const canonicalItem = itemValues.find((it) => it?.canonical === true) ?? itemValues[0]; evidence.push({ id: sourceId, kind: typeof source["kind"] === "string" ? source["kind"] : "", label: typeof source["name"] === "string" ? source["name"] : sourceId, ...(typeof canonicalItem?.["sha256"] === "string" ? { sha256: canonicalItem["sha256"] } : {}), ...(typeof source["canonicalUri"] === "string" ? { canonicalUri: source["canonicalUri"] } : {}), }); } result.push({ id, claimClass: typeof claim["claimClass"] === "string" ? claim["claimClass"] : "", claimKind: typeof claim["claimKind"] === "string" ? claim["claimKind"] : "", statement: typeof claim["statement"] === "string" ? claim["statement"] : "", ...(typeof claim["statementLang"] === "string" ? { statementLang: claim["statementLang"] } : {}), ...(typeof claim["verificationLevel"] === "string" ? { verificationLevel: claim["verificationLevel"] } : {}), ...(typeof claim["confidence"] === "string" ? { confidence: claim["confidence"] } : {}), evidence, }); } return result; } /** * RFC-0287: project the business web/domain identity record (business/{lang}/web.md * — primaryUrl, statusUrl, domains.{primary,german}). A thin, faithful passthrough of * an already-validated, stable cross-app shape — not a generic business-file dump. */ export function projectWeb(data: Record | undefined): SemanticWeb | undefined { if (!data) return undefined; const primaryUrl = typeof data["primaryUrl"] === "string" ? data["primaryUrl"] : undefined; const statusUrl = typeof data["statusUrl"] === "string" ? data["statusUrl"] : undefined; const domainsRaw = (data["domains"] ?? {}) as Record; const primary = typeof domainsRaw["primary"] === "string" ? domainsRaw["primary"] : undefined; const german = typeof domainsRaw["german"] === "string" ? domainsRaw["german"] : undefined; if (!primaryUrl && !statusUrl && !primary && !german) return undefined; return { ...(primaryUrl ? { primaryUrl } : {}), ...(statusUrl ? { statusUrl } : {}), ...(primary || german ? { domains: { ...(primary ? { primary } : {}), ...(german ? { german } : {}) } } : {}), }; }