@cenogram/mcp-server 0.1.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,50 @@
1
- import { PROPERTY_TYPES, MARKET_TYPES } from "./mappings.js";
2
- // ── Primitives ──────────────────────────────────────────────────────
1
+ import { PROPERTY_TYPES, MARKET_TYPES, BUILDING_TYPES, OWNERSHIP_TYPES, PARTY_TYPES, LAND_USES } from "./mappings.js";
2
+ const BUILDING_BREAKDOWN_TIP = "Tip: call get_building_breakdown(transaction_id) for per-building detail (footprint, storeys, est. area).";
3
+ const FLOOD_BREAKDOWN_TIP = "Tip: call get_transaction_flood(transaction_id) for the per-parcel flood-zone breakdown (scenario, hazard source, share in zone).";
4
+ const FLOOD_RISK_NOTE = {
5
+ high: "~1-in-10-year",
6
+ medium: "~1-in-100-year",
7
+ low: "~1-in-500-year",
8
+ };
9
+ const HERITAGE_BREAKDOWN_TIP = "Tip: call get_transaction_heritage(transaction_id) for the per-parcel heritage-listing breakdown (entries, category, share in protected area).";
10
+ const HERITAGE_STATUS_NOTE = {
11
+ listed: "protected monument on/at the parcel",
12
+ zone: "within a protected urban layout or monument surroundings",
13
+ };
14
+ const HERITAGE_DISCLAIMER = "Indicative data — the regional heritage conservator makes the final, binding determination.";
15
+ const LANDSLIDE_BREAKDOWN_TIP = "Tip: call get_transaction_landslide(transaction_id) for the per-parcel landslide-zone breakdown (category, share in zone, source-record version date).";
16
+ const LANDSLIDE_RISK_NOTE = {
17
+ landslide: "a mapped landslide area",
18
+ threatened: "an area threatened by mass movements",
19
+ };
20
+ function areaBucketSuffix(bucket) {
21
+ return bucket && bucket !== "all" ? ` (${bucket} m2)` : "";
22
+ }
23
+ function windowSuffix(w) {
24
+ return w.from && w.to ? ` (${w.from} to ${w.to})` : "";
25
+ }
26
+ function offerDateSuffix(date) {
27
+ return date ? ` (as of ${date})` : "";
28
+ }
29
+ function formatPercentileLadder(p) {
30
+ if ([p.p10, p.p25, p.p50, p.p75, p.p90].every((v) => v == null))
31
+ return null;
32
+ const f = (v) => (v == null ? "—" : formatPLN(v));
33
+ return `p10 ${f(p.p10)} · p25 ${f(p.p25)} · p50 ${f(p.p50)} · p75 ${f(p.p75)} · p90 ${f(p.p90)} /m2`;
34
+ }
35
+ function distributionLines(asking, tx) {
36
+ const ask = formatPercentileLadder(asking);
37
+ const txLadder = formatPercentileLadder(tx);
38
+ if (!ask && !txLadder)
39
+ return [];
40
+ const out = ["", "Distribution (price per m2):"];
41
+ if (ask)
42
+ out.push(` Asking: ${ask}`);
43
+ if (txLadder)
44
+ out.push(` Transaction: ${txLadder}`);
45
+ return out;
46
+ }
47
+ export const MARKET_CAVEAT = "Note: median/average prices are market-based — fractional ownership shares and non-market deeds (public tenders, foreclosures, privileged/subsidized sales) are excluded from price aggregates. Transaction counts and coverage stay complete.";
3
48
  export function formatPLN(value) {
4
49
  if (value == null)
5
50
  return "N/A";
@@ -21,34 +66,83 @@ export function formatNumber(value) {
21
66
  return "N/A";
22
67
  return new Intl.NumberFormat("pl-PL").format(value);
23
68
  }
69
+ function formatPLNExact(value) {
70
+ if (value == null)
71
+ return "N/A";
72
+ return new Intl.NumberFormat("pl-PL", {
73
+ style: "currency",
74
+ currency: "PLN",
75
+ maximumFractionDigits: 2,
76
+ }).format(value);
77
+ }
24
78
  function formatTransactionCore(f) {
25
79
  const parts = [];
26
- // Address with optional county/voivodeship
27
- const addr = [f.street, f.building_number].filter(Boolean).join(" ");
80
+ const streetAddr = [f.street, f.building_number].filter(Boolean).join(" ");
28
81
  const district = f.district || f.city;
29
- const region = [f.county_name ? `pow. ${f.county_name}` : null, f.voivodeship_name ? `woj. ${f.voivodeship_name}` : null].filter(Boolean).join(", ");
30
- const loc = [addr, district].filter(Boolean).join(", ");
82
+ const region = [f.county_name ? `county: ${f.county_name}` : null, f.voivodeship_name ? `voivodeship: ${f.voivodeship_name}` : null].filter(Boolean).join(", ");
83
+ const loc = f.street
84
+ ? [streetAddr, district].filter(Boolean).join(", ")
85
+ : [district, f.building_number].filter(Boolean).join(" ");
86
+ const streetApprox = f.street != null && (f.address_source === "approx_high" || f.address_source === "approx_low");
87
+ const approxTag = streetApprox ? " [street approximate — derived, not from deed]" : "";
31
88
  if (loc && region)
32
- parts.push(`${loc} (${region})`);
89
+ parts.push(`${loc} (${region})${approxTag}`);
33
90
  else if (loc)
34
- parts.push(loc);
35
- // Metadata line
91
+ parts.push(`${loc}${approxTag}`);
36
92
  const meta = [];
37
93
  meta.push(`Date: ${f.transaction_date}`);
38
- meta.push(PROPERTY_TYPES[f.property_type] || `Type ${f.property_type}`);
94
+ let typeLabel = PROPERTY_TYPES[f.property_type] || `Type ${f.property_type}`;
95
+ if (f.property_type_reclassed)
96
+ typeLabel += " [shown as a unit — registry recorded land, the deed is a residential unit]";
97
+ else if (f.property_type_inferred)
98
+ typeLabel += " [type inferred from transaction structure — not stated in the registry]";
99
+ meta.push(typeLabel);
39
100
  meta.push(MARKET_TYPES[f.market_type] || `Market ${f.market_type}`);
40
101
  parts.push(meta.join(" | "));
41
- // Price line
42
102
  const price = [];
43
103
  price.push(`Price: ${formatPLN(f.price_gross)}`);
44
- if (f.usable_area_m2 != null)
45
- price.push(`Area: ${formatArea(f.usable_area_m2)}`);
104
+ if (f.usable_area_m2 != null) {
105
+ const areaNote = f.area_basis === "building" ? " [whole garage building, not the parking space]" : "";
106
+ price.push(`Area: ${formatArea(f.usable_area_m2)}${areaNote}`);
107
+ }
46
108
  if (f.price_per_m2 != null)
47
109
  price.push(`Price/m\u00B2: ${formatPLN(f.price_per_m2)}`);
48
- if (f.parcel_area != null && f.usable_area_m2 == null)
49
- price.push(`Parcel: ${formatArea(f.parcel_area)}`);
110
+ if (f.parcel_area != null && f.usable_area_m2 == null) {
111
+ const pNotes = [];
112
+ if (f.parcel_count != null && f.parcel_count >= 2)
113
+ pNotes.push(`sum of ${f.parcel_count} parcels`);
114
+ if (f.area_is_ha_converted)
115
+ pNotes.push("converted from hectares — county reports area in ha");
116
+ const pTag = pNotes.length > 0 ? ` [${pNotes.join("; ")}]` : "";
117
+ price.push(`Parcel: ${formatArea(f.parcel_area)}${pTag}`);
118
+ }
119
+ if (f.share_basis === "fraction")
120
+ price.push("fractional share (excluded from market median)");
50
121
  parts.push(price.join(" | "));
51
- // Extra details
122
+ if (f.building_count != null) {
123
+ const bld = [];
124
+ if (f.footprint_area_m2 != null)
125
+ bld.push(`Building footprint: ${formatArea(f.footprint_area_m2)}`);
126
+ if (f.building_storeys != null)
127
+ bld.push(`Storeys: ${f.building_storeys}`);
128
+ if (f.est_total_area_m2 != null) {
129
+ bld.push(`Est. total floor area: ${formatArea(f.est_total_area_m2)} [estimate: footprint × storeys, not from deed]`);
130
+ }
131
+ if (bld.length > 0)
132
+ parts.push(bld.join(" | "));
133
+ }
134
+ if (f.flood_risk) {
135
+ const note = FLOOD_RISK_NOTE[f.flood_risk];
136
+ parts.push(`Flood risk: ${f.flood_risk}${note ? ` [mapped flood-hazard zone — ${note}]` : ""}`);
137
+ }
138
+ if (f.heritage_status) {
139
+ const note = HERITAGE_STATUS_NOTE[f.heritage_status];
140
+ parts.push(`Heritage listing: ${f.heritage_status}${note ? ` [${note}]` : ""}`);
141
+ }
142
+ if (f.landslide_risk) {
143
+ const note = LANDSLIDE_RISK_NOTE[f.landslide_risk];
144
+ parts.push(`Landslide risk: ${f.landslide_risk}${note ? ` [${note} — parcel intersects a mapped hazard area, 1:10,000-scale maps]` : ""}`);
145
+ }
52
146
  const extra = [];
53
147
  if (f.parcel_number)
54
148
  extra.push(`Plot no: ${f.parcel_number}`);
@@ -56,15 +150,34 @@ function formatTransactionCore(f) {
56
150
  extra.push(`Rooms: ${f.rooms}`);
57
151
  if (f.floor != null)
58
152
  extra.push(`Floor: ${f.floor}`);
153
+ if (f.ownership_type != null)
154
+ extra.push(`Ownership: ${OWNERSHIP_TYPES[f.ownership_type] || `Type ${f.ownership_type}`}`);
155
+ if (f.share_basis === "fraction" && f.ownership_share)
156
+ extra.push(`Share: ${f.ownership_share}`);
157
+ if (f.seller_type != null)
158
+ extra.push(`Seller: ${PARTY_TYPES[f.seller_type] || `Party type ${f.seller_type}`}`);
159
+ if (f.buyer_type != null)
160
+ extra.push(`Buyer: ${PARTY_TYPES[f.buyer_type] || `Party type ${f.buyer_type}`}`);
161
+ if (f.land_use)
162
+ extra.push(`Land use: ${LAND_USES[f.land_use] || f.land_use}`);
163
+ if (f.property_type === 4 && f.unit_price != null && Number(f.unit_price) > 0 && Number(f.unit_price) !== Number(f.price_gross)) {
164
+ extra.push(`Deed unit price (not per-m²): ${formatPLN(Number(f.unit_price))}`);
165
+ }
166
+ if (f.vat != null && f.vat !== "") {
167
+ const vatNum = Number(f.vat);
168
+ if (Number.isFinite(vatNum))
169
+ extra.push(`VAT (as recorded — rate % or amount): ${formatNumber(vatNum)}`);
170
+ }
59
171
  if (f.coordinates) {
60
172
  const [lng, lat] = f.coordinates;
61
173
  extra.push(`Location: ${lat?.toFixed(4)}\u00B0N, ${lng?.toFixed(4)}\u00B0E`);
62
174
  }
175
+ if (f.id)
176
+ extra.push(`id: ${f.id}`);
63
177
  if (extra.length > 0)
64
178
  parts.push(extra.join(" | "));
65
179
  return parts.join("\n ");
66
180
  }
67
- // ── Transaction formatting ──────────────────────────────────────────
68
181
  export function formatTransaction(tx) {
69
182
  return formatTransactionCore({
70
183
  ...tx,
@@ -93,9 +206,20 @@ export function formatTransactionList(res, summary) {
93
206
  if (parts.length > 0)
94
207
  lines.push(`\nSummary: ${parts.join(" | ")}`);
95
208
  }
209
+ if (data.some((tx) => tx.building_count != null)) {
210
+ lines.push(`\n${BUILDING_BREAKDOWN_TIP}`);
211
+ }
212
+ if (data.some((tx) => tx.flood_risk != null)) {
213
+ lines.push(`\n${FLOOD_BREAKDOWN_TIP}`);
214
+ }
215
+ if (data.some((tx) => tx.heritage_status != null)) {
216
+ lines.push(`\n${HERITAGE_BREAKDOWN_TIP}`);
217
+ }
218
+ if (data.some((tx) => tx.landslide_risk != null)) {
219
+ lines.push(`\n${LANDSLIDE_BREAKDOWN_TIP}`);
220
+ }
96
221
  return lines.join("\n");
97
222
  }
98
- // ── Stats formatting ────────────────────────────────────────────────
99
223
  export function formatMarketOverview(stats) {
100
224
  const lines = [];
101
225
  lines.push("Polish Real Estate Transaction Database \u2014 Cenogram.pl\n");
@@ -124,6 +248,7 @@ export function formatMarketOverview(stats) {
124
248
  lines.push(` ${i + 1}. ${d.district} \u2014 ${formatNumber(d.transaction_count)} transactions`);
125
249
  });
126
250
  }
251
+ lines.push(`\n${MARKET_CAVEAT}`);
127
252
  return lines.join("\n");
128
253
  }
129
254
  export function formatPriceStats(rows, location) {
@@ -136,7 +261,6 @@ export function formatPriceStats(rows, location) {
136
261
  ? `Price statistics for "${location}" (residential units only):\n`
137
262
  : "Price statistics by location (residential units only):\n";
138
263
  const lines = [header];
139
- // Sort by median descending
140
264
  const sorted = [...rows].sort((a, b) => b.median_price_m2 - a.median_price_m2);
141
265
  const shown = sorted.slice(0, 30);
142
266
  lines.push("Location | Median PLN/m\u00B2 | Avg PLN/m\u00B2 | Transactions");
@@ -147,6 +271,7 @@ export function formatPriceStats(rows, location) {
147
271
  if (sorted.length > 30) {
148
272
  lines.push(`\n...and ${sorted.length - 30} more locations.`);
149
273
  }
274
+ lines.push(`\n${MARKET_CAVEAT}`);
150
275
  return lines.join("\n");
151
276
  }
152
277
  export function formatHistogram(bins) {
@@ -161,9 +286,9 @@ export function formatHistogram(bins) {
161
286
  : "";
162
287
  lines.push(`${formatPLN(bin.range_min).padStart(15)} - ${formatPLN(bin.range_max).padEnd(15)} | ${bar} ${formatNumber(bin.count)}`);
163
288
  }
289
+ lines.push(`\n${MARKET_CAVEAT}`);
164
290
  return lines.join("\n");
165
291
  }
166
- // ── Parcel search formatting ───────────────────────────────────────
167
292
  export function formatParcelResults(res, query) {
168
293
  if (res.results.length === 0) {
169
294
  return `No parcels found matching "${query}".`;
@@ -172,12 +297,33 @@ export function formatParcelResults(res, query) {
172
297
  for (const [i, p] of res.results.entries()) {
173
298
  const district = p.district ?? "Unknown";
174
299
  const area = p.area_m2 != null ? formatArea(p.area_m2) : "N/A";
175
- lines.push(`${i + 1}. ${p.parcel_id}`);
176
- lines.push(` District: ${district} | Area: ${area} | Location: ${p.lat.toFixed(4)}\u00B0N, ${p.lng.toFixed(4)}\u00B0E`);
300
+ const location = `${p.lat.toFixed(4)}\u00B0N, ${p.lng.toFixed(4)}\u00B0E`;
301
+ lines.push(`${i + 1}. ${p.parcel_id ?? "(parcel number requires a paid plan)"}`);
302
+ lines.push(` District: ${district} | Area: ${area} | Location: ${location}`);
303
+ }
304
+ return lines.join("\n");
305
+ }
306
+ export function formatParcelResolve(res) {
307
+ if (res.coverage === "not_covered" || res.matches.length === 0) {
308
+ return "No parcel matched. The identifier or 'name + number' is not in our cadastral copy (the credit is refunded). Check the spelling of the locality name, or use search_parcels to look up a parcel id by prefix.";
309
+ }
310
+ const lines = [`Found ${res.matches.length} parcel${res.matches.length === 1 ? "" : "s"}:\n`];
311
+ for (const [i, m] of res.matches.entries()) {
312
+ const id = m.parcel_id ?? "(parcel id requires a paid plan)";
313
+ const district = m.district ?? "Unknown";
314
+ const area = m.area_m2 != null ? formatArea(m.area_m2) : "N/A";
315
+ const location = m.centroid ? `${m.centroid.lat.toFixed(4)}°N, ${m.centroid.lng.toFixed(4)}°E` : "no geometry";
316
+ lines.push(`${i + 1}. ${id}`);
317
+ lines.push(` District: ${district} | Area: ${area} | Location: ${location}`);
318
+ }
319
+ if (res.truncated) {
320
+ lines.push(`\nMore matches exist than shown — narrow the locality name or provide the full parcel id.`);
321
+ }
322
+ if (res.as_of) {
323
+ lines.push(`\nCadastral copy as of ${res.as_of.split("T")[0]}.`);
177
324
  }
178
325
  return lines.join("\n");
179
326
  }
180
- // ── Spatial search formatting ──────────────────────────────────────
181
327
  function formatSpatialFeature(f) {
182
328
  return formatTransactionCore({
183
329
  ...f.properties,
@@ -204,9 +350,421 @@ export function formatSpatialResults(res) {
204
350
  if (res.features.length > displayCap) {
205
351
  lines.push(`\n...and ${res.features.length - displayCap} more in response (not displayed). Use a smaller limit or narrower polygon.`);
206
352
  }
353
+ if (res.features.some((f) => f.properties.building_count != null)) {
354
+ lines.push(`\n${BUILDING_BREAKDOWN_TIP}`);
355
+ }
356
+ return lines.join("\n");
357
+ }
358
+ export function formatBuildingBreakdown(res) {
359
+ const { data, truncated } = res;
360
+ if (data.length === 0) {
361
+ return "No per-building data available for this transaction.";
362
+ }
363
+ const lines = [`Per-building breakdown (${data.length} building${data.length === 1 ? "" : "s"}):`, ""];
364
+ data.forEach((b, i) => {
365
+ const cells = [];
366
+ const typeLabel = b.building_type != null
367
+ ? (BUILDING_TYPES[b.building_type] ?? `Type ${b.building_type}`)
368
+ : "Building";
369
+ cells.push(typeLabel);
370
+ if (b.footprint_area_m2 != null) {
371
+ const alt = b.footprint_divergent === true && b.footprint_area_alt_m2 != null
372
+ ? ` (alt. measurement ${formatArea(b.footprint_area_alt_m2)} — diverge)`
373
+ : "";
374
+ cells.push(`footprint ${formatArea(b.footprint_area_m2)}${alt}`);
375
+ }
376
+ if (b.storeys != null)
377
+ cells.push(`storeys ${b.storeys}`);
378
+ if (b.est_total_area_m2 != null) {
379
+ cells.push(`est. total floor area ${formatArea(b.est_total_area_m2)} [estimate: footprint × storeys, not from deed]`);
380
+ }
381
+ if (b.match_confidence)
382
+ cells.push(`match confidence: ${b.match_confidence}`);
383
+ lines.push(`${i + 1}. ${cells.join(" | ")}`);
384
+ });
385
+ if (truncated) {
386
+ lines.push("", "Showing the first 500 buildings (the transaction has more).");
387
+ }
388
+ return lines.join("\n");
389
+ }
390
+ export function formatFloodBreakdown(res) {
391
+ const { data, truncated } = res;
392
+ if (data.length === 0) {
393
+ return "No mapped flood-hazard zone is recorded for this transaction's land (or the id was not found). Absence of a mapped zone is not a guarantee of safety — it is never asserted as 'no risk'.";
394
+ }
395
+ const lines = [
396
+ `Per-parcel flood-zone breakdown (${data.length} parcel${data.length === 1 ? "" : "s"} in a mapped flood-hazard zone):`,
397
+ "",
398
+ ];
399
+ data.forEach((r, i) => {
400
+ const cells = [];
401
+ const note = r.flood_risk ? FLOOD_RISK_NOTE[r.flood_risk] : undefined;
402
+ cells.push(`risk: ${r.flood_risk ?? "—"}${note ? ` (${note})` : ""}`);
403
+ if (r.source)
404
+ cells.push(`source: ${r.source}`);
405
+ if (r.pct_in_zone != null) {
406
+ const pct = Number(r.pct_in_zone);
407
+ if (Number.isFinite(pct))
408
+ cells.push(`${Math.round(pct)}% of the parcel in the worst-scenario zone`);
409
+ }
410
+ if (Array.isArray(r.scenarios) && r.scenarios.length > 0) {
411
+ const labels = r.scenarios.map((s) => s.scenario).filter((s) => !!s);
412
+ if (labels.length > 0)
413
+ cells.push(`scenarios: ${labels.join("; ")}`);
414
+ }
415
+ lines.push(`${i + 1}. ${cells.join(" | ")}`);
416
+ });
417
+ if (truncated) {
418
+ lines.push("", "Showing the first 500 parcels (the transaction is linked to more).");
419
+ }
420
+ return lines.join("\n");
421
+ }
422
+ export function formatHeritageBreakdown(res) {
423
+ const { data, truncated } = res;
424
+ if (data.length === 0) {
425
+ return "No heritage-listing records found for this transaction's parcels (or the id was not found). This is not a statement that the property is free of heritage protection — absence of a detection is never asserted as 'not listed'.";
426
+ }
427
+ const lines = [
428
+ `Per-parcel heritage-listing breakdown (${data.length} parcel${data.length === 1 ? "" : "s"} with a detected listing):`,
429
+ "",
430
+ ];
431
+ data.forEach((r, i) => {
432
+ const cells = [];
433
+ const note = r.heritage_status ? HERITAGE_STATUS_NOTE[r.heritage_status] : undefined;
434
+ cells.push(`status: ${r.heritage_status ?? "—"}${note ? ` (${note})` : ""}`);
435
+ if (r.site_count != null)
436
+ cells.push(`entries: ${r.site_count}`);
437
+ if (r.pct_in_zone != null) {
438
+ const pct = Number(r.pct_in_zone);
439
+ if (Number.isFinite(pct))
440
+ cells.push(`${Math.round(pct)}% of the parcel in the protected area`);
441
+ }
442
+ lines.push(`${i + 1}. ${cells.join(" | ")}`);
443
+ if (Array.isArray(r.sites)) {
444
+ for (const s of r.sites) {
445
+ const detail = [s.category];
446
+ if (s.name)
447
+ detail.push(s.name);
448
+ if (s.function)
449
+ detail.push(`function: ${s.function}`);
450
+ if (s.period)
451
+ detail.push(`period: ${s.period}`);
452
+ if (s.entry_date)
453
+ detail.push(`entered: ${s.entry_date.split("T")[0]}`);
454
+ lines.push(` - ${detail.join(" | ")}`);
455
+ }
456
+ }
457
+ });
458
+ if (truncated) {
459
+ lines.push("", "Showing the first 500 parcels (the transaction is linked to more).");
460
+ }
461
+ lines.push("", HERITAGE_DISCLAIMER);
462
+ return lines.join("\n");
463
+ }
464
+ export function formatLandslideBreakdown(res) {
465
+ const { data, truncated } = res;
466
+ if (data.length === 0) {
467
+ return "No mapped landslide-hazard zone intersects this transaction's parcels (or the id was not found). Absence of mapped data is not a guarantee of safety — it is never asserted as 'no risk'.";
468
+ }
469
+ const lines = [
470
+ `Per-parcel landslide-zone breakdown (${data.length} parcel${data.length === 1 ? "" : "s"} intersecting a mapped landslide-hazard zone):`,
471
+ "",
472
+ ];
473
+ data.forEach((r, i) => {
474
+ const cells = [];
475
+ const note = r.landslide_risk ? LANDSLIDE_RISK_NOTE[r.landslide_risk] : undefined;
476
+ cells.push(`risk: ${r.landslide_risk ?? "—"}${note ? ` (${note})` : ""}`);
477
+ if (r.pct_in_zone != null) {
478
+ const pct = Number(r.pct_in_zone);
479
+ if (Number.isFinite(pct))
480
+ cells.push(`${Math.round(pct)}% of the parcel in mapped zones`);
481
+ }
482
+ if (Array.isArray(r.zones) && r.zones.length > 0) {
483
+ const labels = r.zones
484
+ .map((z) => {
485
+ if (!z.kind)
486
+ return null;
487
+ return z.source_version_date ? `${z.kind} (record version date: ${z.source_version_date})` : z.kind;
488
+ })
489
+ .filter((s) => !!s);
490
+ if (labels.length > 0)
491
+ cells.push(`zones: ${labels.join("; ")}`);
492
+ }
493
+ lines.push(`${i + 1}. ${cells.join(" | ")}`);
494
+ });
495
+ if (truncated) {
496
+ lines.push("", "Showing the first 500 parcels (the transaction is linked to more).");
497
+ }
498
+ lines.push("", "Note: based on official landslide-hazard maps (1:10,000 scale). An intersection means the parcel overlaps a mapped hazard area, not that the parcel itself is a landslide.");
499
+ return lines.join("\n");
500
+ }
501
+ const SURROUNDINGS_CATEGORIES = [
502
+ { key: "cemetery_distance_m", label: "cemetery", radiusLabel: "1 km" },
503
+ { key: "landfill_distance_m", label: "landfill (waste disposal)", radiusLabel: "3 km" },
504
+ { key: "sewage_treatment_distance_m", label: "sewage treatment plant", radiusLabel: "2 km" },
505
+ { key: "industrial_area_distance_m", label: "industrial/storage area", radiusLabel: "1 km" },
506
+ { key: "industrial_plant_distance_m", label: "large industrial plant", radiusLabel: "3 km" },
507
+ { key: "livestock_farm_distance_m", label: "intensive livestock farm", radiusLabel: "3 km" },
508
+ ];
509
+ export function formatSurroundings(res) {
510
+ const { data, truncated } = res;
511
+ if (data.length === 0) {
512
+ return "No surroundings data is available for this transaction (no linked plots, or the id was not found).";
513
+ }
514
+ const lines = [
515
+ `Per-parcel surroundings (${data.length} plot${data.length === 1 ? "" : "s"}; distance from the plot boundary to the nearest mapped object, "~" = approximate):`,
516
+ "",
517
+ ];
518
+ data.forEach((r, i) => {
519
+ if (!r.assessed) {
520
+ lines.push(`${i + 1}. not assessed yet — this plot has not been evaluated (no statement either way)`);
521
+ return;
522
+ }
523
+ const cells = SURROUNDINGS_CATEGORIES.map(({ key, label, radiusLabel }) => {
524
+ const raw = r[key];
525
+ const dist = raw == null ? null : Number(raw);
526
+ if (dist == null || !Number.isFinite(dist)) {
527
+ return `${label}: none within ${radiusLabel}`;
528
+ }
529
+ if (dist === 0)
530
+ return `${label}: on or adjoining the plot`;
531
+ return `${label}: ~${Math.round(dist)} m`;
532
+ });
533
+ lines.push(`${i + 1}. ${cells.join(" | ")}`);
534
+ });
535
+ if (truncated) {
536
+ lines.push("", "Showing the first 500 plots (the transaction is linked to more).");
537
+ }
538
+ return lines.join("\n");
539
+ }
540
+ const TRANSIT_COVERAGE_NOTE = "Note: distances are from open public-transport schedules (GTFS format); coverage is cities and national rail, not every rural area. A mode missing above means no stop of that mode was found within its distance cap — never read as 'no public transport access'.";
541
+ export function formatTransitBreakdown(res) {
542
+ const { data, truncated } = res;
543
+ if (data.length === 0) {
544
+ return `No public transport stop is recorded near this transaction's land in any mode (or the id was not found). ${TRANSIT_COVERAGE_NOTE}`;
545
+ }
546
+ const lines = [
547
+ `Per-parcel public transport access (${data.length} parcel${data.length === 1 ? "" : "s"} with a stop nearby, from open GTFS data):`,
548
+ "",
549
+ ];
550
+ data.forEach((r, i) => {
551
+ const cells = [];
552
+ if (r.rail_distance_m != null)
553
+ cells.push(`Rail: ${r.rail_distance_m} m${r.rail_stop_name ? ` (${r.rail_stop_name})` : ""}`);
554
+ if (r.metro_distance_m != null)
555
+ cells.push(`Metro: ${r.metro_distance_m} m${r.metro_stop_name ? ` (${r.metro_stop_name})` : ""}`);
556
+ if (r.tram_distance_m != null)
557
+ cells.push(`Tram: ${r.tram_distance_m} m${r.tram_stop_name ? ` (${r.tram_stop_name})` : ""}`);
558
+ if (r.bus_distance_m != null)
559
+ cells.push(`Bus: ${r.bus_distance_m} m${r.bus_stop_name ? ` (${r.bus_stop_name})` : ""}`);
560
+ lines.push(`${i + 1}. ${cells.join(" | ")}`);
561
+ });
562
+ if (truncated) {
563
+ lines.push("", "Showing the first 500 parcels (the transaction is linked to more).");
564
+ }
565
+ lines.push("", TRANSIT_COVERAGE_NOTE);
566
+ return lines.join("\n");
567
+ }
568
+ const PERMITS_EMPTY_NOTE = "No positively-resolved building permit or works notification is on record for this transaction's parcels (or the id was not found). The register covers cases resolved since 2016, matched by the parcel's current identifier — an empty list is never a statement that nothing was ever planned.";
569
+ export function formatPermitsBreakdown(res) {
570
+ const { data, truncated } = res;
571
+ if (data.length === 0) {
572
+ return PERMITS_EMPTY_NOTE;
573
+ }
574
+ const lines = [
575
+ `Building permits & notifications on record for this transaction's parcels (${data.length} record${data.length === 1 ? "" : "s"}):`,
576
+ "",
577
+ ];
578
+ data.forEach((r, i) => {
579
+ const cells = [];
580
+ cells.push(r.record_kind);
581
+ if (r.intent_type)
582
+ cells.push(`intent: ${r.intent_type}`);
583
+ if (r.works_type)
584
+ cells.push(`works: ${r.works_type}`);
585
+ if (r.object_category)
586
+ cells.push(`category: ${r.object_category}`);
587
+ if (r.status)
588
+ cells.push(`status: ${r.status}`);
589
+ const date = r.decision_date ?? r.intake_date;
590
+ if (date)
591
+ cells.push(`date: ${date}`);
592
+ if (r.authority)
593
+ cells.push(`authority: ${r.authority}`);
594
+ const addr = [r.address_street, r.address_number].filter(Boolean).join(" ");
595
+ const addrFull = [addr, r.address_city].filter(Boolean).join(", ");
596
+ if (addrFull)
597
+ cells.push(`address: ${addrFull}`);
598
+ if (r.volume_m3 != null && Number.isFinite(Number(r.volume_m3))) {
599
+ cells.push(`volume: ${Math.round(Number(r.volume_m3))} m³`);
600
+ }
601
+ lines.push(`${i + 1}. ${cells.join(" | ")}`);
602
+ });
603
+ if (truncated) {
604
+ lines.push("", "Showing the first 500 records (the transaction's parcels have more).");
605
+ }
606
+ return lines.join("\n");
607
+ }
608
+ const PLANNING_OVERLAY_LABEL = {
609
+ infill_area: "Infill development area (obszar uzupełnienia zabudowy)",
610
+ downtown_area: "Central development area (obszar zabudowy śródmiejskiej)",
611
+ };
612
+ function planningNum(raw) {
613
+ if (raw == null)
614
+ return null;
615
+ const n = Number(raw);
616
+ return Number.isFinite(n) ? n : null;
617
+ }
618
+ function planningParam(raw, unit) {
619
+ const n = planningNum(raw);
620
+ if (n == null)
621
+ return null;
622
+ return `${String(n)}${unit}`;
623
+ }
624
+ export function formatPlanningBreakdown(res) {
625
+ const { data, coverage, truncated } = res;
626
+ if (data.length === 0) {
627
+ if (coverage === "covered_no_data") {
628
+ return "This transaction's municipality has an adopted general plan (plan ogólny), but no planning-zone data covers these parcels in our sources yet.";
629
+ }
630
+ return "No published general plan (plan ogólny) data is available for this transaction's municipality yet — this is NOT a statement that no plan exists. General plans are still being adopted across Poland, so coverage grows over time.";
631
+ }
632
+ const zones = data.filter((r) => r.kind === "zone");
633
+ const overlays = data.filter((r) => r.kind !== "zone");
634
+ const counts = [];
635
+ if (zones.length > 0)
636
+ counts.push(`${zones.length} planning zone${zones.length === 1 ? "" : "s"}`);
637
+ if (overlays.length > 0)
638
+ counts.push(`${overlays.length} overlay area${overlays.length === 1 ? "" : "s"}`);
639
+ const lines = [
640
+ `General plan (plan ogólny) zoning for this transaction's land (${counts.join(", ")}):`,
641
+ "",
642
+ ];
643
+ const byParcel = new Map();
644
+ for (const r of data) {
645
+ const list = byParcel.get(r.parcel_ord);
646
+ if (list)
647
+ list.push(r);
648
+ else
649
+ byParcel.set(r.parcel_ord, [r]);
650
+ }
651
+ const multiParcel = byParcel.size > 1;
652
+ let n = 0;
653
+ for (const [ord, prows] of byParcel) {
654
+ if (multiParcel) {
655
+ if (n > 0)
656
+ lines.push("");
657
+ lines.push(`Parcel ${ord} of ${byParcel.size}:`);
658
+ }
659
+ for (const r of prows.filter((x) => x.kind === "zone")) {
660
+ n += 1;
661
+ const cells = [];
662
+ const label = r.zone_symbol
663
+ ? `${r.zone_symbol}${r.zone_name ? ` — ${r.zone_name}` : ""}`
664
+ : r.zone_name ?? "planning zone";
665
+ cells.push(label);
666
+ const pct = planningNum(r.pct_of_parcel);
667
+ if (pct != null)
668
+ cells.push(`${Math.round(pct)}% of the parcel`);
669
+ const params = [];
670
+ const height = planningParam(r.max_building_height_m, " m");
671
+ if (height)
672
+ params.push(`max building height: ${height}`);
673
+ const intensity = planningParam(r.max_development_intensity, "");
674
+ if (intensity)
675
+ params.push(`max development intensity: ${intensity}`);
676
+ const coveragePct = planningParam(r.max_built_up_coverage_pct, "%");
677
+ if (coveragePct)
678
+ params.push(`max built-up coverage: ${coveragePct}`);
679
+ const bioPct = planningParam(r.min_bio_active_area_pct, "%");
680
+ if (bioPct)
681
+ params.push(`min biologically active area: ${bioPct}`);
682
+ if (params.length > 0)
683
+ cells.push(params.join(", "));
684
+ lines.push(`${n}. ${cells.join(" | ")}`);
685
+ if (r.params_mixed) {
686
+ lines.push(" - note: this symbol merges sub-zones with differing building parameters; only values that agreed across them are shown, the rest are omitted as ambiguous (not 'no limit').");
687
+ }
688
+ }
689
+ for (const r of prows.filter((x) => x.kind !== "zone")) {
690
+ n += 1;
691
+ const label = PLANNING_OVERLAY_LABEL[r.kind] ?? "development overlay area";
692
+ const pct = planningNum(r.pct_of_parcel);
693
+ lines.push(`${n}. ${label} — overlay${pct != null ? `, ${Math.round(pct)}% of the parcel` : ""}`);
694
+ }
695
+ }
696
+ if (multiParcel) {
697
+ lines.push("", "Note: this transaction covers several land parcels. Zones are listed per parcel, so the same symbol may appear under more than one parcel — that is not a duplicate.");
698
+ }
699
+ if (truncated) {
700
+ lines.push("", "Showing the first 500 rows (this transaction's land carries more zones/overlays).");
701
+ }
702
+ lines.push("", "Note: shares are relative to the cadastral parcel geometry; base-zone and overlay shares are independent (overlays may sit on top of zones), so they need not add up to 100%.");
703
+ return lines.join("\n");
704
+ }
705
+ export function formatFarmland(res) {
706
+ const { data, truncated, parcels_total, parcels_with_data, as_of } = res;
707
+ if (data.length === 0) {
708
+ const asOfNote = as_of ? ` (reference data as of ${as_of})` : "";
709
+ return `No eligible agricultural area found for the linked parcels (or the id was not found)${asOfNote}. This is not a statement that the property is non-agricultural — absence of a match is never asserted as "not agricultural".`;
710
+ }
711
+ const lines = [
712
+ `Per-parcel agricultural land-eligibility (${parcels_with_data} of ${parcels_total} linked parcel${parcels_total === 1 ? "" : "s"} with a matched eligible area):`,
713
+ "",
714
+ ];
715
+ data.forEach((r, i) => {
716
+ const cells = [];
717
+ cells.push(`eligible agricultural area: ${formatArea(r.eligible_area_m2)}`);
718
+ if (r.pct_of_parcel != null) {
719
+ const pct = Number(r.pct_of_parcel);
720
+ if (Number.isFinite(pct))
721
+ cells.push(`${Math.round(pct)}% of the parcel`);
722
+ }
723
+ if (r.feature_count != null && Number(r.feature_count) > 1) {
724
+ cells.push(`${Number(r.feature_count)} features`);
725
+ }
726
+ lines.push(`${i + 1}. ${cells.join(" | ")}`);
727
+ });
728
+ if (truncated) {
729
+ lines.push("", "Showing the first 500 parcels (the transaction is linked to more).");
730
+ }
731
+ if (as_of) {
732
+ lines.push("", `Official nationwide agricultural land-eligibility data (updated weekly); this snapshot as of ${as_of}.`);
733
+ }
734
+ return lines.join("\n");
735
+ }
736
+ const LEVEL_TIPS = {
737
+ voivodeship: "Use a 2-digit code as 'parent' to browse counties.",
738
+ county: "Use a 4-digit code as 'parent' to browse municipalities.",
739
+ municipality: "Use a 6-digit code as 'parent' to browse precincts, or use any code with 'teryt' in search_transactions.",
740
+ precinct: "Use these precinct codes with 'teryt' in search_transactions for precise area filtering.",
741
+ };
742
+ export function formatLocationHierarchy(items, parent) {
743
+ if (items.length === 0) {
744
+ if (parent) {
745
+ if (parent.length >= 6) {
746
+ return `No sub-locations found for TERYT code '${parent}'. This may be a leaf code - use it directly with search_transactions(teryt='${parent}').`;
747
+ }
748
+ return `No sub-locations found for TERYT code '${parent}'. Verify the code is correct using list_locations.`;
749
+ }
750
+ return "No locations available.";
751
+ }
752
+ const level = items[0].level;
753
+ const header = parent
754
+ ? `TERYT location hierarchy (parent: ${parent}, level: ${level}):`
755
+ : `TERYT location hierarchy (Poland, level: ${level}):`;
756
+ const plural = { voivodeship: "voivodeships", county: "counties", municipality: "municipalities", precinct: "precincts" };
757
+ const lines = [header, "", `Found ${items.length} ${plural[level] ?? `${level}s`}:`, ""];
758
+ for (const item of items) {
759
+ const typeSuffix = item.typeName ? ` (${item.typeName})` : "";
760
+ lines.push(` ${item.code} - ${item.name}${typeSuffix}`);
761
+ }
762
+ const tip = LEVEL_TIPS[level];
763
+ if (tip) {
764
+ lines.push("", `Tip: ${tip}`);
765
+ }
207
766
  return lines.join("\n");
208
767
  }
209
- // ── Compare locations formatting ───────────────────────────────────
210
768
  export function formatCompareResults(res) {
211
769
  const districts = Object.keys(res);
212
770
  if (districts.length === 0) {
@@ -233,5 +791,556 @@ export function formatCompareResults(res) {
233
791
  lines.push(`Note: ${s}`);
234
792
  }
235
793
  }
794
+ const withDemo = districts.filter((name) => {
795
+ const d = res[name]?.demographics;
796
+ return d && Object.keys(d).length > 0;
797
+ });
798
+ if (withDemo.length > 0) {
799
+ lines.push("", "Demographics (GUS BDL, county-level):");
800
+ for (const name of withDemo) {
801
+ lines.push("", `${name}:`);
802
+ for (const [slug, ind] of Object.entries(res[name].demographics)) {
803
+ const unit = ind.unit ? ` ${ind.unit}` : "";
804
+ const year = ind.year != null ? ` (${ind.year})` : "";
805
+ const flags = [ind.derived ? "derived" : null, ind.cross_source ? "cross-source" : null].filter(Boolean);
806
+ const flagSuffix = flags.length > 0 ? ` [${flags.join(", ")}]` : "";
807
+ const value = ind.value != null ? `${formatNumber(ind.value)}${unit}` : "N/A";
808
+ lines.push(` - ${slug}: ${value}${year}${flagSuffix}`);
809
+ }
810
+ }
811
+ const missing = districts.filter((name) => !withDemo.includes(name));
812
+ if (missing.length > 0) {
813
+ lines.push("", `Note: no demographic data for ${missing.join(", ")} (not resolved to a county).`);
814
+ }
815
+ }
816
+ lines.push(`\n${MARKET_CAVEAT}`);
817
+ return lines.join("\n");
818
+ }
819
+ const DEMOGRAPHICS_CATEGORY_LABELS = {
820
+ demographics: "Demographics",
821
+ economy: "Economy",
822
+ economy_macro: "Macro-economy (GDP, NUTS3/region)",
823
+ housing: "Housing",
824
+ planning: "Spatial planning (MPZP zoning)",
825
+ infrastructure: "Infrastructure",
826
+ environment: "Environment",
827
+ safety: "Safety",
828
+ re_market: "Real estate market (historical)",
829
+ education: "Education",
830
+ prices: "Prices (CPI)",
831
+ };
832
+ const DEMOGRAPHICS_CATEGORY_ORDER = Object.keys(DEMOGRAPHICS_CATEGORY_LABELS);
833
+ function formatDemographicsIndicator(ind) {
834
+ const years = Object.keys(ind.values).map(Number).filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
835
+ const unit = ind.unit ? ` ${ind.unit}` : "";
836
+ const flags = [ind.derived ? "derived" : null, ind.snapshot ? "snapshot" : null].filter(Boolean);
837
+ const flagSuffix = flags.length > 0 ? ` [${flags.join(", ")}]` : "";
838
+ const noteSuffix = ind.note ? ` — ${ind.note}` : "";
839
+ let valueStr;
840
+ if (years.length === 0) {
841
+ valueStr = "N/A";
842
+ }
843
+ else if (years.length === 1) {
844
+ const y = years[0];
845
+ valueStr = `${formatNumber(ind.values[String(y)])}${unit} (${y})`;
846
+ }
847
+ else if (years.length <= 5) {
848
+ valueStr = years.map((y) => `${y}: ${formatNumber(ind.values[String(y)])}`).join(", ") + unit;
849
+ }
850
+ else {
851
+ const first = years[0];
852
+ const last = years[years.length - 1];
853
+ valueStr = `${formatNumber(ind.values[String(last)])}${unit} (${last}); ${years.length} yrs ${first}→${last}, from ${formatNumber(ind.values[String(first)])}`;
854
+ }
855
+ return ` - ${ind.name}: ${valueStr}${flagSuffix}${noteSuffix}`;
856
+ }
857
+ export function formatDemographics(r) {
858
+ const loc = r.location;
859
+ const title = loc.name ?? `TERYT ${loc.teryt}`;
860
+ const lines = [`Demographics & local statistics — ${title} (${loc.level}, teryt ${loc.teryt})`];
861
+ const asOf = r.meta.as_of ? ` · as of ${r.meta.as_of}` : "";
862
+ lines.push(`Source: ${r.meta.data_source}${asOf}`);
863
+ if (r.coverage === "no_data" || Object.keys(r.indicators).length === 0) {
864
+ lines.push("", `No GUS BDL indicators are available for this location (teryt ${loc.teryt}).`, "Tip: a city/county name resolves to powiat (county) level — pass a 6/7-digit teryt for gmina-level data, or use list_locations to find a valid code.");
865
+ return lines.join("\n");
866
+ }
867
+ const byCategory = new Map();
868
+ for (const ind of Object.values(r.indicators)) {
869
+ const arr = byCategory.get(ind.category) ?? [];
870
+ arr.push(ind);
871
+ byCategory.set(ind.category, arr);
872
+ }
873
+ const orderedCats = [
874
+ ...DEMOGRAPHICS_CATEGORY_ORDER.filter((c) => byCategory.has(c)),
875
+ ...[...byCategory.keys()].filter((c) => !DEMOGRAPHICS_CATEGORY_ORDER.includes(c)),
876
+ ];
877
+ for (const cat of orderedCats) {
878
+ lines.push("", DEMOGRAPHICS_CATEGORY_LABELS[cat] ?? cat);
879
+ for (const ind of byCategory.get(cat))
880
+ lines.push(formatDemographicsIndicator(ind));
881
+ }
882
+ const levels = [...new Set(Object.values(r.indicators).map((i) => i.level))];
883
+ if (levels.length > 1) {
884
+ lines.push("", `Note: indicators draw from multiple administrative levels (${levels.join(", ")}); each line's level is where GUS publishes that metric.`);
885
+ }
886
+ return lines.join("\n");
887
+ }
888
+ const INFRA_CATEGORY_LABELS = {
889
+ sewerage: "Sewerage",
890
+ water_supply: "Water supply",
891
+ roads: "Roads",
892
+ lighting: "Street lighting",
893
+ gas: "Gas network",
894
+ cycling: "Cycling infrastructure",
895
+ };
896
+ const INFRA_VALUE_KIND_LABELS = {
897
+ estimated: "estimated value",
898
+ winning_bid: "winning bid",
899
+ contract: "contract value",
900
+ };
901
+ export function formatInfrastructureSignals(r) {
902
+ const loc = r.location;
903
+ const title = loc.name ?? `TERYT ${loc.teryt}`;
904
+ const scope = loc.level === "powiat" ? "aggregated over every municipality in this county" : "this municipality";
905
+ const lines = [
906
+ `Infrastructure signals — ${title} (${loc.level}, teryt ${loc.teryt})`,
907
+ `Scope: ${scope}. Coverage: ${r.coverage}.`,
908
+ ];
909
+ if (r.coverage === "no_data") {
910
+ lines.push("", "No infrastructure signals are recorded for this location.", "This does NOT mean the municipality is not investing — the tender feed carries below-EU-threshold contracts only (from 2021), and the other two sources may simply not list it.", r.meta.coverage_note);
911
+ return lines.join("\n");
912
+ }
913
+ const cats = Object.entries(r.tenders.by_category).sort((a, b) => b[1] - a[1]);
914
+ lines.push("", `Public tenders, last ${r.tenders.window_months} months (municipal contracting authorities only)`);
915
+ if (cats.length === 0)
916
+ lines.push(" None recorded in this window.");
917
+ else
918
+ for (const [cat, n] of cats)
919
+ lines.push(` - ${INFRA_CATEGORY_LABELS[cat] ?? cat}: ${n}`);
920
+ if (r.tenders.recent.length > 0) {
921
+ lines.push("", "Recent notices (all contracting authorities)");
922
+ for (const t of r.tenders.recent) {
923
+ const value = t.value_pln == null
924
+ ? ""
925
+ : ` · ${formatNumber(t.value_pln)} PLN (${INFRA_VALUE_KIND_LABELS[t.value_kind ?? ""] ?? t.value_kind ?? "value"})`;
926
+ const attribution = t.attribution_confidence === "high" ? "" : " · authority based here, works may be elsewhere";
927
+ lines.push(` - [${t.published_at}] ${INFRA_CATEGORY_LABELS[t.category] ?? t.category}: ${t.title}${value}${attribution}`);
928
+ }
929
+ if (r.tenders.truncated)
930
+ lines.push(` … list truncated at ${r.tenders.recent.length} notices.`);
931
+ }
932
+ lines.push("", "National urban waste-water treatment programme");
933
+ if (r.kposk.in_agglomeration) {
934
+ lines.push(" In a designated agglomeration — collective sewerage exists or is planned here.");
935
+ for (const a of r.kposk.agglomerations) {
936
+ const rlm = a.rlm == null ? "" : ` (${formatNumber(a.rlm)} population equivalent)`;
937
+ lines.push(` - ${a.name}${rlm}`);
938
+ }
939
+ if (r.kposk.truncated)
940
+ lines.push(` … list truncated at ${r.kposk.agglomerations.length} agglomerations.`);
941
+ }
942
+ else {
943
+ lines.push(" Not listed in a designated agglomeration.");
944
+ }
945
+ const years = Object.entries(r.capex.by_year).sort(([a], [b]) => a.localeCompare(b));
946
+ lines.push("", "Planned capital expenditure (municipal multi-year financial forecast)");
947
+ if (years.length === 0)
948
+ lines.push(" No forecast rows recorded for this location.");
949
+ for (const [year, c] of years) {
950
+ const across = c.gmina_count > 1 ? ` · summed across ${c.gmina_count} municipalities` : "";
951
+ const adopted = c.resolution_date ? ` · adopted ${c.resolution_date}` : "";
952
+ lines.push(` - ${year}: ${formatNumber(c.value_pln)} PLN${across}${adopted}`);
953
+ }
954
+ lines.push("", r.meta.coverage_note);
955
+ if (r.meta.as_of)
956
+ lines.push(`Most recent tender notice: ${r.meta.as_of}.`);
957
+ return lines.join("\n");
958
+ }
959
+ const RENTAL_YIELD_LOCATIONS_PATH = "rental-yield/locations";
960
+ export function formatRentalYield(r) {
961
+ const { rent, transaction: tx } = r.inputs;
962
+ const q = r.quality;
963
+ const lines = [`Gross rental yield — ${r.location.name}${areaBucketSuffix(r.segment.area_bucket)}`, ""];
964
+ lines.push(r.result.gross_yield_pct != null
965
+ ? `Gross yield: ${r.result.gross_yield_pct}% per year`
966
+ : `Gross yield: N/A (coverage: ${q.coverage})`);
967
+ lines.push("");
968
+ lines.push("Calculation (gross, top-line — no vacancy/management/tax/maintenance):");
969
+ lines.push(rent.median_monthly_asking_per_m2 != null && rent.annualized_per_m2 != null
970
+ ? ` Annualized rent: ${formatPLNExact(rent.median_monthly_asking_per_m2)}/m²/mo × 12 = ${formatPLN(rent.annualized_per_m2)}/m²/yr`
971
+ : " Annualized rent: N/A");
972
+ lines.push(tx.median_price_per_m2 != null
973
+ ? ` Median transaction price: ${formatPLN(tx.median_price_per_m2)}/m² (${r.segment.market_type} market)`
974
+ : ` Median transaction price: N/A (${r.segment.market_type} market)`);
975
+ lines.push(" (market median — fractional shares & non-market deeds excluded)");
976
+ lines.push("");
977
+ const rentN = rent.sample_n != null
978
+ ? `${formatNumber(rent.sample_n)} rent offer${rent.sample_n === 1 ? "" : "s"}${offerDateSuffix(rent.snapshot_date)}`
979
+ : "no rent data";
980
+ const txN = tx.sample_n != null
981
+ ? `${formatNumber(tx.sample_n)} transaction${tx.sample_n === 1 ? "" : "s"}${windowSuffix(tx.window)}`
982
+ : "no transaction data";
983
+ lines.push(`Samples: ${rentN}, ${txN}`);
984
+ lines.push(`Coverage: ${q.coverage} | Confidence: ${q.confidence}${q.stale ? " | transaction data lags publication" : ""}`);
985
+ if (q.as_of)
986
+ lines.push(`Transaction data as of: ${q.as_of}`);
987
+ lines.push(...distributionLines(r.distribution.asking_rent_monthly_per_m2, r.distribution.transaction_price_per_m2));
988
+ const visibleNotes = q.notes.filter((n) => !n.includes(RENTAL_YIELD_LOCATIONS_PATH));
989
+ if (visibleNotes.length > 0) {
990
+ lines.push("", "Notes:");
991
+ for (const n of visibleNotes)
992
+ lines.push(` - ${n}`);
993
+ }
994
+ if (q.coverage === "no_rental_data") {
995
+ lines.push("", "Tip: call list_rental_yield_locations to see which cities have rental-yield coverage.");
996
+ }
997
+ return lines.join("\n");
998
+ }
999
+ export function formatRentalYieldLocations(r) {
1000
+ const { data, meta } = r;
1001
+ if (data.length === 0) {
1002
+ return "No rental-yield-covered locations match.";
1003
+ }
1004
+ const dateSuffix = meta.snapshot_date ? `, data from ${meta.snapshot_date}` : "";
1005
+ const lines = [
1006
+ `Rental-yield coverage — ${meta.total} location${meta.total === 1 ? "" : "s"}${dateSuffix}`,
1007
+ "",
1008
+ ];
1009
+ for (const loc of data) {
1010
+ lines.push(`- ${loc.location} (teryt ${loc.county_code}, ${loc.voivodeship}, ${loc.type}) — n=${formatNumber(loc.rent_sample_n)}, ${loc.confidence} confidence`);
1011
+ }
1012
+ return lines.join("\n");
1013
+ }
1014
+ const PRICE_SPREAD_LOCATIONS_PATH = "price-spread/locations";
1015
+ export function formatPriceSpread(r) {
1016
+ const { asking, transaction: tx } = r.inputs;
1017
+ const q = r.quality;
1018
+ const spread = r.result.spread_pct;
1019
+ const lines = [`Asking-vs-transaction price spread — ${r.location.name}${areaBucketSuffix(r.segment.area_bucket)}`, ""];
1020
+ lines.push(spread != null
1021
+ ? `Spread: ${spread > 0 ? "+" : ""}${spread}% (asking ${spread >= 0 ? "above" : "below"} transaction)`
1022
+ : `Spread: N/A (coverage: ${q.coverage})`);
1023
+ lines.push("");
1024
+ lines.push("Calculation ((asking − transaction) / transaction × 100):");
1025
+ lines.push(asking.median_price_per_m2 != null
1026
+ ? ` Median asking price: ${formatPLN(asking.median_price_per_m2)}/m² (apartments for sale)`
1027
+ : " Median asking price: N/A");
1028
+ lines.push(tx.median_price_per_m2 != null
1029
+ ? ` Median transaction price: ${formatPLN(tx.median_price_per_m2)}/m² (${r.segment.market_type} market)`
1030
+ : ` Median transaction price: N/A (${r.segment.market_type} market)`);
1031
+ lines.push(" (market median — fractional shares & non-market deeds excluded)");
1032
+ lines.push("");
1033
+ const askN = asking.sample_n != null
1034
+ ? `${formatNumber(asking.sample_n)} sale offer${asking.sample_n === 1 ? "" : "s"}${offerDateSuffix(asking.snapshot_date)}`
1035
+ : "no asking data";
1036
+ const txN = tx.sample_n != null
1037
+ ? `${formatNumber(tx.sample_n)} transaction${tx.sample_n === 1 ? "" : "s"}${windowSuffix(tx.window)}`
1038
+ : "no transaction data";
1039
+ lines.push(`Samples: ${askN}, ${txN}`);
1040
+ lines.push(`Coverage: ${q.coverage} | Confidence: ${q.confidence}${q.stale ? " | transaction data lags publication" : ""}`);
1041
+ if (q.as_of)
1042
+ lines.push(`Transaction data as of: ${q.as_of}`);
1043
+ lines.push(...distributionLines(r.distribution.asking_sale_per_m2, r.distribution.transaction_price_per_m2));
1044
+ const visibleNotes = q.notes.filter((n) => !n.includes(PRICE_SPREAD_LOCATIONS_PATH));
1045
+ if (visibleNotes.length > 0) {
1046
+ lines.push("", "Notes:");
1047
+ for (const n of visibleNotes)
1048
+ lines.push(` - ${n}`);
1049
+ }
1050
+ if (q.coverage === "no_asking_data") {
1051
+ lines.push("", "Tip: call list_price_spread_locations to see which cities have asking-price coverage.");
1052
+ }
1053
+ return lines.join("\n");
1054
+ }
1055
+ export function formatPriceSpreadLocations(r) {
1056
+ const { data, meta } = r;
1057
+ if (data.length === 0) {
1058
+ return "No price-spread-covered locations match.";
1059
+ }
1060
+ const dateSuffix = meta.snapshot_date ? `, data from ${meta.snapshot_date}` : "";
1061
+ const lines = [
1062
+ `Price-spread coverage — ${meta.total} location${meta.total === 1 ? "" : "s"}${dateSuffix}`,
1063
+ "",
1064
+ ];
1065
+ for (const loc of data) {
1066
+ lines.push(`- ${loc.location} (teryt ${loc.county_code}, ${loc.voivodeship}, ${loc.type}) — n=${formatNumber(loc.asking_sample_n)}, ${loc.confidence} confidence`);
1067
+ }
1068
+ return lines.join("\n");
1069
+ }
1070
+ function valuationCompLine(c) {
1071
+ const parts = [`${formatNumber(c.distance_m)} m`, c.transaction_date, formatArea(c.area_m2), `${formatPLN(c.price_per_m2)}/m²`];
1072
+ if (c.market_type)
1073
+ parts.push(c.market_type);
1074
+ if (c.district)
1075
+ parts.push(c.district);
1076
+ return ` - ${parts.join(" · ")}`;
1077
+ }
1078
+ export function formatValuation(r) {
1079
+ const { result: res, inputs, quality: q, segment } = r ?? {};
1080
+ const loc = r?.location;
1081
+ if (!res || !inputs || !q || !segment || !loc) {
1082
+ return "Unexpected response from the Cenogram API — the valuation could not be rendered. Try again shortly.";
1083
+ }
1084
+ const where = loc.lat != null && loc.lng != null
1085
+ ? `near ${loc.lat}, ${loc.lng}`
1086
+ : loc.county_code
1087
+ ? `county ${loc.county_code}`
1088
+ : "the requested point";
1089
+ const lines = [`Apartment value estimate — ${formatArea(segment.area_m2)} ${where}`, ""];
1090
+ if (res.estimated_value == null) {
1091
+ const parcelUnresolved = q.coverage !== "not_covered" && loc.lat == null && loc.lng == null;
1092
+ lines.push(q.coverage === "not_covered"
1093
+ ? "No estimate: outside the covered property type (v1 covers apartments only)."
1094
+ : parcelUnresolved
1095
+ ? "No estimate: that parcel could not be resolved (unknown id, or no geometry on record). The credit is refunded — check the id, or address the apartment by lat/lng."
1096
+ : "No estimate: too few comparable transactions near this point (credit refunded). Try a point in a denser urban area.");
1097
+ if (q.note)
1098
+ lines.push("", q.note);
1099
+ return lines.join("\n");
1100
+ }
1101
+ lines.push(`Estimated value: ${formatPLN(res.estimated_value)}${res.price_per_m2 != null ? ` (${formatPLN(res.price_per_m2)}/m²)` : ""}`);
1102
+ const likely = res.value_range_likely;
1103
+ const wide = res.value_range_wide;
1104
+ if (likely?.low != null && likely.high != null)
1105
+ lines.push(`Likely range: ${formatPLN(likely.low)} – ${formatPLN(likely.high)}`);
1106
+ if (wide?.low != null && wide.high != null)
1107
+ lines.push(`Wide range: ${formatPLN(wide.low)} – ${formatPLN(wide.high)}`);
1108
+ if (res.confidence_band)
1109
+ lines.push(`Confidence: ${res.confidence_band}${res.confidence != null ? ` (${res.confidence})` : ""}`);
1110
+ lines.push("");
1111
+ const radius = inputs.radius_m != null ? ` within ${formatNumber(inputs.radius_m)} m` : "";
1112
+ lines.push(`Based on ${formatNumber(inputs.comps_total)} comparable transaction${inputs.comps_total === 1 ? "" : "s"}${radius}, last ${inputs.window_months} months.`);
1113
+ if (q.as_of)
1114
+ lines.push(`Transaction data as of: ${q.as_of} (varies by county — publication lag)`);
1115
+ if (Array.isArray(inputs.comparables) && inputs.comparables.length > 0) {
1116
+ const shown = inputs.comparables.slice(0, 5);
1117
+ lines.push("", `Comparables (nearest ${shown.length}):`);
1118
+ for (const c of shown)
1119
+ lines.push(valuationCompLine(c));
1120
+ }
1121
+ if (q.note)
1122
+ lines.push("", q.note);
1123
+ return lines.join("\n");
1124
+ }
1125
+ function fourStateGloss(coverage) {
1126
+ switch (coverage) {
1127
+ case "covered": return "covered";
1128
+ case "covered_no_data": return "covered_no_data (checked — nothing found, still billed)";
1129
+ case "not_covered": return "not_covered (outside our data — refunded)";
1130
+ case "not_computed": return "not_computed (could not finish in time — refunded, retry)";
1131
+ default: return coverage;
1132
+ }
1133
+ }
1134
+ function toNum(v) {
1135
+ if (v == null)
1136
+ return null;
1137
+ const n = typeof v === "number" ? v : Number(v);
1138
+ return Number.isFinite(n) ? n : null;
1139
+ }
1140
+ function nearestDistances(entries) {
1141
+ return entries
1142
+ .filter((d) => d[1] != null)
1143
+ .sort((a, b) => a[1] - b[1]);
1144
+ }
1145
+ function reportSectionDetail(name, s) {
1146
+ const covered = s.coverage === "covered";
1147
+ switch (name) {
1148
+ case "flood": {
1149
+ if (!covered)
1150
+ return "";
1151
+ const risk = typeof s.flood_risk === "string" ? s.flood_risk : null;
1152
+ const pct = toNum(s.pct_in_zone);
1153
+ return risk ? `${risk} risk${pct != null ? `, ${pct}% of the parcel in the mapped zone` : ""}` : "";
1154
+ }
1155
+ case "heritage": {
1156
+ if (!covered)
1157
+ return "";
1158
+ const status = typeof s.heritage_status === "string" ? s.heritage_status : null;
1159
+ const sites = toNum(s.site_count);
1160
+ return status ? `${status}${sites != null ? `, ${sites} listing(s)` : ""}` : "";
1161
+ }
1162
+ case "landslide": {
1163
+ if (!covered)
1164
+ return "";
1165
+ const risk = typeof s.landslide_risk === "string" ? s.landslide_risk : null;
1166
+ return risk ? LANDSLIDE_RISK_NOTE[risk] ?? risk : "";
1167
+ }
1168
+ case "surroundings": {
1169
+ if (!covered)
1170
+ return "";
1171
+ const dists = nearestDistances([
1172
+ ["cemetery", toNum(s.cemetery_distance_m)],
1173
+ ["landfill", toNum(s.landfill_distance_m)],
1174
+ ["sewage treatment", toNum(s.sewage_treatment_distance_m)],
1175
+ ["industrial area", toNum(s.industrial_area_distance_m)],
1176
+ ["industrial plant", toNum(s.industrial_plant_distance_m)],
1177
+ ["livestock farm", toNum(s.livestock_farm_distance_m)],
1178
+ ]);
1179
+ if (dists.length === 0)
1180
+ return "no mapped nuisance object within range";
1181
+ return dists.slice(0, 3).map(([k, m]) => `${k} ${Math.round(m)} m`).join(", ");
1182
+ }
1183
+ case "transit": {
1184
+ if (!covered)
1185
+ return "";
1186
+ const modes = nearestDistances([
1187
+ ["rail", toNum(s.rail_distance_m)],
1188
+ ["metro", toNum(s.metro_distance_m)],
1189
+ ["tram", toNum(s.tram_distance_m)],
1190
+ ["bus", toNum(s.bus_distance_m)],
1191
+ ]);
1192
+ if (modes.length === 0)
1193
+ return "";
1194
+ return modes.map(([k, m]) => `${k} ${Math.round(m)} m`).join(", ");
1195
+ }
1196
+ case "planning": {
1197
+ if (!covered)
1198
+ return "";
1199
+ const rows = Array.isArray(s.data) ? s.data : [];
1200
+ const symbols = [...new Set(rows.map((r) => r.zone_symbol).filter((x) => typeof x === "string"))];
1201
+ return symbols.length > 0 ? `zones: ${symbols.join(", ")}` : `${rows.length} zone row(s)`;
1202
+ }
1203
+ case "buildings": {
1204
+ if (!covered)
1205
+ return "";
1206
+ const rows = Array.isArray(s.data) ? s.data : [];
1207
+ return `${rows.length} building(s) on the parcel`;
1208
+ }
1209
+ case "permits": {
1210
+ if (!covered)
1211
+ return "";
1212
+ const rows = Array.isArray(s.data) ? s.data : [];
1213
+ return `${rows.length} registered case(s)`;
1214
+ }
1215
+ case "farmland": {
1216
+ if (!covered)
1217
+ return "";
1218
+ const area = toNum(s.eligible_area_m2);
1219
+ const pct = toNum(s.pct_of_parcel);
1220
+ return area != null ? `${formatArea(area)} eligible${pct != null ? ` (${pct}% of parcel)` : ""}` : "";
1221
+ }
1222
+ default:
1223
+ return "";
1224
+ }
1225
+ }
1226
+ function reportTxLine(r) {
1227
+ const date = typeof r.transaction_date === "string" ? r.transaction_date.split("T")[0] : "?";
1228
+ const type = PROPERTY_TYPES[Number(r.property_type)] || `Type ${r.property_type}`;
1229
+ const market = MARKET_TYPES[Number(r.market_type)] || `Market ${r.market_type}`;
1230
+ const price = formatPLN(toNum(r.price_gross));
1231
+ const area = toNum(r.usable_area_m2);
1232
+ const ppm2 = toNum(r.price_per_m2);
1233
+ const tail = [area != null ? formatArea(area) : null, ppm2 != null ? `${formatPLN(ppm2)}/m2` : null].filter(Boolean).join(", ");
1234
+ return ` - ${date} — ${type}, ${market} — ${price}${tail ? ` (${tail})` : ""}`;
1235
+ }
1236
+ function reportMarketLine(label, lvl) {
1237
+ if (lvl.coverage === "no_data")
1238
+ return ` - ${label}: no data`;
1239
+ if (lvl.median_price_per_m2 == null)
1240
+ return ` - ${label}: withheld (only ${lvl.n} sale(s) — too few to publish)`;
1241
+ const flag = lvl.coverage === "low_sample" ? " [small sample]" : "";
1242
+ return ` - ${label}: ${formatPLN(lvl.median_price_per_m2)}/m2 (n=${lvl.n})${flag}`;
1243
+ }
1244
+ function reportBillingFooter(billing) {
1245
+ const why = {
1246
+ full: "billed in full — at least one enrichment layer had data",
1247
+ core_floor: "resolved, but no enrichment layer had data — only the parcel-core floor is billed, the rest refunded",
1248
+ total_miss_refund: "fully refunded — the parcel could not be resolved",
1249
+ not_computed_refund: "fully refunded — no layer could be computed right now (retry-worthy)",
1250
+ disabled: "fully refunded — the composite report is temporarily unavailable",
1251
+ demo: "no charge (demo / web session)",
1252
+ };
1253
+ const reason = why[billing.rule] ?? billing.rule;
1254
+ return `Billing: ${billing.charged} charged, ${billing.refunded} refunded — ${reason}`;
1255
+ }
1256
+ const REPORT_LAYER_ORDER = [
1257
+ ["flood", "Flood risk"],
1258
+ ["heritage", "Heritage listing"],
1259
+ ["landslide", "Landslide risk"],
1260
+ ["surroundings", "Nuisance surroundings"],
1261
+ ["transit", "Public transport"],
1262
+ ["planning", "Planning (general plan)"],
1263
+ ["buildings", "Buildings"],
1264
+ ["permits", "Building activity"],
1265
+ ["farmland", "Agricultural land"],
1266
+ ];
1267
+ export function formatParcelReport(res) {
1268
+ const p = res.parcel;
1269
+ const id = p.parcel_id ?? p.parcel_key ?? "(parcel id requires a paid plan)";
1270
+ if (res.coverage !== "covered") {
1271
+ const head = res.coverage === "not_covered"
1272
+ ? `Parcel ${id} could not be resolved — it is not in our cadastral copy.`
1273
+ : res.billing.rule === "disabled"
1274
+ ? `The composite report is temporarily unavailable for parcel ${id}.`
1275
+ : `Parcel ${id} could not be resolved right now (a live lookup did not finish — retry).`;
1276
+ return `${head}\n\n---\n${reportBillingFooter(res.billing)}`;
1277
+ }
1278
+ const lines = [`Parcel report: ${id}`];
1279
+ const place = [p.district, p.county_name, p.voivodeship_name].filter(Boolean).join(", ");
1280
+ if (place)
1281
+ lines.push(place);
1282
+ const facts = [
1283
+ p.area_m2 != null ? `Area: ${formatArea(p.area_m2)}` : null,
1284
+ p.land_use ? `Land use: ${p.land_use}` : null,
1285
+ p.mpzp_designation ? `Plan designation: ${p.mpzp_designation}` : null,
1286
+ ].filter(Boolean);
1287
+ if (facts.length > 0)
1288
+ lines.push(facts.join(" | "));
1289
+ const asOf = res.as_of ? ` (as of ${res.as_of.split("T")[0]})` : "";
1290
+ lines.push(`Core: covered${asOf}`);
1291
+ lines.push("", "Enrichment layers:");
1292
+ const sections = res.sections;
1293
+ for (const [key, label] of REPORT_LAYER_ORDER) {
1294
+ const s = sections[key];
1295
+ const detail = reportSectionDetail(key, s);
1296
+ lines.push(`- ${label}: ${fourStateGloss(s.coverage)}${detail ? ` — ${detail}` : ""}`);
1297
+ }
1298
+ const tx = sections.transactions;
1299
+ const total = toNum(tx.total) ?? 0;
1300
+ const rows = Array.isArray(tx.data) ? tx.data : [];
1301
+ lines.push("", `Transaction history: ${fourStateGloss(tx.coverage)}`);
1302
+ if (tx.coverage === "covered") {
1303
+ lines.push(` ${total} recorded${rows.length < total ? ` (showing newest ${rows.length})` : ""}:`);
1304
+ for (const r of rows)
1305
+ lines.push(reportTxLine(r));
1306
+ if (rows.length < total)
1307
+ lines.push(` … call search_transactions(parcelId="${p.parcel_id ?? id}") for the full history.`);
1308
+ }
1309
+ const m = sections.market_context;
1310
+ lines.push("", "Local price context (median zł/m², last 12 months):");
1311
+ if (m.coverage === "no_data") {
1312
+ lines.push(" - no data for this location");
1313
+ }
1314
+ else {
1315
+ lines.push(reportMarketLine("County", m.county));
1316
+ lines.push(reportMarketLine(m.locality.district ? `Locality (${m.locality.district})` : "Locality", m.locality));
1317
+ }
1318
+ const loc = sections.location_context;
1319
+ lines.push("", `Municipal context${loc.gmina_teryt ? ` (gmina ${loc.gmina_teryt})` : ""}:`);
1320
+ const demo = loc.demographics;
1321
+ if (demo.coverage === "no_data") {
1322
+ lines.push(" - Demographics: no data");
1323
+ }
1324
+ else {
1325
+ const inds = Object.values(demo.indicators).slice(0, 4);
1326
+ const indText = inds.map((i) => {
1327
+ const years = Object.keys(i.values);
1328
+ const latest = years.length > 0 ? i.values[years[years.length - 1]] : null;
1329
+ return latest != null ? `${i.name} ${formatNumber(latest)} ${i.unit}`.trim() : i.name;
1330
+ });
1331
+ lines.push(` - Demographics${demo.name ? ` (${demo.name})` : ""}: ${indText.length > 0 ? indText.join("; ") : "—"}`);
1332
+ }
1333
+ const infra = loc.infra_signals;
1334
+ if (infra.coverage === "no_data") {
1335
+ lines.push(" - Infrastructure signals: no data");
1336
+ }
1337
+ else {
1338
+ const tenderTotal = Object.values(infra.tenders.by_category).reduce((a, b) => a + b, 0);
1339
+ const kposk = infra.kposk.in_agglomeration ? "in a collective-sewerage agglomeration" : "not in a collective-sewerage agglomeration";
1340
+ lines.push(` - Infrastructure signals: ${tenderTotal} municipal tender(s) in the last ${infra.tenders.window_months} months; ${kposk}`);
1341
+ }
1342
+ if (res.note)
1343
+ lines.push("", res.note);
1344
+ lines.push("", "---", reportBillingFooter(res.billing));
236
1345
  return lines.join("\n");
237
1346
  }