Skip to main content
Jira progress: loading…

AI Template Generator

1. What we want the AI to do

For each customer we want to be able to:

  1. Recommend which policy templates they should start from
  • Based on: NACE, size, jurisdictions, risk profile, ESRS scope, etc.
  1. Pre-fill obvious parts
  • Company name, addresses, group structure, locations, facilities, languages…
  1. Adapt the text to the company’s profile
  • Adjust ambition, scope, governance, references to frameworks, etc.
  1. Keep everything explainable & reproducible
  • So you can see why something was recommended and what data went in.

You already have almost all the building blocks:

  • template-registry.json – canonical template metadata
  • framework-tags.json & framework-groups.json – regulatory & framework context
  • MDX frontmatter with aiProfile, domain, category, placeholders, frameworkTags

Now we just need to connect this to an AI generation flow.

2. Core data structures

2.1 Company profile schema

I’d formalise something like:

{
"companyId": "acme-123",
"legalName": "ACME Manufacturing Group AG",
"brandName": "ACME",
"size": {
"employees": 4200,
"turnoverEUR": 780000000,
"csrdScope": "large_undertaking" // small, large, listed, non-listed, group, etc.
},
"jurisdictions": ["EU", "NO", "US"],
"naceCodes": ["C25.11", "C25.29"],
"sectors": ["metals", "industrial_components"],
"esrsApplicability": {
"E1": "high",
"E2": "medium",
"E3": "low",
"E4": "high",
"E5": "medium",
"S1": "high",
"S2": "medium",
"S3": "medium",
"S4": "low",
"G1": "high"
},
"riskSignals": {
"climatePhysical": "high",
"climateTransition": "medium",
"biodiversity": "high",
"waterStress": "medium",
"supplyChainHumanRights": "high"
},
"languages": ["en", "no"],
"tonePreferences": {
"formality": "high",
"length": "medium",
"audience": "internal_external" // internal_only, external_only
}
}

This can sit in ZAYAZ SIS / company master data and be passed into the AI pipeline as JSON.

2.2 Policy generation spec

Define a spec that the frontend / API passes to the AI “engine”:

{
"companyId": "acme-123",
"templateId": "tpl_policy_env_climate_change_v1",
"language": "en",
"targetAudience": "internal_external",
"policyPurpose": "core_policy", // or guideline, procedure, supplier_addendum
"strictnessLevel": "medium_high", // low/medium/high ambition
"includeFrameworks": ["CSRD", "ESRS_E1", "TCFD", "SBTI"],
"excludeFrameworks": ["CCPA"],
"jurisdictionOverride": ["NO", "EU"],
"maxWords": 2000
}

The engine will:

  1. Load the template metadata + MDX body
  2. Load the companyProfile
  3. Run any ZAYAZ-side logic (selection, defaults)
  4. Call the LLM with a strong system prompt + structured instructions.

3. Engine flow: step-by-step

3.1 Template selection / recommendation

This is pre-LLM logic and should be deterministic:

  1. Filter template-registry.json by:
  • domain (env / social / gov / cross)
  • frameworkTags intersecting with company’s ESRS priorities / CSRD scope
  • jurisdictionHints vs company.jurisdictions
  • naceHints vs company.naceCodes (when you start using that)
  1. Rank by:
  • Matching ESRS (via framework-groups)
  • Matching riskSignals
  • Company size / sector

Output: a list like:

[
{
"templateId": "tpl_policy_env_climate_change_v1",
"score": 0.94,
"reasons": [
"High ESRS_E1 materiality",
"Manufacturing sector with high climate_transition risk",
"Large EU undertaking under CSRD"
]
},
...
]

This list is great both for UI and for logging / explainability.

3.2 Pre-filling placeholders

Use the placeholders array in the MDX frontmatter:

  • For each placeholder:
  • Resolve source against companyProfile or other ZAYAZ data
  • Fill simple variables like {{company_name}}, {{registered_country}}, etc.
  • This gives you a pre-populated draft the AI can refine instead of writing from scratch.

You can do this with a small, deterministic renderer on the backend.

3.3 LLM call: “adapt not invent”

This is where the AI comes in. We want it to:

  • Respect the underlying structure
  • Adapt language, specifics, and detail
  • Not hallucinate frameworks or laws

High-level prompt architecture:

  • System message: who the AI is & global rules
  • Template/context message: the base policy text + metadata
  • Company profile message: JSON with key profile data
  • Instruction message: what to adapt / what not to change
  • Optional: few-shot examples of good outputs

4. Prompt design: concrete example

4.1 System prompt (engine-level)

Something like:

You are the ZAYAZ Policy Engine, an ESG/CSRD-aligned AI assistant. You generate and adapt sustainability-related policies for companies based on: – Approved base templates authored by Viroway Ltd. – Company attributes (size, NACE, jurisdictions, risk profile).

Hard rules: – Never invent or misquote legal requirements. – When referencing frameworks (ESRS, CSRD, GRI, ISO, UN SDGs, etc.), use only those explicitly provided to you. – Preserve the structural sections of the base template (headings, numbered sections) unless asked otherwise. – Use clear, concise, non-marketing language suitable for internal and external stakeholders. – Do not remove disclaimers or notes that indicate this is a template. – Always keep the meaning aligned with the base template, just adapted to the company.

This is generic and reused for all templates.

4.2 Template context prompt

You pass:

  • Template frontmatter (or a summarised version)
  • Framework tags & groups (for extra context)
  • Template body with placeholders already substituted (where possible)

Example (abridged):

[BASE_TEMPLATE_METADATA]
templateId: tpl_policy_env_climate_change_v1
domain: env
category: climate_change
frameworkTags: [CSRD, ESRS_E1, ESRS_2, TCFD, SBTI, GHG_PROTOCOL]
frameworkGroups: [CLIMATE_CORE, ESG_REPORTING_CORE]

[BASE_TEMPLATE_TEXT]
# Climate Change Policy – Template

Purpose:
This policy outlines `{{company_name}}`’s commitment to managing climate-related risks ...
...

