AI Template Generator
1. What we want the AI to do
For each customer we want to be able to:
- Recommend which policy templates they should start from
- Based on: NACE, size, jurisdictions, risk profile, ESRS scope, etc.
- Pre-fill obvious parts
- Company name, addresses, group structure, locations, facilities, languages…
- Adapt the text to the company’s profile
- Adjust ambition, scope, governance, references to frameworks, etc.
- 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:
- Load the template metadata + MDX body
- Load the companyProfile
- Run any ZAYAZ-side logic (selection, defaults)
- 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:
- 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)
- 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"],
{
"$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
- npm install openai
- Set OPENAI_API_KEY in your environment / Codespaces secrets
- Add llmClient.ts as above
- Replace the stub callLlm in policyGenerationService.ts
- 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.)
⸻