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.
- package/LICENSE +21 -21
- package/README.md +370 -355
- package/dist/index.js +11 -3
- package/dist/index.js.map +1 -1
- package/dist/propstack-client.d.ts.map +1 -1
- package/dist/propstack-client.js +6 -0
- package/dist/propstack-client.js.map +1 -1
- package/dist/tools/admin.js +21 -21
- package/dist/tools/composites.d.ts +13 -0
- package/dist/tools/composites.d.ts.map +1 -1
- package/dist/tools/composites.js +313 -154
- package/dist/tools/composites.js.map +1 -1
- package/dist/tools/contacts.js +80 -80
- package/dist/tools/deals.js +47 -47
- package/dist/tools/helpers.d.ts +2 -7
- package/dist/tools/helpers.d.ts.map +1 -1
- package/dist/tools/helpers.js +23 -7
- package/dist/tools/helpers.js.map +1 -1
- package/dist/tools/images.d.ts +4 -0
- package/dist/tools/images.d.ts.map +1 -0
- package/dist/tools/images.js +131 -0
- package/dist/tools/images.js.map +1 -0
- package/dist/tools/properties.d.ts.map +1 -1
- package/dist/tools/properties.js +223 -72
- package/dist/tools/properties.js.map +1 -1
- package/dist/types/propstack.d.ts +45 -0
- package/dist/types/propstack.d.ts.map +1 -1
- package/package.json +58 -58
package/dist/tools/composites.js
CHANGED
|
@@ -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
|
-
|
|
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
|