You’ll replace {{company_name}} etc. before giving it to the model.

4.3 Company context prompt

Send the company profile (trimmed to essentials):

{
"companyId": "acme-123",
"legalName": "ACME Manufacturing Group AG",
"size": "large_undertaking",
"jurisdictions": ["EU", "NO"],
"naceCodes": ["C25.11", "C25.29"],
"riskSignals": {
"climatePhysical": "high",
"climateTransition": "medium",
"biodiversity": "high"
},
"esrsApplicability": {
"E1": "high",
"E4": "high",
"S1": "high",
"G1": "high"
}
}

4.4 Instruction / user message

Example:

TASK:
Adapt the provided Climate Change Policy template for the company above.

Requirements:
- Keep the same section structure and headings.
- Fill in any remaining placeholders in a generic but realistic way.
- Make the policy appropriate for:
- A large EU manufacturing group with high E1 and E4 materiality.
- Operations in EU and Norway.
- Explicitly connect the policy to CSRD, ESRS E1, TCFD and the GHG Protocol, but do not add frameworks that were not provided.
- Use formal, clear language suitable for both internal employees and external stakeholders (reading level B2).
- Keep length under approximately 1,800 words.

Output:
Return ONLY the adapted policy text as Markdown/MDX.
Do not include explanations or commentary.

This uses the aiProfile fields too (style, readingLevel, lengthHintWords).

5. Guardrails & validation after generation

Once the LLM has generated a policy, you can run some post-checks: 1. Structure check

  • Ensure required sections (Purpose, Scope, Responsibilities, Monitoring & Review) still exist. 2. Framework check
  • Search text for framework names (e.g. “GRI”, “ISO 9001”)
  • Ensure they appear only in frameworkTags + allowed frameworks for that template. 3. Placeholder check
  • Verify no {{...}} placeholders are left unresolved. 4. Length check
  • Count words vs. lengthHintWords and warn if too large.

You can implement these checks in the same spirit as your current MDX validators.

6. Where to put this in ZAYAZ architecture

I’d introduce a small dedicated service/module:

policy-generation-engin

Responsibilities:

  • API endpoints, e.g.:
POST /api/policies/generate
POST /api/policies/suggest
POST /api/policies/adapt
  • Loading templates from filesystem / CMS / DB
  • Applying placeholder fills
  • Talking to the LLM (via OpenAI API or equivalent)
  • Running validations and returning structured results:
{
"policyInstanceId": "acme-123:tpl_policy_env_climate_change_v1:2025-001",
"templateId": "tpl_policy_env_climate_change_v1",
"companyId": "acme-123",
"generatedAt": "2025-11-26T14:30:00Z",
"frameworkTags": ["CSRD", "ESRS_E1", "TCFD", "SBTI"],
"qualityChecks": {
"structureOk": true,
"allowedFrameworksOnly": true,
"placeholdersResolved": true,
"warnings": []
},
"policyMarkdown": "## Climate Change Policy\n..."
}

On the Vue 3 side, you just call this engine, then render policyMarkdown into your policy editor.

7. Implementation roadmap (minimal but powerful)

Step 1 – Read-only AI

  • Implement POST /api/policies/adapt
  • No recommendations yet: user picks a template manually. Step 2 – Recommendation layer
  • Add template scoring based on ESRS, NACE, size, risk. Step 3 – Advanced controls
  • Ambition slider (low/med/high)
  • Internal vs external audience
  • Language variants Step 4 – Feedback loop
  • Capture which generated clauses users keep/edit/remove
  • Use signals to refine prompts and default wording over time.

8. JSON schema for PolicyGenerationSpec and CompanyProfile

two main definitions:

  • CompanyProfile
  • PolicyGenerationSpec

JSON Schema (Draft 2020-12)

