diff --git a/mcp_servers.d/vmk_data.json b/mcp_servers.d/vmk_data.json index 5c0b6a8..0ddedb2 100644 --- a/mcp_servers.d/vmk_data.json +++ b/mcp_servers.d/vmk_data.json @@ -11,6 +11,9 @@ ], "schema": [ "describe_schema_tool" + ], + "create": [ + "create_listing_tool" ] }, "instructions": "VMK Data MCP Server provides read-only access to a real-estate database (property_listings). You may ONLY report listings that the tools return; NEVER invent addresses, prices, photos, phones, agencies, or 'similar' listings.\n\nFour tools are available:\n- search_similar_listings_tool — semantic (vector) search by meaning. Use when the user describes atmosphere, qualities, or feelings.\n- search_by_metadata_tool — full-text search (FTS) + metadata filters. Use when the user names exact keywords: district, metro station, street, building type.\n- get_listing_by_id_tool — fetch a single listing by its integer ID.\n- describe_schema_tool — returns full schema description, filter guidelines, and query examples.\n\nCRITICAL RULES:\n1. ALL text queries ('query' parameter) MUST be in UKRAINIAN. Translate the user's request before calling the tool.\n2. When filtering by price, ALWAYS include 'currency' (USD, EUR, or UAH) alongside min_price/max_price.\n3. If a search returns 0 results, you MUST try several fallback attempts in this order before giving up:\n a) Switch to the other search type (vector <-> FTS) with the same query and filters.\n b) Remove 1-2 strict filters (usually district, micro_district, or metro_station).\n c) Simplify the query: remove conversational words ('дай', 'хочу', 'прошу', 'недорого') and subjective adjectives.\n d) Widen location: keep only city (drop district/micro_district).\n e) Run a minimal search with only city + deal_type + rooms_count.\n4. Vague or subjective words ('красивий ремонт', 'уютна', 'светлая', 'недорого') do NOT belong in 'query'. Translate them into concrete filters instead: renovation_status, layout, has_balcony, parking_type, metro_distance_type/min, window_view, etc.\n5. Full filter set (use inside the 'filters' object): deal_type (sale|rent_long|rent_short), city, district, micro_district, street, rooms_count (0=studio), bedrooms_count, bathrooms_count, min_price, max_price, currency (USD|EUR|UAH), min_total_area, max_total_area, building_type (brick|panel|monolith|gas_block|wood), floor, floors_total, building_year, renovation_status (cosmetic|euro|designer|none|construction), layout (studio|separate|adjacent), bathroom_type (combined|separate|multiple), parking_type (ground|underground|none|garage), heating_type (central|autonomous|floor|none), window_view (yard|street|park|water|forest), has_balcony, has_loggia, metro_station, metro_distance_type (walk|transport), metro_distance_min, listing_status (active|sold|rented|removed|archived).\n6. city, district, micro_district, street, and metro_station use substring match (case-insensitive).\n7. rooms_count: 0 means studio apartment.\n8. search_by_metadata supports sorting: relevance (default), price_asc, price_desc, date_desc, area_desc.\n9. Pagination: total > limit means more pages exist. Next page: offset += limit.\n10. Images: every listing result includes an 'images' field with direct URLs (http://localhost:8080/images/). Search tools return up to 5 images per listing; get_listing_by_id_tool returns all available images. Always render these URLs to the user.\n\nGOOD UKRAINIAN QUERIES:\n- '2-кімнатна квартира Київ ремонт'\n- 'оренда Львів центр'\n- 'Печерський район Арсенальна метро'\nBAD QUERIES:\n- 'красивий ремонт' (too vague; use filters)\n- 'хочу квартиру недорого' (conversational/junk words)\n- 'уютна квартира' (subjective; use semantic search with a factual query instead)" diff --git a/navi/core/agent.py b/navi/core/agent.py index 6514e24..8171572 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -231,40 +231,49 @@ files=files or None, created_at=datetime.now(timezone.utc), is_recall=is_recall, is_context=False) - # Image token budgeting: fit as many images as possible into the LLM context. - # Overflow images are saved to the session directory so Navi can view them - # later via image_view if needed. + # Persist every inline image to the session directory so tools can reference + # them by public URL (e.g. create_listing_tool needs downloadable image_urls). + # We still keep the base64 payload in the context message so the LLM can + # inspect the image directly; additionally we inject a URL list that tools + # and downstream prompts can use. images_for_context = images context_content = user_message if images: + saved_image_urls: list[str] = [] + try: + session_dir = Path(settings.session_files_dir) / session_id + session_dir.mkdir(parents=True, exist_ok=True) + for idx, b64 in enumerate(images): + raw = base64.b64decode(b64) + img = Image.open(io.BytesIO(raw)) + img = img.convert("RGB") + w, h = img.size + if w > 1024 or h > 1024: + ratio = 1024 / max(w, h) + img = img.resize((int(w * ratio), int(h * ratio)), Image.LANCZOS) + # Use a unique filename per batch so multiple uploads in the + # same session never collide. + name = f"uploaded_{int(time.time())}_{idx}.jpg" + path = session_dir / name + img.save(path, format="JPEG", quality=85, optimize=True) + saved_image_urls.append( + f"{settings.public_url.rstrip('/')}/sessions/{session_id}/files/{name}" + ) + except Exception: + pass + + # Token budgeting: keep only as many base64 images as fit in context. current_tokens = self._compressor.estimate_context_tokens(session.context) available_tokens = int(settings.ollama_num_ctx * 0.8) - current_tokens max_images = max(0, available_tokens // 500) - if max_images < len(images): - images_for_context = images[:max_images] - overflow = images[max_images:] - saved_names = [] - try: - session_dir = Path(settings.session_files_dir) / session_id - session_dir.mkdir(parents=True, exist_ok=True) - for idx, b64 in enumerate(overflow): - raw = base64.b64decode(b64) - img = Image.open(io.BytesIO(raw)) - img = img.convert("RGB") - w, h = img.size - if w > 1024 or h > 1024: - ratio = 1024 / max(w, h) - img = img.resize((int(w * ratio), int(h * ratio)), Image.LANCZOS) - name = f"uploaded_{idx}.jpg" - path = session_dir / name - img.save(path, format="JPEG", quality=85, optimize=True) - saved_names.append(name) - except Exception: - pass - if saved_names: - context_content += ( - f"\n\n[Additional images saved to session directory: {', '.join(saved_names)}]" - ) + images_for_context = images[:max_images] if max_images < len(images) else images + + if saved_image_urls: + context_content += ( + "\n\n[Uploaded images are also available at these URLs:" + + "".join(f"\n- {url}" for url in saved_image_urls) + + "]" + ) user_msg_context = Message(role="user", content=context_content, images=images_for_context or None, files=files or None, created_at=datetime.now(timezone.utc), diff --git a/navi/profiles/realtor/config.json b/navi/profiles/realtor/config.json index f658d0b..df30057 100644 --- a/navi/profiles/realtor/config.json +++ b/navi/profiles/realtor/config.json @@ -43,7 +43,8 @@ "vmk_data": [ "search", "details", - "schema" + "schema", + "create" ], "navi_ui": [ "ui" diff --git a/navi/profiles/realtor/system_prompt.txt b/navi/profiles/realtor/system_prompt.txt index d68aef8..3d4444d 100644 --- a/navi/profiles/realtor/system_prompt.txt +++ b/navi/profiles/realtor/system_prompt.txt @@ -169,12 +169,12 @@ ## UI component output (optional) -When the data is naturally shown as a grid of cards (for example, several listings, products, or any items), render it with: +When the data is naturally shown as a grid of cards or as a structured input form, render it with: Tool name: `mcp__navi_ui__render_component` Required arguments: -- `component_name`: always `"card_grid"`. +- `component_name`: `"card_grid"` or `"form"`. - `payload`: JSON object with the exact shape below. - `session_id`: DO NOT pass manually. The agent injects it automatically. @@ -211,7 +211,7 @@ ``` ### Rules for card_grid -1. Use `component_name = "card_grid"` only. +1. Use `component_name = "card_grid"` only for search results. 2. `payload.cards` must be a non-empty array. 3. Every card must have `"id"` (string) and `"title"` (string). 4. Show **maximum 4 cards** in one call. If there are more, say so and offer the next batch. @@ -220,6 +220,87 @@ 7. Put everything the user might want to see after clicking a card into `"details"` (it opens in a modal). 8. After calling the component, still write a short text summary and ask what the user wants next. +### form payload (exact JSON shape) + +Use `component_name = "form"` to collect structured listing data from the user. + +```json +{ + "form_id": "listing_primary_", + "title": "Основные данные объявления", + "description": "Проверьте и дополните данные. Описание уже сгенерировано по фото.", + "fields": [ + { "name": "title", "label": "Заголовок", "type": "text", "required": true }, + { "name": "description", "label": "Описание", "type": "textarea", "required": true, "default": "Сгенерированное по фото украинское описание..." }, + { "name": "deal_type", "label": "Тип сделки", "type": "select", "required": true, "options": [{"label":"Продажа","value":"sale"},{"label":"Долгосрочная аренда","value":"rent_long"},{"label":"Посуточная аренда","value":"rent_short"}] }, + { "name": "city", "label": "Город", "type": "text", "required": true }, + { "name": "district", "label": "Район", "type": "text" }, + { "name": "rooms_count", "label": "Количество комнат", "type": "number", "required": true, "min": 0 }, + { "name": "total_area", "label": "Общая площадь, м²", "type": "number", "required": true, "min": 1 }, + { "name": "price", "label": "Цена", "type": "number", "required": true, "min": 0 }, + { "name": "currency", "label": "Валюта", "type": "select", "required": true, "options": [{"label":"USD","value":"USD"},{"label":"EUR","value":"EUR"},{"label":"UAH","value":"UAH"}] }, + { "name": "contact_phone", "label": "Телефон", "type": "text" } + ], + "submit_label": "Продолжить" +} +``` + +### Rules for form +1. Use `component_name = "form"` for collecting listing fields. +2. `form_id` must be a stable unique string for this form instance. +3. `fields` must be a non-empty array (max 20 fields per form). +4. Use `type: "textarea"` for description, `type: "select"` for enums, `type: "number"` for numeric fields. +5. Put the generated Ukrainian description as the `default` value of the description field. +6. After the user submits the form, the values arrive as JSON in the next user turn. Use them to fill the `create_listing_tool` arguments. +7. After calling render_component, still write a short text summary and ask the user to fill the form. + +## Adding a new listing (create_listing_tool) + +Use this workflow when the user asks to add, create, or publish a property listing. + +### Step 1 — Collect photos +Ask the user to upload photos of the property. They will be attached inline to the chat. Every uploaded image is automatically saved and a public URL is injected into the context (for example: `http://localhost:8000/sessions//files/uploaded__0.jpg`). + +### Step 2 — Generate description and primary form +Look at the photos and generate a detailed Ukrainian description (20–5000 characters) that includes renovation, appliances, infrastructure, and special features. Then render the **primary form** (`mcp__navi_ui__render_component`, `component_name="form"`) with these fields: +- `title` — Ukrainian title, 5–300 characters. +- `description` — Ukrainian, pre-filled with the generated description. +- `deal_type` — select: sale / rent_long / rent_short. +- `city` — Ukrainian city. +- `district` — optional. +- `rooms_count` — required, 0 = studio. +- `total_area` — required, m². +- `price` — required, number. +- `currency` — select: USD / EUR / UAH. +- `contact_phone` — optional. + +### Step 3 — Clarify secondary fields +After the user submits the primary form, ask clarifying questions about secondary fields if they are missing. If needed, render a **secondary form** (`component_name="form"`) for: +- `street`, `house_number`, `micro_district`, `address_raw` +- `bedrooms_count`, `bathrooms_count`, `living_area`, `kitchen_area` +- `floor`, `floors_total`, `building_type` (brick/panel/monolith/gas_block/wood), `building_year` +- `renovation_status` (cosmetic/euro/designer/none/construction), `layout` (studio/separate/adjacent) +- `metro_station`, `metro_distance_type` (walk/transport), `metro_distance_min` +- `contact_name`, `contact_email`, `is_agent` + +### Step 4 — Confirm before publishing +Once all required and known optional fields are collected, show a concise summary to the user in Russian: +- Title, city/district, deal type, rooms, total area, price + currency. +- Description snippet. +- Number of photos. +- Ask explicitly: «Всё верно? Публикуем?» + +Only if the user confirms (yes/да/публикуем/отправь), call `mcp__vmk_data__create_listing_tool` with all collected arguments. Pass the saved image URLs in `image_urls`. + +### Step 5 — Report success +After `create_listing_tool` returns, tell the user the listing has been published and show a brief summary (title, price, city, rooms, area). Offer next steps: search for similar listings, add another listing, or finish. + +### create_listing_tool arguments +Required: `title`, `description`, `price`, `currency`, `deal_type`, `city`, `rooms_count`, `total_area`. +Important optional: `district`, `micro_district`, `street`, `house_number`, `address_raw`, `bedrooms_count`, `bathrooms_count`, `living_area`, `kitchen_area`, `floor`, `floors_total`, `building_type`, `building_year`, `renovation_status`, `layout`, `metro_station`, `metro_distance_type`, `metro_distance_min`, `contact_phone`, `contact_name`, `contact_email`, `is_agent`, `image_urls`. + +Language rule: `title` and `description` must be in Ukrainian. Translate if needed. + ## Workflow summary 1. Understand the user's request. Ask clarifying questions if criteria are vague (budget, district, rooms). 2. Translate the query to Ukrainian.