propstack-mcp-server 1.1.0 → 1.2.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,5 @@
1
1
  import { z } from "zod";
2
- import { textResult, errorResult, fmt, fmtPrice, fmtArea, formatError, stripUndefined, unwrapNumber, validateFields, renderFieldValue, CONTACT_FIELDS } from "./helpers.js";
2
+ import { textResult, errorResult, fmt, fmtPrice, fmtArea, formatError, stripUndefined, unwrapNumber, unwrapPropstackValue, validateFields, renderFieldValue, CONTACT_FIELDS } from "./helpers.js";
3
3
  import { enrichDealsWithStageNames, fetchPipelines } from "./deals.js";
4
4
  function daysBetween(from, to) {
5
5
  return Math.floor((to.getTime() - new Date(from).getTime()) / (1000 * 60 * 60 * 24));
@@ -7,24 +7,160 @@ function daysBetween(from, to) {
7
7
  function contactName(c) {
8
8
  return fmt(c.name) !== "none" ? fmt(c.name) : ([fmt(c.first_name, ""), fmt(c.last_name, "")].filter(Boolean).join(" ") || "Unknown");
9
9
  }
10
+ const FEATURE_LABELS = {
11
+ balcony: "Balcony",
12
+ lift: "Lift",
13
+ garden: "Garden",
14
+ built_in_kitchen: "Built-in kitchen",
15
+ cellar: "Cellar",
16
+ rented: "Rented",
17
+ };
18
+ function profileFeatureWanted(value) {
19
+ const v = unwrapPropstackValue(value);
20
+ if (v === true || v === "true")
21
+ return true;
22
+ if (v === false || v === "false")
23
+ return false;
24
+ return null;
25
+ }
26
+ /**
27
+ * Score one search profile against one property. Each criterion the profile
28
+ * actually sets adds to maxScore; criteria it leaves empty are ignored, so a
29
+ * broad profile is not penalised for what it doesn't care about.
30
+ */
31
+ export function scoreProfileAgainstProperty(sp, property) {
32
+ let score = 0;
33
+ let maxScore = 0;
34
+ const matches = [];
35
+ const mismatches = [];
36
+ // Marketing type (weight: 3)
37
+ if (sp.marketing_type && property.marketing_type) {
38
+ maxScore += 3;
39
+ if (sp.marketing_type === property.marketing_type) {
40
+ score += 3;
41
+ matches.push(`Type: ${sp.marketing_type}`);
42
+ }
43
+ else {
44
+ mismatches.push(`Type: wants ${sp.marketing_type}, property is ${property.marketing_type}`);
45
+ }
46
+ }
47
+ // City (weight: 3)
48
+ const propCity = fmt(property.city, "");
49
+ if (sp.cities?.length && propCity) {
50
+ maxScore += 3;
51
+ if (sp.cities.some((c) => propCity.toLowerCase().includes(c.toLowerCase()))) {
52
+ score += 3;
53
+ matches.push(`City: ${propCity}`);
54
+ }
55
+ else {
56
+ mismatches.push(`City: wants ${sp.cities.join("/")}, property in ${propCity}`);
57
+ }
58
+ }
59
+ // Price range (weight: 2)
60
+ const propPrice = unwrapNumber(property.price);
61
+ if ((sp.price !== null || sp.price_to !== null) && propPrice !== null) {
62
+ maxScore += 2;
63
+ const inRange = (sp.price === null || sp.price === undefined || propPrice >= sp.price) &&
64
+ (sp.price_to === null || sp.price_to === undefined || propPrice <= sp.price_to);
65
+ if (inRange) {
66
+ score += 2;
67
+ matches.push(`Price: ${fmtPrice(property.price)} in range`);
68
+ }
69
+ else {
70
+ mismatches.push(`Price: ${fmtPrice(property.price)} outside ${fmtPrice(sp.price)}–${fmtPrice(sp.price_to)}`);
71
+ }
72
+ }
73
+ // Rent range (weight: 2)
74
+ const propRent = unwrapNumber(property.base_rent);
75
+ if ((sp.base_rent !== null || sp.base_rent_to !== null) && propRent !== null) {
76
+ maxScore += 2;
77
+ const inRange = (sp.base_rent === null || sp.base_rent === undefined || propRent >= sp.base_rent) &&
78
+ (sp.base_rent_to === null || sp.base_rent_to === undefined || propRent <= sp.base_rent_to);
79
+ if (inRange) {
80
+ score += 2;
81
+ matches.push(`Rent: ${fmtPrice(property.base_rent)} in range`);
82
+ }
83
+ else {
84
+ mismatches.push(`Rent: ${fmtPrice(property.base_rent)} outside ${fmtPrice(sp.base_rent)}–${fmtPrice(sp.base_rent_to)}`);
85
+ }
86
+ }
87
+ // Rooms (weight: 2)
88
+ const propRooms = unwrapNumber(property.number_of_rooms);
89
+ if ((sp.number_of_rooms !== null || sp.number_of_rooms_to !== null) && propRooms !== null) {
90
+ maxScore += 2;
91
+ const inRange = (sp.number_of_rooms === null || sp.number_of_rooms === undefined || propRooms >= sp.number_of_rooms) &&
92
+ (sp.number_of_rooms_to === null || sp.number_of_rooms_to === undefined || propRooms <= sp.number_of_rooms_to);
93
+ if (inRange) {
94
+ score += 2;
95
+ matches.push(`Rooms: ${fmt(property.number_of_rooms)}`);
96
+ }
97
+ else {
98
+ mismatches.push(`Rooms: ${fmt(property.number_of_rooms)} outside ${fmt(sp.number_of_rooms)}–${fmt(sp.number_of_rooms_to)}`);
99
+ }
100
+ }
101
+ // Living space (weight: 1)
102
+ const propSpace = unwrapNumber(property.living_space);
103
+ if ((sp.living_space !== null || sp.living_space_to !== null) && propSpace !== null) {
104
+ maxScore += 1;
105
+ const inRange = (sp.living_space === null || sp.living_space === undefined || propSpace >= sp.living_space) &&
106
+ (sp.living_space_to === null || sp.living_space_to === undefined || propSpace <= sp.living_space_to);
107
+ if (inRange) {
108
+ score += 1;
109
+ matches.push(`Space: ${fmtArea(property.living_space)}`);
110
+ }
111
+ else {
112
+ mismatches.push(`Space: ${fmtArea(property.living_space)} outside ${fmt(sp.living_space)}–${fmt(sp.living_space_to)} m²`);
113
+ }
114
+ }
115
+ // Property type (weight: 2)
116
+ const propRsType = fmt(property.rs_type, "");
117
+ if (sp.rs_types?.length && propRsType) {
118
+ maxScore += 2;
119
+ if (sp.rs_types.includes(propRsType)) {
120
+ score += 2;
121
+ matches.push(`Property type: ${propRsType}`);
122
+ }
123
+ else {
124
+ mismatches.push(`Property type: wants ${sp.rs_types.join("/")}, is ${propRsType}`);
125
+ }
126
+ }
127
+ // Features (weight: 1 each) — profile says "true" (required) or "false" (excluded)
128
+ const record = property;
129
+ const spRecord = sp;
130
+ for (const [key, label] of Object.entries(FEATURE_LABELS)) {
131
+ const wanted = profileFeatureWanted(spRecord[key]);
132
+ const has = unwrapPropstackValue(record[key]);
133
+ if (wanted === null || (has !== true && has !== false))
134
+ continue;
135
+ maxScore += 1;
136
+ if (has === wanted) {
137
+ score += 1;
138
+ matches.push(`${label}: ${has ? "yes" : "no"}`);
139
+ }
140
+ else {
141
+ mismatches.push(`${label}: wants ${wanted ? "yes" : "no"}, is ${has ? "yes" : "no"}`);
142
+ }
143
+ }
144
+ return { score, maxScore, matches, mismatches };
145
+ }
10
146
  // ── Tool registration ────────────────────────────────────────────────
11
147
  export function registerCompositeTools(server, client) {
12
148
  // ── full_contact_360 ────────────────────────────────────────────
13
- server.tool("full_contact_360", `Get a complete 360° view of a contact — everything you need before calling a client.
14
-
15
- Combines 4 API calls in parallel:
16
- - Full contact details with children, documents, relationships, owned properties
17
- - Search profiles (what they're looking for)
18
- - Active deals (which properties they're linked to)
19
- - Recent activity (last 10 interactions)
20
-
21
- Returns a complete contact dossier in one request. Use this when you
22
- need the full picture: "Tell me everything about Herr Weber."
23
-
24
- Data minimization (Art. 25 DSGVO): pass 'fields' with an array of contact
25
- field names to limit the contact detail section to only those fields (e.g.
26
- ["first_name", "last_name", "email"]). The search profiles, deals, and
27
- activity sections are unaffected. Omit 'fields' for the full detail section.
149
+ server.tool("full_contact_360", `Get a complete 360° view of a contact — everything you need before calling a client.
150
+
151
+ Combines 4 API calls in parallel:
152
+ - Full contact details with children, documents, relationships, owned properties
153
+ - Search profiles (what they're looking for)
154
+ - Active deals (which properties they're linked to)
155
+ - Recent activity (last 10 interactions)
156
+
157
+ Returns a complete contact dossier in one request. Use this when you
158
+ need the full picture: "Tell me everything about Herr Weber."
159
+
160
+ Data minimization (Art. 25 DSGVO): pass 'fields' with an array of contact
161
+ field names to limit the contact detail section to only those fields (e.g.
162
+ ["first_name", "last_name", "email"]). The search profiles, deals, and
163
+ activity sections are unaffected. Omit 'fields' for the full detail section.
28
164
  Unknown field names produce an error listing the valid options.`, {
29
165
  contact_id: z.number()
30
166
  .describe("Contact ID to get 360° view for"),
@@ -207,17 +343,17 @@ Unknown field names produce an error listing the valid options.`, {
207
343
  }
208
344
  });
209
345
  // ── property_performance_report ─────────────────────────────────
210
- server.tool("property_performance_report", `Performance report for a property — days on market, inquiry count,
211
- pipeline breakdown, and activity summary.
212
-
213
- Combines 3 API calls in parallel:
214
- - Full property details (with custom fields)
215
- - All deals/inquiries for this property
216
- - Activity feed (last 50 interactions)
217
-
218
- Calculates: days on market, total inquiries, deals by stage/category,
219
- and recent activity breakdown by type.
220
-
346
+ server.tool("property_performance_report", `Performance report for a property — days on market, inquiry count,
347
+ pipeline breakdown, and activity summary.
348
+
349
+ Combines 3 API calls in parallel:
350
+ - Full property details (with custom fields)
351
+ - All deals/inquiries for this property
352
+ - Activity feed (last 50 interactions)
353
+
354
+ Calculates: days on market, total inquiries, deals by stage/category,
355
+ and recent activity breakdown by type.
356
+
221
357
  Use when asked: "How is the Friedrichstr property doing?"`, {
222
358
  property_id: z.number()
223
359
  .describe("Property ID to generate report for"),
@@ -347,15 +483,15 @@ Use when asked: "How is the Friedrichstr property doing?"`, {
347
483
  }
348
484
  });
349
485
  // ── pipeline_summary ────────────────────────────────────────────
350
- server.tool("pipeline_summary", `Pipeline overview — deals per stage, total values, and stale deals
351
- needing attention.
352
-
353
- Fetches all deal pipelines and deals, then aggregates:
354
- - Deal count per stage
355
- - Total value per stage (from deal price or property price)
356
- - Stale deals: deals with no update in 14+ days
357
-
358
- Filter by pipeline_id and/or broker_id. Use when asked:
486
+ server.tool("pipeline_summary", `Pipeline overview — deals per stage, total values, and stale deals
487
+ needing attention.
488
+
489
+ Fetches all deal pipelines and deals, then aggregates:
490
+ - Deal count per stage
491
+ - Total value per stage (from deal price or property price)
492
+ - Stale deals: deals with no update in 14+ days
493
+
494
+ Filter by pipeline_id and/or broker_id. Use when asked:
359
495
  "How's the pipeline looking?" or "Give me a sales overview."`, {
360
496
  pipeline_id: z.number().optional()
361
497
  .describe("Filter by specific pipeline ID"),
@@ -474,18 +610,18 @@ Filter by pipeline_id and/or broker_id. Use when asked:
474
610
  }
475
611
  });
476
612
  // ── smart_lead_intake ───────────────────────────────────────────
477
- server.tool("smart_lead_intake", `Complete lead intake workflow — dedup check, create/update contact,
478
- log notes, create deal if specific property, and set follow-up reminder.
479
-
480
- Perfect for post-call processing from a voice agent. Handles the entire
481
- intake in one tool call:
482
-
483
- 1. If phone or email provided → search for existing contact (dedup)
484
- 2. If found → update contact; if not → create new contact
485
- 3. If notes provided → log as a note task
486
- 4. If property_id provided → create deal at first pipeline stage
487
- 5. Create follow-up reminder for broker (due tomorrow 9am)
488
-
613
+ server.tool("smart_lead_intake", `Complete lead intake workflow — dedup check, create/update contact,
614
+ log notes, create deal if specific property, and set follow-up reminder.
615
+
616
+ Perfect for post-call processing from a voice agent. Handles the entire
617
+ intake in one tool call:
618
+
619
+ 1. If phone or email provided → search for existing contact (dedup)
620
+ 2. If found → update contact; if not → create new contact
621
+ 3. If notes provided → log as a note task
622
+ 4. If property_id provided → create deal at first pipeline stage
623
+ 5. Create follow-up reminder for broker (due tomorrow 9am)
624
+
489
625
  Returns what was done: created vs updated, IDs of all created records.`, {
490
626
  first_name: z.string()
491
627
  .describe("Contact first name"),
@@ -647,17 +783,17 @@ Returns what was done: created vs updated, IDs of all created records.`, {
647
783
  }
648
784
  });
649
785
  // ── match_contacts_to_property ──────────────────────────────────
650
- server.tool("match_contacts_to_property", `Find contacts whose search profiles match a property. Returns a
651
- ranked list with match scores.
652
-
653
- Use when a new listing comes in to find potential buyers/renters:
654
- "Who should I send this new listing to?"
655
-
656
- Logic:
657
- 1. Fetches the property details (type, price, rooms, space, city, features)
658
- 2. Fetches active search profiles (paginates, capped by max_profiles)
659
- 3. Scores each profile against the property on: marketing type, city,
660
- price range, room count, living space, property type, and features
786
+ server.tool("match_contacts_to_property", `Find contacts whose search profiles match a property. Returns a
787
+ ranked list with match scores.
788
+
789
+ Use when a new listing comes in to find potential buyers/renters:
790
+ "Who should I send this new listing to?"
791
+
792
+ Logic:
793
+ 1. Fetches the property details (type, price, rooms, space, city, features)
794
+ 2. Fetches active search profiles (paginates, capped by max_profiles)
795
+ 3. Scores each profile against the property on: marketing type, city,
796
+ price range, room count, living space, property type, and features
661
797
  4. Returns top 20 matches sorted by score with match/mismatch details`, {
662
798
  property_id: z.number()
663
799
  .describe("Property ID to find matching contacts for"),
@@ -691,101 +827,7 @@ Logic:
691
827
  for (const sp of allProfiles) {
692
828
  if (!sp.active)
693
829
  continue;
694
- let score = 0;
695
- let maxScore = 0;
696
- const matches = [];
697
- const mismatches = [];
698
- // Marketing type (weight: 3)
699
- if (sp.marketing_type && property.marketing_type) {
700
- maxScore += 3;
701
- if (sp.marketing_type === property.marketing_type) {
702
- score += 3;
703
- matches.push(`Type: ${sp.marketing_type}`);
704
- }
705
- else {
706
- mismatches.push(`Type: wants ${sp.marketing_type}, property is ${property.marketing_type}`);
707
- }
708
- }
709
- // City (weight: 3)
710
- const propCity = fmt(property.city, "");
711
- if (sp.cities?.length && propCity) {
712
- maxScore += 3;
713
- if (sp.cities.some((c) => propCity.toLowerCase().includes(c.toLowerCase()))) {
714
- score += 3;
715
- matches.push(`City: ${propCity}`);
716
- }
717
- else {
718
- mismatches.push(`City: wants ${sp.cities.join("/")}, property in ${propCity}`);
719
- }
720
- }
721
- // Price range (weight: 2)
722
- const propPrice = unwrapNumber(property.price);
723
- if ((sp.price !== null || sp.price_to !== null) && propPrice !== null) {
724
- maxScore += 2;
725
- const inRange = (sp.price === null || sp.price === undefined || propPrice >= sp.price) &&
726
- (sp.price_to === null || sp.price_to === undefined || propPrice <= sp.price_to);
727
- if (inRange) {
728
- score += 2;
729
- matches.push(`Price: ${fmtPrice(property.price)} in range`);
730
- }
731
- else {
732
- mismatches.push(`Price: ${fmtPrice(property.price)} outside ${fmtPrice(sp.price)}–${fmtPrice(sp.price_to)}`);
733
- }
734
- }
735
- // Rent range (weight: 2)
736
- const propRent = unwrapNumber(property.base_rent);
737
- if ((sp.base_rent !== null || sp.base_rent_to !== null) && propRent !== null) {
738
- maxScore += 2;
739
- const inRange = (sp.base_rent === null || sp.base_rent === undefined || propRent >= sp.base_rent) &&
740
- (sp.base_rent_to === null || sp.base_rent_to === undefined || propRent <= sp.base_rent_to);
741
- if (inRange) {
742
- score += 2;
743
- matches.push(`Rent: ${fmtPrice(property.base_rent)} in range`);
744
- }
745
- else {
746
- mismatches.push(`Rent: ${fmtPrice(property.base_rent)} outside ${fmtPrice(sp.base_rent)}–${fmtPrice(sp.base_rent_to)}`);
747
- }
748
- }
749
- // Rooms (weight: 2)
750
- const propRooms = unwrapNumber(property.number_of_rooms);
751
- if ((sp.number_of_rooms !== null || sp.number_of_rooms_to !== null) && propRooms !== null) {
752
- maxScore += 2;
753
- const inRange = (sp.number_of_rooms === null || sp.number_of_rooms === undefined || propRooms >= sp.number_of_rooms) &&
754
- (sp.number_of_rooms_to === null || sp.number_of_rooms_to === undefined || propRooms <= sp.number_of_rooms_to);
755
- if (inRange) {
756
- score += 2;
757
- matches.push(`Rooms: ${fmt(property.number_of_rooms)}`);
758
- }
759
- else {
760
- mismatches.push(`Rooms: ${fmt(property.number_of_rooms)} outside ${fmt(sp.number_of_rooms)}–${fmt(sp.number_of_rooms_to)}`);
761
- }
762
- }
763
- // Living space (weight: 1)
764
- const propSpace = unwrapNumber(property.living_space);
765
- if ((sp.living_space !== null || sp.living_space_to !== null) && propSpace !== null) {
766
- maxScore += 1;
767
- const inRange = (sp.living_space === null || sp.living_space === undefined || propSpace >= sp.living_space) &&
768
- (sp.living_space_to === null || sp.living_space_to === undefined || propSpace <= sp.living_space_to);
769
- if (inRange) {
770
- score += 1;
771
- matches.push(`Space: ${fmtArea(property.living_space)}`);
772
- }
773
- else {
774
- mismatches.push(`Space: ${fmtArea(property.living_space)} outside ${fmt(sp.living_space)}–${fmt(sp.living_space_to)} m²`);
775
- }
776
- }
777
- // Property type (weight: 2)
778
- const propRsType = fmt(property.rs_type, "");
779
- if (sp.rs_types?.length && propRsType) {
780
- maxScore += 2;
781
- if (sp.rs_types.includes(propRsType)) {
782
- score += 2;
783
- matches.push(`Property type: ${propRsType}`);
784
- }
785
- else {
786
- mismatches.push(`Property type: wants ${sp.rs_types.join("/")}, is ${propRsType}`);
787
- }
788
- }
830
+ const { score, maxScore, matches, mismatches } = scoreProfileAgainstProperty(sp, property);
789
831
  // Only include profiles that matched on at least 1 criterion
790
832
  if (score > 0) {
791
833
  results.push({
@@ -828,5 +870,122 @@ Logic:
828
870
  return errorResult("Property matching", err);
829
871
  }
830
872
  });
873
+ // ── match_properties_to_contact ─────────────────────────────────
874
+ server.tool("match_properties_to_contact", `Reverse matching: find properties that fit a contact's search profiles.
875
+
876
+ The counterpart to match_contacts_to_property. Use it for "Which of our
877
+ listings fit Herr Weber?" or before calling a buyer.
878
+
879
+ Logic:
880
+ 1. Loads the contact's active search profiles (or one specific profile)
881
+ 2. Pre-filters properties via the API on marketing type, price/rent,
882
+ rooms and living space (single property type too, when the profile
883
+ names exactly one)
884
+ 3. Scores every candidate on type, city, price/rent, rooms, space,
885
+ property type and features (balcony, lift, garden, kitchen, cellar,
886
+ rented)
887
+ 4. Returns the top 20 properties with score and match/mismatch details
888
+
889
+ Tip: pass status with the IDs of your "on the market" statuses
890
+ (get_property_statuses) so sold or reserved listings are left out.
891
+ Scores are a shortlist for the agent to review, not an automatic decision.`, {
892
+ contact_id: z.number()
893
+ .describe("Contact whose search profiles should be matched"),
894
+ search_profile_id: z.number().optional()
895
+ .describe("Only use this search profile (default: all active profiles of the contact)"),
896
+ status: z.string().optional()
897
+ .describe("Comma-separated property status IDs to consider (use get_property_statuses)"),
898
+ max_properties: z.number().optional()
899
+ .describe("Max candidate properties to fetch per profile (default: 300)"),
900
+ }, async (args) => {
901
+ try {
902
+ const profilesRes = await client.get("/saved_queries", { params: { client: args.contact_id, per: 100 } });
903
+ let profiles = (profilesRes.data ?? []).filter((sp) => sp.active);
904
+ if (args.search_profile_id !== undefined) {
905
+ profiles = (profilesRes.data ?? []).filter((sp) => sp.id === args.search_profile_id);
906
+ if (profiles.length === 0) {
907
+ return textResult(`Search profile ${args.search_profile_id} not found for contact ${args.contact_id}.`);
908
+ }
909
+ }
910
+ if (profiles.length === 0) {
911
+ return textResult(`Contact ${args.contact_id} has no active search profiles. ` +
912
+ `Create one with create_search_profile first.`);
913
+ }
914
+ const maxProperties = args.max_properties ?? 300;
915
+ const best = new Map();
916
+ let candidatesChecked = 0;
917
+ for (const sp of profiles) {
918
+ const params = {
919
+ with_meta: 1,
920
+ per_page: 100,
921
+ marketing_type: sp.marketing_type ?? undefined,
922
+ price_from: sp.price ?? undefined,
923
+ price_to: sp.price_to ?? undefined,
924
+ base_rent_from: sp.base_rent ?? undefined,
925
+ base_rent_to: sp.base_rent_to ?? undefined,
926
+ number_of_rooms_from: sp.number_of_rooms ?? undefined,
927
+ number_of_rooms_to: sp.number_of_rooms_to ?? undefined,
928
+ living_space_from: sp.living_space ?? undefined,
929
+ living_space_to: sp.living_space_to ?? undefined,
930
+ rs_type: sp.rs_types?.length === 1 ? sp.rs_types[0] : undefined,
931
+ status: args.status,
932
+ };
933
+ const candidates = [];
934
+ let page = 1;
935
+ while (candidates.length < maxProperties) {
936
+ const res = await client.get("/units", { params: { ...params, page } });
937
+ const batch = res.data ?? [];
938
+ candidates.push(...batch);
939
+ const total = res.meta?.total_count ?? 0;
940
+ if (batch.length === 0 || candidates.length >= total)
941
+ break;
942
+ page++;
943
+ }
944
+ candidatesChecked += candidates.length;
945
+ for (const property of candidates.slice(0, maxProperties)) {
946
+ const result = scoreProfileAgainstProperty(sp, property);
947
+ if (result.score === 0)
948
+ continue;
949
+ const prev = best.get(property.id);
950
+ const ratio = result.maxScore > 0 ? result.score / result.maxScore : 0;
951
+ const prevRatio = prev && prev.result.maxScore > 0 ? prev.result.score / prev.result.maxScore : -1;
952
+ if (!prev || ratio > prevRatio) {
953
+ best.set(property.id, { property, profileId: sp.id, result });
954
+ }
955
+ }
956
+ }
957
+ const ranked = [...best.values()].sort((a, b) => {
958
+ const ra = a.result.maxScore > 0 ? a.result.score / a.result.maxScore : 0;
959
+ const rb = b.result.maxScore > 0 ? b.result.score / b.result.maxScore : 0;
960
+ return rb - ra || b.result.score - a.result.score || a.result.mismatches.length - b.result.mismatches.length;
961
+ });
962
+ const top = ranked.slice(0, 20);
963
+ if (top.length === 0) {
964
+ return textResult(`No matching properties found for contact ${args.contact_id} ` +
965
+ `(${profiles.length} search profile(s), ${candidatesChecked} candidate properties checked).`);
966
+ }
967
+ const header = [
968
+ `# Matching Properties for Contact #${args.contact_id}`,
969
+ `Search profiles used: ${profiles.map((sp) => `#${sp.id}`).join(", ")}`,
970
+ "",
971
+ `Found **${ranked.length}** matching properties out of ${candidatesChecked} candidates (showing top ${top.length}):`,
972
+ "",
973
+ ].join("\n");
974
+ const lines = top.map(({ property: p, profileId, result: m }, i) => {
975
+ const pct = m.maxScore > 0 ? Math.round((m.score / m.maxScore) * 100) : 0;
976
+ const price = p.price ? fmtPrice(p.price) : p.base_rent ? `${fmtPrice(p.base_rent)} rent` : "no price";
977
+ return [
978
+ `**${i + 1}. ${fmt(p.title, "Untitled")}** (ID: ${p.id}) — Score: ${m.score}/${m.maxScore} (${pct}%) — Profile #${profileId}`,
979
+ ` ${fmt(p.city, "?")} · ${price} · ${fmt(p.number_of_rooms)} rooms · ${fmtArea(p.living_space)}`,
980
+ m.matches.length ? ` Matches: ${m.matches.join(", ")}` : null,
981
+ m.mismatches.length ? ` Mismatches: ${m.mismatches.join(", ")}` : null,
982
+ ].filter(Boolean).join("\n");
983
+ });
984
+ return textResult(header + lines.join("\n\n"));
985
+ }
986
+ catch (err) {
987
+ return errorResult("Property matching", err);
988
+ }
989
+ });
831
990
  }
832
991
  //# sourceMappingURL=composites.js.map