/src/components/policy-generator.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://zayaz.eco/schemas/policy-generation.json",
"title": "ZAYAZ Policy Generation Schemas",
"type": "object",
"properties": {
"companyProfile": { "$ref": "#/$defs/CompanyProfile" },
"policyGenerationSpec": { "$ref": "#/$defs/PolicyGenerationSpec" }
},
"required": ["companyProfile", "policyGenerationSpec"],
  "$defs": {
"CompanyProfile": {
"title": "CompanyProfile",
"type": "object",
"additionalProperties": false,
"properties": {
"companyId": {
"type": "string",
"description": "Internal unique identifier for the company in ZAYAZ."
},
"legalName": {
"type": "string",
"description": "Official legal name of the company."
},
"brandName": {
"type": "string",
"description": "Common brand or trade name (if different from legalName)."
},
"size": {
"type": "object",
"description": "Size metrics and CSRD-related classification.",
"additionalProperties": false,
"properties": {
"employees": {
"type": "integer",
"minimum": 0
},
"turnoverEUR": {
"type": "number",
"minimum": 0
},
"balanceSheetTotalEUR": {
"type": "number",
"minimum": 0
},
"csrdScope": {
"type": "string",
"description": "Categorisation relative to CSRD thresholds.",
"enum": [
"micro",
"small",
"medium",
"large_undertaking",
"listed_sme",
"consolidated_group",
"non_eu_parent",
"not_in_scope",
"unknown"
]
}
},
"required": ["csrdScope"]
},
"jurisdictions": {
"type": "array",
"description": "List of main jurisdictions where the company operates or is regulated. Typically ISO 3166 country codes or higher-level labels like EU.",
"items": { "type": "string" },
"uniqueItems": true
},
"headquartersCountry": {
"type": "string",
"description": "Primary country of headquarters (ISO 3166-1 alpha-2 or alpha-3)."
},
"naceCodes": {
"type": "array",
"description": "Primary and secondary NACE activity codes.",
"items": {
"type": "string",
"pattern": "^[A-U][0-9]{2}(\\.[0-9]{1,2})?$"
},
"uniqueItems": true
},
"sectors": {
"type": "array",
"description": "Normalised sector tags used by ZAYAZ (ontology can be maintained separately).",
"items": { "type": "string" },
"uniqueItems": true
},
"languages": {
"type": "array",
"description": "Preferred languages for generated content (BCP-47 or ISO 639).",
"items": { "type": "string" },
"uniqueItems": true,
"default": ["en"]
},
"esrsApplicability": {
"type": "object",
"description": "Per-standard qualitative materiality/priority signal (from SIS/materiality engine).",
"additionalProperties": false,
"properties": {
"E1": { "$ref": "#/$defs/MaterialityLevel" },
"E2": { "$ref": "#/$defs/MaterialityLevel" },
"E3": { "$ref": "#/$defs/MaterialityLevel" },
"E4": { "$ref": "#/$defs/MaterialityLevel" },
"E5": { "$ref": "#/$defs/MaterialityLevel" },
"S1": { "$ref": "#/$defs/MaterialityLevel" },
"S2": { "$ref": "#/$defs/MaterialityLevel" },
"S3": { "$ref": "#/$defs/MaterialityLevel" },
"S4": { "$ref": "#/$defs/MaterialityLevel" },
"G1": { "$ref": "#/$defs/MaterialityLevel" }
}
},
"riskSignals": {
"type": "object",
"description": "High-level risk signals that can influence ambition, emphasis and examples.",
"additionalProperties": false,
"properties": {
"climatePhysical": { "$ref": "#/$defs/RiskLevel" },
"climateTransition": { "$ref": "#/$defs/RiskLevel" },
"biodiversity": { "$ref": "#/$defs/RiskLevel" },
"waterStress": { "$ref": "#/$defs/RiskLevel" },
"supplyChainHumanRights": { "$ref": "#/$defs/RiskLevel" },
"dataPrivacySecurity": { "$ref": "#/$defs/RiskLevel" },
"corruptionBribery": { "$ref": "#/$defs/RiskLevel" },
"productSafety": { "$ref": "#/$defs/RiskLevel" }
}
},
"tonePreferences": {
"type": "object",
"description": "Content style and tone preferences to steer policy drafting.",
"additionalProperties": false,
"properties": {
"formality": {
"type": "string",
"enum": ["low", "medium", "high"],
"default": "high"
},
"length": {
"type": "string",
"enum": ["short", "medium", "long"],
"default": "medium"
},
"audience": {
"type": "string",
"enum": ["internal_only", "external_only", "internal_external"],
"default": "internal_external"
},
"readingLevel": {
"type": "string",
"description": "Approximate CEFR-style reading level.",
"enum": ["B1", "B2", "C1", "C2"],
"default": "B2"
}
}
},
"contacts": {
"type": "object",
"description": "Optional key ESG-related roles used for governance sections.",
"additionalProperties": false,
"properties": {
"esgLeadTitle": { "type": "string" },
"boardCommitteeName": { "type": "string" },
"sustainabilityDepartmentName": { "type": "string" }
}
},
"metadata": {
"type": "object",
"description": "Free-form extension for internal use (e.g. tags, clusters, scoring).",
"additionalProperties": true
}
},
"required": ["companyId", "legalName", "size"]
},

"PolicyGenerationSpec": {
"title": "PolicyGenerationSpec",
"type": "object",
"additionalProperties": false,
"properties": {
"requestId": {
"type": "string",
"description": "Idempotency / trace ID for this generation request."
},
"companyId": {
"type": "string",
"description": "Must match CompanyProfile.companyId. Used for logging and storage."
},
"templateId": {
"type": "string",
"description": "The template to base the policy on (e.g. tpl_policy_env_climate_change_v1)."
},
"language": {
"type": "string",
"description": "Language for the generated policy content (BCP-47 or ISO 639).",
"default": "en"
},
"targetAudience": {
"type": "string",
"enum": ["internal_only", "external_only", "internal_external"],
"default": "internal_external"
},
"policyPurpose": {
"type": "string",
"description": "How the policy will be used.",
"enum": [
"core_policy",
"supporting_policy",
"guideline",
"procedure",
"supplier_addendum",
"customer_facing_statement"
],
"default": "core_policy"
},
"strictnessLevel": {
"type": "string",
"description": "Desired ambition/strictness of commitments.",
"enum": ["low", "medium", "medium_high", "high"],
"default": "medium_high"
},
"detailLevel": {
"type": "string",
"description": "Granularity of operational detail.",
"enum": ["principles_only", "balanced", "detailed"],
"default": "balanced"
},
"includeFrameworks": {
"type": "array",
"description": "Framework tags that MUST be referenced or at least considered in phrasing.",
"items": { "type": "string" },
"uniqueItems": true
},
"excludeFrameworks": {
"type": "array",
"description": "Framework tags that MUST NOT be referenced explicitly (e.g. not applicable jurisdictions).",
"items": { "type": "string" },
"uniqueItems": true
},
"jurisdictionOverride": {
"type": "array",
"description": "If set, restrict adaptation to these jurisdictions instead of full companyProfile.jurisdictions.",
"items": { "type": "string" },
"uniqueItems": true
},
"maxWords": {
"type": "integer",
"minimum": 200,
"maximum": 10000,
"description": "Soft maximum word count for generated content."
},
"sectionsToInclude": {
"type": "array",
"description": "Optional whitelist of section IDs/names from the template to include.",
"items": { "type": "string" },
"uniqueItems": true
},
"sectionsToExclude": {
"type": "array",
"description": "Optional blacklist of section IDs/names from the template to remove.",
"items": { "type": "string" },
"uniqueItems": true
},
"enablePlaceholdersFallback": {
"type": "boolean",
"description": "If true, the AI is allowed to fill unresolved placeholders with generic but sensible text; if false, unresolved placeholders should be left as-is.",
"default": false
},
"rewriteExistingText": {
"type": "string",
"description": "Control how much the AI is allowed to modify baseline template wording.",
"enum": ["minimal", "moderate", "extensive"],
"default": "moderate"
},
"debugMode": {
"type": "boolean",
"description": "If true, engine may attach extra diagnostics (e.g. section mapping, applied frameworks).",
"default": false
},
"metadata": {
"type": "object",
"description": "Free-form extension point (e.g. UI origin, userId, scenario).",
"additionalProperties": true
}
},
"required": ["companyId", "templateId"]
},

"MaterialityLevel": {
"type": "string",
"enum": ["none", "low", "medium", "high", "unknown"],
"description": "Qualitative importance / materiality level for a topic."
},

"RiskLevel": {
"type": "string",
"enum": ["none", "low", "medium", "high", "unknown"],
"description": "Qualitative risk level classification."
}
}
}

