Credit assessment¶
A complete, simulated assessment of an unsecured loan (KTA) from one application file: form, payslip, credit bureau summary (SLIK OJK) and the credit policy. It combines most features of the API in one schema.
The model extracts, code computes, the model explains, code enforces the policy.
flowchart TD
A["Stage 1 · 16 fields extracted in parallel<br/>name, national ID, age, salary, installments, collectibility…"] --> B["Stage 2+ · compute<br/>tenure, new installment, DSR,<br/>installment room, max & offered amount"]
B --> G{"Policy gates (code)<br/>age 21–55 · collectibility < 3"}
G -- reject --> X["rejected + honest letter<br/>(extracted data kept)"]
G -- pass --> C["analysis (model)"] --> D["decision (votes: 3)"] --> E{"switch"}
E --> L1["approval letter"]
E --> L2["counter-offer letter"]
E --> L3["rejection letter"]
Key parts of the schema¶
"net_salary": {"type": "number", "integer": true, "max_digits": 10,
"on": [{"when": {"ne_expr": "base_salary + allowances - deductions", "tol": 1}, "do": "review"}]},
"national_id": {"type": "string", "extract": true,
"on": [{"when": {"not_match": "\\d{16}"}, "do": "review"}, {"do": "drop"}]},
"new_installment": {"type": "compute", "round": 0,
"expr": "requested_amount * (1 + annual_flat_rate / 100 * tenor_months / 12) / tenor_months"},
"total_installment": {"type": "compute", "expr": "mortgage_installment + paylater_installment + new_installment", "round": 0},
"dsr_percent": {"type": "compute", "expr": "total_installment / net_salary * 100", "round": 2},
"installment_room": {"type": "compute", "expr": "0.4 * net_salary - total_installment", "round": 0,
"on": [{"when": {"lt": 0}, "do": "review"}]},
"offered_amount": {"type": "compute", "round": 0,
"expr": "min(requested_amount, round(max_amount / 1000000 - 0.5) * 1000000)"},
"collectibility_gate": {"type": "compute", "expr": "worst_collectibility", "round": 0,
"on": [{"when": {"gte": 3}, "do": "reject"}, {"when": {"eq": 2}, "do": "review"}]},
"decision": {"enum": ["approve", "approve_with_adjustment", "reject"], "votes": 3,
"depends_on": ["analysis"],
"on": [{"when": {"eq": "reject"}, "do": "review"},
{"do": "switch", "cases": {"approve": "approval_letter",
"approve_with_adjustment": "counter_offer_letter"},
"default": "rejection_letter"}]}
Full request body
{
"system": "You are a bank credit analyst. Extract values exactly from the document; write numbers without digit separators. Do not invent data that is not in the document.",
"context": "UNSECURED LOAN APPLICATION (KTA) - No. KTA-2026-09-0147\nApplication date: 27 September 2026\n\nAPPLICANT DATA\nFull name: Dewi Lestari\nNational ID: 3174056708920003\nDate of birth: 17 August 1992 (age 34)\nEmployment status: Permanent employee at PT Sinar Logistik Nusantara since March 2019 (tenure 7 years 6 months)\n\nAPPLICATION\nRequested amount: $60,000,000\nTenor: 36 months\nPurpose: home renovation\n\nAUGUST 2026 PAYSLIP\nBase salary: $11,500,000\nAllowances: $2,000,000\nDeductions (BPJS & income tax): $1,050,000\nNet salary (take home pay): $12,450,000\n\nCREDIT BUREAU REPORT\n1. Bank Mandiri mortgage - installment $2,100,000/month - collectibility 1 (Current)\n2. Kredivo paylater - installment $650,000/month - collectibility 2 (Special Mention), 45 days late in June 2026\n3. BNI credit card - limit $15,000,000, balance $0 - collectibility 1 (Current)\n\nUNSECURED LOAN CREDIT POLICY\n- Applicant age 21 to 55 years.\n- Minimum tenure 24 months.\n- DSR (total monthly installment divided by net salary) at most 40%.\n- Worst collectibility 1: pass; 2: analyst review required; 3 to 5: reject.\n- Flat rate 12% per year.",
"schema": {
"name": {
"type": "string",
"extract": true,
"instruction": "Applicant full name."
},
"national_id": {
"type": "string",
"extract": true,
"instruction": "Applicant national ID.",
"on": [
{
"when": {
"not_match": "\\d{16}"
},
"do": "review"
},
{
"do": "drop"
}
]
},
"age": {
"type": "number",
"integer": true,
"max_digits": 3,
"instruction": "Applicant age in years."
},
"employment_status": {
"enum": [
"permanent_employee",
"contract_employee",
"self_employed",
"other"
],
"instruction": "Applicant employment status."
},
"tenure_years": {
"type": "number",
"integer": true,
"max_digits": 2,
"instruction": "Tenure: the YEARS part only."
},
"tenure_extra_months": {
"type": "number",
"integer": true,
"max_digits": 2,
"instruction": "Tenure: the MONTHS part only (0-11)."
},
"requested_amount": {
"type": "number",
"integer": true,
"max_digits": 10,
"instruction": "Requested loan amount."
},
"tenor_months": {
"type": "number",
"integer": true,
"max_digits": 3,
"instruction": "Tenor in months."
},
"annual_flat_rate": {
"type": "number",
"max_digits": 5,
"instruction": "Flat rate per year in percent (number only)."
},
"base_salary": {
"type": "number",
"integer": true,
"max_digits": 10,
"instruction": "Base salary per month."
},
"allowances": {
"type": "number",
"integer": true,
"max_digits": 10,
"instruction": "Total allowances per month."
},
"deductions": {
"type": "number",
"integer": true,
"max_digits": 10,
"instruction": "Total salary deductions per month."
},
"net_salary": {
"type": "number",
"integer": true,
"max_digits": 10,
"instruction": "Net salary (take home pay) per month.",
"on": [
{
"when": {
"ne_expr": "base_salary + allowances - deductions",
"tol": 1
},
"do": "review"
}
]
},
"mortgage_installment": {
"type": "number",
"integer": true,
"max_digits": 10,
"instruction": "Mortgage installment per month according to the bureau report (0 if none)."
},
"paylater_installment": {
"type": "number",
"integer": true,
"max_digits": 10,
"instruction": "Paylater installment per month according to the bureau report (0 if none)."
},
"worst_collectibility": {
"type": "number",
"integer": true,
"max_digits": 1,
"instruction": "WORST collectibility (largest number 1-5) across all facilities in the bureau report.",
"on": [
{
"when": {
"confidence_lt": 0.9
},
"do": "review"
}
]
},
"age_gate": {
"type": "compute",
"expr": "age",
"on": [
{
"when": {
"lt": 21
},
"do": "reject"
},
{
"when": {
"gt": 55
},
"do": "reject"
}
],
"round": 0
},
"collectibility_gate": {
"type": "compute",
"expr": "worst_collectibility",
"on": [
{
"when": {
"gte": 3
},
"do": "reject"
},
{
"when": {
"eq": 2
},
"do": "review"
}
],
"round": 0
},
"tenure_months": {
"type": "compute",
"expr": "tenure_years * 12 + tenure_extra_months",
"round": 0,
"on": [
{
"when": {
"lt": 24
},
"do": "reject"
}
]
},
"new_installment": {
"type": "compute",
"expr": "requested_amount * (1 + annual_flat_rate / 100 * tenor_months / 12) / tenor_months",
"round": 0
},
"total_installment": {
"type": "compute",
"expr": "mortgage_installment + paylater_installment + new_installment",
"round": 0
},
"dsr_percent": {
"type": "compute",
"expr": "total_installment / net_salary * 100",
"round": 2
},
"installment_room": {
"type": "compute",
"expr": "0.4 * net_salary - total_installment",
"round": 0,
"on": [
{
"when": {
"lt": 0
},
"do": "review"
}
]
},
"max_amount": {
"type": "compute",
"round": 0,
"expr": "(0.4 * net_salary - mortgage_installment - paylater_installment) * tenor_months / (1 + annual_flat_rate / 100 * tenor_months / 12)"
},
"offered_amount": {
"type": "compute",
"expr": "min(requested_amount, round(max_amount / 1000000 - 0.5) * 1000000)",
"round": 0
},
"analysis": {
"type": "string",
"free_form": true,
"depends_on": [
"age",
"employment_status",
"tenure_months",
"worst_collectibility",
"dsr_percent",
"requested_amount",
"offered_amount",
"installment_room"
],
"instruction": "Assess the application against EVERY credit-policy point using Known values, one sentence per point: age, tenure, DSR, collectibility. Remember: collectibility 2 is NOT a reason to reject, it just means proceed with analyst review; DSR above 40% is handled by lowering the amount to offered_amount. Close with a recommendation. At most 110 words. Copy numbers exactly as in Known values.",
"on_reject": "Automatically rejected by policy (see `rejected`)."
},
"decision": {
"enum": [
"approve",
"approve_with_adjustment",
"reject"
],
"votes": 3,
"depends_on": [
"analysis"
],
"instruction": "Final recommendation. approve = DSR at most 40% (installment_room >= 0). approve_with_adjustment = DSR above 40% but offered_amount more than 0. reject = offered_amount 0 or less. Collectibility 2 does not change this choice (analyst review is enough); age, tenure, and collectibility 3-5 are already checked by the system.",
"on_reject": "reject",
"on": [
{
"when": {
"eq": "reject"
},
"do": "review"
},
{
"do": "switch",
"cases": {
"approve": "approval_letter",
"approve_with_adjustment": "counter_offer_letter"
},
"default": "rejection_letter"
}
]
},
"approval_letter": {
"type": "string",
"free_form": true,
"depends_on": [
"decision",
"name",
"requested_amount",
"tenor_months",
"new_installment"
],
"instruction": "Write a short loan approval letter (max 80 words) addressing the applicant by name: amount, tenor, monthly installment. Copy numbers exactly."
},
"counter_offer_letter": {
"type": "string",
"free_form": true,
"depends_on": [
"decision",
"name",
"requested_amount",
"offered_amount",
"tenor_months",
"dsr_percent"
],
"instruction": "Write a short counter-offer letter (max 80 words) addressing the applicant by name: the requested amount cannot be approved because DSR exceeds 40%, offer offered_amount with the same tenor, and mention the application is still awaiting analyst review. Copy numbers exactly."
},
"rejection_letter": {
"type": "string",
"free_form": true,
"depends_on": [
"decision",
"name",
"analysis"
],
"instruction": "Write a short, polite rejection letter (max 60 words) addressing the applicant by name, with the main reason from the analysis.",
"on_reject": "We are sorry, your loan application cannot be approved because it does not meet the applicable credit policy."
}
},
"max_new_tokens": 260
}
Results¶
Tested with trusttyped-v1.0.0:
| Scenario | Outcome |
|---|---|
| As given ($60,000,000, DSR 40.29%) | approve_with_adjustment, offered $59,000,000; review flags for collectibility 2 and negative installment room |
| Amount changed to $40,000,000 | approve, DSR 34.23%, installment $1,511,111 |
| Collectibility 3, or age 58 | Rejected by code in about 5 s; applicant data kept in result |
All 16 extracted values and all computed values matched a manual calculation. A full run takes about 20 s.
Design decisions¶
- No arithmetic by the model. Every number that matters is a
computed field, logged in
tracewith its formula. - Gates in stage 2. A
rejectcancels only fields of its own stage, so policy gates run after extraction and the applicant's data stays in the response. - The decision is defined, not guessed. Without explicit definitions in
the
decisioninstruction, the model choserejectfor collectibility 2, confidently and unanimously: it misapplied the policy, it did not misread the data. Mandatory rejections are done by code, so arejectfrom the model is always flagged for review. - National ID stays out of later prompts (
drop) and is only format-checked.
A recommendation, not a final decision
In regulated lending, an automated result should support an analyst,
not replace one. Use needs_review, flagged and trace to show the
analyst why.
Playground preset: 26.