Example instances (for quick sanity checks)

CompanyProfile example

{
"companyId": "acme-123",
"legalName": "ACME Manufacturing Group AG",
"brandName": "ACME",
"size": {
"employees": 4200,
"turnoverEUR": 780000000,
"csrdScope": "large_undertaking"
},
"jurisdictions": ["EU", "NO"],
"headquartersCountry": "NO",
"naceCodes": ["C25.11", "C25.29"],
"sectors": ["metals", "industrial_components"],
"languages": ["en", "no"],
"esrsApplicability": {
"E1": "high",
"E4": "high",
"S1": "high",
"G1": "medium"
},
"riskSignals": {
"climatePhysical": "high",
"climateTransition": "medium",
"biodiversity": "high",
"supplyChainHumanRights": "high"
},
"tonePreferences": {
"formality": "high",
"length": "medium",
"audience": "internal_external",
"readingLevel": "B2"
}
}

PolicyGenerationSpec example

{
"requestId": "req-2025-0001",
"companyId": "acme-123",
"templateId": "tpl_policy_env_climate_change_v1",
"language": "en",
"targetAudience": "internal_external",
"policyPurpose": "core_policy",
"strictnessLevel": "medium_high",
"detailLevel": "balanced",
"includeFrameworks": ["CSRD", "ESRS_E1", "TCFD", "SBTI"],
"excludeFrameworks": [],
"jurisdictionOverride": ["EU", "NO"],
"maxWords": 2000,
"rewriteExistingText": "moderate",
"enablePlaceholdersFallback": false
}

9. Endpoint overview

HTTP

POST /api/policies/generate
Content-Type: application/json
Accept: application/json

High-level behaviour

  • Takes:
    • A PolicyGenerationSpec (what to generate, from which template, with which constraints)
    • Either an inline CompanyProfile or a companyId that ZAYAZ can resolve
  • Runs:
    • Template lookup (from template-registry.json + MDX)
    • Placeholder pre-fill from company data
    • LLM adaptation
    • Post-generation quality checks
  • Returns:
    • A generated policy Markdown/MDX string
    • Metadata (IDs, frameworks, checks, trace info)

10. Request contract

JSON body

{
"companyId": "acme-123", // required if companyProfile is not provided
"companyProfile": { /* optional; inline CompanyProfile */ },

"spec": {
"requestId": "req-2025-0001",
"companyId": "acme-123",
"templateId": "tpl_policy_env_climate_change_v1",
"language": "en",
"targetAudience": "internal_external",
"policyPurpose": "core_policy",
"strictnessLevel": "medium_high",
"detailLevel": "balanced",
"includeFrameworks": ["CSRD", "ESRS_E1", "TCFD", "SBTI"],
"excludeFrameworks": [],
"jurisdictionOverride": ["EU", "NO"],
"maxWords": 2000,
"sectionsToInclude": [],
"sectionsToExclude": [],
"enablePlaceholdersFallback": false,
"rewriteExistingText": "moderate",
"debugMode": false,
"metadata": {
"invokedBy": "ui.policy-wizard",
"userId": "user-789"
}
}
}

Rules

  • Either:
    • companyProfile is provided (full inline object, validated by the CompanyProfile schema), or
    • companyId is provided and the service loads the profile from SIS.
  • spec.companyId MUST match companyId (if both present).
  • spec.templateId MUST be a valid template ID from template-registry.json.

If both companyId and companyProfile are provided, you can:

  • Prefer companyProfile for generation
  • Still log companyId as the identity / storage key

11. Response contract

200 OK – success

{
"policyInstanceId": "acme-123:tpl_policy_env_climate_change_v1:2025-0001",
"companyId": "acme-123",
"templateId": "tpl_policy_env_climate_change_v1",
"templateVersion": "1",
"language": "en",
"generatedAt": "2025-11-26T14:35:12.123Z",

"frameworkTags": ["CSRD", "ESRS_E1", "ESRS_2", "TCFD", "SBTI", "GHG_PROTOCOL"],
"frameworkGroups": ["CLIMATE_CORE", "ESG_REPORTING_CORE"],

"qualityChecks": {
"structureOk": true,
"allowedFrameworksOnly": true,
"placeholdersResolved": true,
"length": {
"words": 1675,
"overMax": false
},
"warnings": [],
"errors": []
},

"policyMarkdown": "# Climate Change Policy\n\n## 1. Purpose\nThis policy ...",

"baseTemplateInfo": {
"id": "policy-env-climate-change",
"slug": "policies/environment/climate-change-policy",
"title": "Climate Change Policy – Template"
},

"trace": {
"requestId": "req-2025-0001",
"engineVersion": "policy-gen-1.0.0",
"model": "gpt-5.1-policy-2025-11-01",
"promptTokens": 5423,
"completionTokens": 1804,
"latencyMs": 2850
}
}

Notes

  • policyInstanceId is the logical identifier for this generated version; you can later store it in a policies DB.
  • frameworkTags/frameworkGroups echo what was actually applied (and allow UI filters).
  • qualityChecks gives your frontend a way to:
    • Show “This looks good ✅” vs “Review these warnings ⚠️”.
  • trace is gold for debugging & billing.

Error responses (enveloped)

400 – Bad Request (missing/invalid fields)

{
"error": {
"code": "BAD_REQUEST",
"message": "Validation failed for PolicyGenerationSpec.",
"details": {
"fieldErrors": [
{ "path": "spec.templateId", "message": "templateId is required." }
]
}
}
}

404 – Company or template not found

{
"error": {
"code": "NOT_FOUND",
"message": "Template tpl_policy_env_climate_change_v1 not found.",
"details": {
"resource": "template",
"templateId": "tpl_policy_env_climate_change_v1"
}
}
}

422 – Business rule / framework mismatch

{
"error": {
"code": "UNPROCESSABLE_ENTITY",
"message": "Requested frameworks are not compatible with the chosen template.",
"details": {
"templateFrameworks": ["CSRD", "ESRS_E1", "TCFD"],
"includeFrameworks": ["CCPA"]
}
}
}

500 – Internal error

{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred while generating the policy.",
"details": {
"traceId": "trc-abc-123"
}
}
}

12. TypeScript interfaces (frontend/backend shared)

You can put these in something like schemas/policy-generation.ts.

// CompanyProfile & PolicyGenerationSpec from earlier schemas:
export type MaterialityLevel = "none" | "low" | "medium" | "high" | "unknown";
export type RiskLevel = "none" | "low" | "medium" | "high" | "unknown";

export interface CompanyProfile {
companyId: string;
legalName: string;
brandName?: string;
size: {
employees?: number;
turnoverEUR?: number;
balanceSheetTotalEUR?: number;
csrdScope:
| "micro"
| "small"
| "medium"
| "large_undertaking"
| "listed_sme"
| "consolidated_group"
| "non_eu_parent"
| "not_in_scope"
| "unknown";
};
jurisdictions?: string[];
headquartersCountry?: string;
naceCodes?: string[];
sectors?: string[];
languages?: string[];
esrsApplicability?: {
E1?: MaterialityLevel;
E2?: MaterialityLevel;
E3?: MaterialityLevel;
E4?: MaterialityLevel;
E5?: MaterialityLevel;
S1?: MaterialityLevel;
S2?: MaterialityLevel;
S3?: MaterialityLevel;
S4?: MaterialityLevel;
G1?: MaterialityLevel;
};
riskSignals?: {
climatePhysical?: RiskLevel;
climateTransition?: RiskLevel;
biodiversity?: RiskLevel;
waterStress?: RiskLevel;
supplyChainHumanRights?: RiskLevel;
dataPrivacySecurity?: RiskLevel;
corruptionBribery?: RiskLevel;
productSafety?: RiskLevel;
};
tonePreferences?: {
formality?: "low" | "medium" | "high";
length?: "short" | "medium" | "long";
audience?: "internal_only" | "external_only" | "internal_external";
readingLevel?: "B1" | "B2" | "C1" | "C2";
};
contacts?: {
esgLeadTitle?: string;
boardCommitteeName?: string;
sustainabilityDepartmentName?: string;
};
metadata?: Record<string, unknown>;
}

export interface PolicyGenerationSpec {
requestId?: string;
companyId: string;
templateId: string;
language?: string;
targetAudience?: "internal_only" | "external_only" | "internal_external";
policyPurpose?:
| "core_policy"
| "supporting_policy"
| "guideline"
| "procedure"
| "supplier_addendum"
| "customer_facing_statement";
strictnessLevel?: "low" | "medium" | "medium_high" | "high";
detailLevel?: "principles_only" | "balanced" | "detailed";
includeFrameworks?: string[];
excludeFrameworks?: string[];
jurisdictionOverride?: string[];
maxWords?: number;
sectionsToInclude?: string[];
sectionsToExclude?: string[];
enablePlaceholdersFallback?: boolean;
rewriteExistingText?: "minimal" | "moderate" | "extensive";
debugMode?: boolean;
metadata?: Record<string, unknown>;
}

// ----- API Contract -----

export interface GeneratePolicyRequest {
companyId?: string;
companyProfile?: CompanyProfile;
spec: PolicyGenerationSpec;
}

export interface PolicyQualityChecks {
structureOk: boolean;
allowedFrameworksOnly: boolean;
placeholdersResolved: boolean;
length: {
words: number;
overMax: boolean;
};
warnings: string[];
errors: string[];
}

export interface GeneratePolicyResponse {
policyInstanceId: string;
companyId: string;
templateId: string;
templateVersion: string;
language: string;
generatedAt: string; // ISO timestamp
frameworkTags: string[];
frameworkGroups: string[];
qualityChecks: PolicyQualityChecks;
policyMarkdown: string;
baseTemplateInfo: {
id: string;
slug: string;
title: string;
};
trace?: {
requestId?: string;
engineVersion: string;
model: string;
promptTokens?: number;
completionTokens?: number;
latencyMs?: number;
};
}

// Error envelope
export interface ApiError {
error: {
code:
| "BAD_REQUEST"
| "NOT_FOUND"
| "UNPROCESSABLE_ENTITY"
| "INTERNAL_ERROR"
| string;
message: string;
details?: Record<string, unknown>;
};
}

13. Backend structure

Something like:

src/
api/
policies/
generatePolicyHandler.ts
services/
policyGenerationService.ts
templateLoader.ts
placeholderFiller.ts
promptBuilder.ts
config/
paths.ts
config/system/
template-registry.json
framework-tags.json
framework-groups.json
content/
templates/
policies/
env/...
social/...
gov/...
cross/...

Assuming Node + Express (or any HTTP framework).

14. TypeScript: core types (re-use)

Use the interfaces we already defined; I’ll just import them here:

// src/types/policyGeneration.ts
export * from "../schemas/policy-generation"; // or copy the interfaces here

I’ll refer to:

  • CompanyProfile
  • PolicyGenerationSpec
  • GeneratePolicyRequest
  • GeneratePolicyResponse
  • PolicyQualityChecks

15. Template loader & placeholder filler

15.1 templateLoader.ts

// src/services/templateLoader.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";

import templateRegistry from "../../config/system/template-registry.json";

export interface TemplateRecord {
templateId: string;
templateVersion: string;
filePath: string;
slug: string;
title: string;
domain: string;
category: string;
frameworkTags: string[];
}

export interface LoadedTemplate {
meta: TemplateRecord & { frontmatter: any };
body: string;
}

const PROJECT_ROOT = path.resolve(__dirname, "..", "..");
const TEMPLATE_ROOT = path.join(PROJECT_ROOT, "content", "templates");

export function getTemplateRecordById(templateId: string): TemplateRecord | null {
const record = (templateRegistry as TemplateRecord[]).find(
(t) => t.templateId === templateId
);
return record || null;
}

export function loadTemplateById(templateId: string): LoadedTemplate {
const record = getTemplateRecordById(templateId);
if (!record) {
throw new Error(`Template not found for templateId=${templateId}`);
}

const absPath = path.join(PROJECT_ROOT, record.filePath);
const raw = fs.readFileSync(absPath, "utf8");
const parsed = matter(raw);

return {
meta: {
...record,
frontmatter: parsed.data
},
body: parsed.content
};
}

Note: template-registry.json already has filePath entries because of the generator script — we’re just reusing that.

15.2 placeholderFiller.ts

Simple deterministic {{key}} substitution based on placeholders + companyProfile.

// src/services/placeholderFiller.ts
import type { CompanyProfile } from "../types/policyGeneration";

interface PlaceholderDefinition {
key: string;
label?: string;
description?: string;
source?: string; // e.g. "company_profile.legal_name"
required?: boolean;
}

export interface PlaceholderFillResult {
text: string;
unresolvedPlaceholders: string[];
}

export function fillPlaceholders(
text: string,
placeholders: PlaceholderDefinition[],
companyProfile: CompanyProfile
): PlaceholderFillResult {
let result = text;
const unresolved: string[] = [];

for (const ph of placeholders) {
const token = `{{${ph.key}}}`;

if (!result.includes(token)) continue;

let value: string | undefined;

// Very simple source mapping – can be extended
if (ph.source?.startsWith("company_profile.")) {
const path = ph.source.replace("company_profile.", "");
value = getCompanyField(companyProfile, path);
}

if (!value) {
// try some common fallbacks
if (ph.key === "company_name") value = companyProfile.legalName;
}

if (!value) {
unresolved.push(ph.key);
continue;
}

const re = new RegExp(escapeRegExp(token), "g");
result = result.replace(re, value);
}

return { text: result, unresolvedPlaceholders: unresolved };
}

function getCompanyField(obj: any, path: string): string | undefined {
const parts = path.split(".");
let current: any = obj;
for (const p of parts) {
if (!current || typeof current !== "object") return undefined;
current = current[p];
}
if (current == null) return undefined;
if (typeof current === "string" || typeof current === "number") {
return String(current);
}
return undefined;
}

function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

16. Prompt builder (LLM messages)

We’ll construct a messages array ready for OpenAI’s Chat API (or similar).

16.1 promptBuilder.ts

// src/services/promptBuilder.ts
import type { CompanyProfile, PolicyGenerationSpec } from "../types/policyGeneration";
import type { LoadedTemplate } from "./templateLoader";

interface LlmMessage {
role: "system" | "user" | "assistant";
content: string;
}

export interface PolicyPromptContext {
messages: LlmMessage[];
// you may also want a flattened "debug" string here
}

export function buildPolicyPrompt(
companyProfile: CompanyProfile,
spec: PolicyGenerationSpec,
template: LoadedTemplate
): PolicyPromptContext {
const { meta, body } = template;
const fm = meta.frontmatter || {};
const frameworkTags: string[] = fm.frameworkTags || meta.frameworkTags || [];

const system: LlmMessage = {
role: "system",
content: [
"You are the ZAYAZ Policy Engine, an ESG/CSRD-aligned AI assistant.",
"You generate and adapt sustainability-related policies for companies based on:",
"- Approved base templates authored by Viroway Ltd.",
"- Structured company profiles (size, NACE, jurisdictions, ESRS materiality, risk signals).",
"",
"Hard rules:",
"- Do not invent legal requirements or standards not provided in the context.",
"- Only reference frameworks (ESRS, CSRD, GRI, ISO, UN SDGs, etc.) that are explicitly listed in the input.",
"- Preserve the section structure and headings of the base template, unless the instructions explicitly allow removal.",
"- Use clear, precise, non-marketing language suitable for internal and external stakeholders.",
"- Keep the meaning aligned with the base template while adapting details to the company profile."
].join("\n")
};

const templateContext: LlmMessage = {
role: "user",
content: [
"[BASE_TEMPLATE_METADATA]",
`templateId: ${meta.templateId}`,
`domain: ${fm.domain ?? meta.domain}`,
`category: ${fm.category ?? meta.category}`,
`frameworkTags: ${JSON.stringify(frameworkTags)}`,
"",
"[BASE_TEMPLATE_TEXT]",
body
].join("\n")
};

const companyContext: LlmMessage = {
role: "user",
content: [
"[COMPANY_PROFILE]",
JSON.stringify(companyProfile, null, 2)
].join("\n")
};

const instructions: LlmMessage = {
role: "user",
content: buildInstructionBlock(companyProfile, spec, frameworkTags)
};

return {
messages: [system, templateContext, companyContext, instructions]
};
}

function buildInstructionBlock(
companyProfile: CompanyProfile,
spec: PolicyGenerationSpec,
frameworkTags: string[]
): string {
const maxWords = spec.maxWords ?? 2000;
const strictness = spec.strictnessLevel ?? "medium_high";
const detailLevel = spec.detailLevel ?? "balanced";
const audience = spec.targetAudience ?? "internal_external";
const readingLevel = companyProfile.tonePreferences?.readingLevel ?? "B2";

const includeFw = spec.includeFrameworks || frameworkTags;
const excludeFw = spec.excludeFrameworks || [];

return [
"[TASK]",
"Adapt the provided base policy template for the company profile above.",
"",
"Requirements:",
`- Keep the same section structure and headings unless sectionsToExclude specifies otherwise.`,
`- Language: ${spec.language ?? "en"}.`,
`- Target audience: ${audience}.`,
`- Reading level (approx.): ${readingLevel}.`,
`- Strictness / ambition level: ${strictness}.`,
`- Detail level: ${detailLevel}.`,
`- Soft word limit: ${maxWords} words.`,
"",
"Framework handling:",
`- You MAY reference and align with these frameworks: ${JSON.stringify(includeFw)}.`,
`- Do NOT reference these frameworks explicitly: ${JSON.stringify(excludeFw)}.`,
"- Do NOT introduce frameworks or standards that are not provided in the input.",
"",
"Placeholders:",
"- If you encounter unresolved placeholders like `{{something}}`:",
spec.enablePlaceholdersFallback
? " - Fill them with generic but sensible text based on the company profile."
: " - Leave them as-is (do not invent values).",
"",
"Output:",
"- Return ONLY the adapted policy text as Markdown/MDX.",
"- Do NOT include explanations, commentary or metadata."
""
].join("\n");
}

This gives you a consistent prompt you can send to the model of your choice.

17. Policy generation service

17.1 policyGenerationService.ts

// src/services/policyGenerationService.ts
import type {
CompanyProfile,
PolicyGenerationSpec,
GeneratePolicyResponse,
PolicyQualityChecks
} from "../types/policyGeneration";
import { loadTemplateById } from "./templateLoader";
import { fillPlaceholders } from "./placeholderFiller";
import { buildPolicyPrompt } from "./promptBuilder";

// TODO: replace with real SIS / DB lookup
async function fetchCompanyProfile(companyId: string): Promise<CompanyProfile> {
throw new Error("fetchCompanyProfile not implemented");
}

// TODO: replace with actual OpenAI / LLM client
async function callLlm(messages: { role: string; content: string }[]): Promise<string> {
// For now, this is a stub.
// In real code you call OpenAI ChatCompletion / etc. and return the content.
return "## Generated policy placeholder\n\nThis is where the model output will go.";
}

export async function generatePolicy(
companyIdOrProfile: { companyId?: string; companyProfile?: CompanyProfile },
spec: PolicyGenerationSpec
): Promise<GeneratePolicyResponse> {
let companyProfile: CompanyProfile;

if (companyIdOrProfile.companyProfile) {
companyProfile = companyIdOrProfile.companyProfile;
} else if (companyIdOrProfile.companyId) {
companyProfile = await fetchCompanyProfile(companyIdOrProfile.companyId);
} else {
throw new Error("Either companyId or companyProfile must be provided");
}

// 1) Load template
const template = loadTemplateById(spec.templateId);

// 2) Pre-fill placeholders
const placeholders = template.meta.frontmatter?.placeholders || [];
const filled = fillPlaceholders(template.body, placeholders, companyProfile);

const bodyWithFilledPlaceholders = filled.text;
const unresolved = filled.unresolvedPlaceholders;

// 3) Build prompt
const templateWithFilledBody = {
...template,
body: bodyWithFilledPlaceholders
};
const promptContext = buildPolicyPrompt(companyProfile, spec, templateWithFilledBody);

// 4) Call LLM
const t0 = Date.now();
const generatedText = await callLlm(promptContext.messages);
const latencyMs = Date.now() - t0;

// 5) Run basic quality checks
const wordCount = countWords(generatedText);
const maxWords = spec.maxWords ?? 2000;

const qualityChecks: PolicyQualityChecks = {
structureOk: true, // TODO: implement structure heuristics (e.g. required headings)
allowedFrameworksOnly: true, // TODO: implement simple scan vs allowed list
placeholdersResolved: unresolved.length === 0,
length: {
words: wordCount,
overMax: wordCount > maxWords
},
warnings: [],
errors: []
};

if (wordCount > maxWords) {
qualityChecks.warnings.push(
`Generated policy has ${wordCount} words, above the soft limit of ${maxWords}.`
);
}
if (unresolved.length > 0) {
qualityChecks.warnings.push(
`Unresolved placeholders remained in the template before generation: ${unresolved.join(
", "
)}`
);
}

const fm = template.meta.frontmatter || {};

const response: GeneratePolicyResponse = {
policyInstanceId: buildPolicyInstanceId(companyProfile.companyId, spec.templateId),
companyId: companyProfile.companyId,
templateId: spec.templateId,
templateVersion: String(template.meta.templateVersion ?? "1"),
language: spec.language ?? companyProfile.languages?.[0] ?? "en",
generatedAt: new Date().toISOString(),
frameworkTags: fm.frameworkTags || template.meta.frameworkTags || [],
frameworkGroups: fm.frameworkGroups || [], // can be enhanced by mapping
qualityChecks,
policyMarkdown: generatedText,
baseTemplateInfo: {
id: template.meta.templateId,
slug: template.meta.slug,
title: fm.title ?? template.meta.title
},
trace: {
requestId: spec.requestId,
engineVersion: "policy-gen-1.0.0",
model: "llm-to-be-configured",
latencyMs
}
};

return response;
}

function buildPolicyInstanceId(companyId: string, templateId: string): string {
const ts = new Date().toISOString();
return `${companyId}:${templateId}:${ts}`;
}

function countWords(text: string): number {
return text
.split(/\s+/)
.map((w) => w.trim())
.filter((w) => w.length > 0).length;
}

18. Express-style handler

18.1 generatePolicyHandler.ts

// src/api/policies/generatePolicyHandler.ts
import { Request, Response } from "express";
import type { GeneratePolicyRequest, ApiError } from "../../types/policyGeneration";
import { generatePolicy } from "../../services/policyGenerationService";

export async function generatePolicyHandler(req: Request, res: Response) {
const body = req.body as GeneratePolicyRequest;

try {
if (!body || !body.spec) {
return res.status(400).json({
error: {
code: "BAD_REQUEST",
message: "Missing spec in request body."
}
} as ApiError);
}

const { companyId, companyProfile, spec } = body;

if (!companyId && !companyProfile) {
return res.status(400).json({
error: {
code: "BAD_REQUEST",
message: "Either companyId or companyProfile must be provided."
}
} as ApiError);
}

if (!spec.companyId && companyId) {
spec.companyId = companyId;
}

if (spec.companyId && companyId && spec.companyId !== companyId) {
return res.status(400).json({
error: {
code: "BAD_REQUEST",
message: "spec.companyId must match companyId."
}
} as ApiError);
}

const result = await generatePolicy({ companyId, companyProfile }, spec);
return res.status(200).json(result);
} catch (err: any) {
console.error("generatePolicyHandler error", err);
const apiError: ApiError = {
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred while generating the policy.",
details: {
error: err?.message
}
}
};
return res.status(500).json(apiError);
}
}

Wire this into your Express router:

// src/api/routes.ts
import express from "express";
import { generatePolicyHandler } from "./policies/generatePolicyHandler";

const router = express.Router();

router.post("/api/policies/generate", express.json(), generatePolicyHandler);

export default router;

19. Install OpenAI SDK

From your backend project root:

npm install openai

And make sure you have the API key set in env (e.g. in Codespaces secrets):

export OPENAI_API_KEY=sk-...

(or via .env → process.env.OPENAI_API_KEY)

20. LLM client module

Create a file, e.g.: src/services/llmClient.t

// src/services/llmClient.ts
import OpenAI from "openai";

const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});

export type LlmRole = "system" | "user" | "assistant";

export interface LlmMessage {
role: LlmRole;
content: string;
}

export interface LlmCallOptions {
model?: string;
temperature?: number;
maxTokens?: number;
timeoutMs?: number;
}

export interface LlmCallResult {
text: string;
model: string;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
latencyMs: number;
}

/**
* Core helper that sends messages to OpenAI Chat Completions and returns the text + usage.
*/
export async function callLlm(
messages: LlmMessage[],
options: LlmCallOptions = {}
): Promise<LlmCallResult> {
const {
model = "gpt-4.1-mini", // or "gpt-4.1" depending on your cost/quality choice
temperature = 0.2,
maxTokens = 2000,
timeoutMs = 30000
} = options;

const t0 = Date.now();

try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);

const response = await openai.chat.completions.create(
{
model,
messages: messages.map((m) => ({
role: m.role,
content: m.content
})),
temperature,
max_tokens: maxTokens
},
{
signal: controller.signal
}
);

clearTimeout(timeout);

const choice = response.choices?.[0];
const text = choice?.message?.content ?? "";

const usage = response.usage;

return {
text,
model: response.model ?? model,
promptTokens: usage?.prompt_tokens,
completionTokens: usage?.completion_tokens,
totalTokens: usage?.total_tokens,
latencyMs: Date.now() - t0
};
} catch (err: any) {
const latencyMs = Date.now() - t0;
const reason = err?.name === "AbortError" ? "timeout" : "error";

console.error("[LLM] call failed:", { reason, err });

throw new Error(
`LLM call failed (${reason}) after ${latencyMs} ms: ${err?.message ?? "unknown error"}`
);
}
}

You can tune model, temperature, maxTokens, and timeoutMs per-call from your generatePolicy service if needed.

21. Wire it into policyGenerationService.ts

In the earlier pseudo-implementation, we had a stub:

// TODO: replace with actual OpenAI / LLM client
async function callLlm(messages: { role: string; content: string }[]): Promise<string> {
return "## Generated policy placeholder\n\nThis is where the model output will go.";
}

Replace that with your new client:

// src/services/policyGenerationService.ts
import { callLlm as callOpenAiLlm } from "./llmClient";
import type { LlmMessage } from "./llmClient";

…and update the “LLM call” section:

  // 3) Build prompt
const templateWithFilledBody = {
...template,
body: bodyWithFilledPlaceholders
};
const promptContext = buildPolicyPrompt(companyProfile, spec, templateWithFilledBody);

// 4) Call LLM
const llmMessages = promptContext.messages as LlmMessage[];
const llmResult = await callOpenAiLlm(llmMessages, {
model: "gpt-4.1-mini",
temperature: 0.2,
maxTokens: spec.maxWords ? Math.min(4096, spec.maxWords * 2) : 3000
});

const generatedText = llmResult.text;

Then wire usage/latency into your trace:

  const response: GeneratePolicyResponse = {
// ...
trace: {
requestId: spec.requestId,
engineVersion: "policy-gen-1.0.0",
model: llmResult.model,
promptTokens: llmResult.promptTokens,
completionTokens: llmResult.completionTokens,
latencyMs: llmResult.latencyMs
}
};

You can remove the manual t0 timing now, since callLlm already returns latencyMs.

22. Optional: model routing by policy type

You might want to route:

  • Short/simple policies → cheaper model (e.g. gpt-4.1-mini)
  • Complex governance + multi-framework policies → higher quality model (e.g. gpt-4.1)

You can derive that from spec and template.meta.domain:

function pickModelForSpec(domain: string, spec: PolicyGenerationSpec): string {
if (domain === "gov" || domain === "cross") {
return "gpt-4.1"; // more complex reasoning
}
if (spec.detailLevel === "detailed" || spec.strictnessLevel === "high") {
return "gpt-4.1";
}
return "gpt-4.1-mini";
}

Then:

const model = pickModelForSpec(template.meta.domain, spec);
const llmResult = await callOpenAiLlm(llmMessages, { model, temperature: 0.2, maxTokens: ... });

23. Quick checklist to get it running

  1. npm install openai
  2. Set OPENAI_API_KEY in your environment / Codespaces secrets
  3. Add llmClient.ts as above
  4. Replace the stub callLlm in policyGenerationService.ts
  5. Hit POST /api/policies/generate from a simple client / curl with a minimal request

Example minimal request body:

{
"companyId": "acme-123",
"spec": {
"companyId": "acme-123",
"templateId": "tpl_policy_env_climate_change_v1",
"language": "en"
}
}

(You’ll still need to implement fetchCompanyProfile or temporarily hardcode a sample profile.)

GitHub RepoRequest for Change (RFC)