ReUseX  0.0.5
3D Point Cloud Processing for Building Reuse
Loading...
Searching...
No Matches
vlm_protocol.hpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 Povl Filip Sonne-Frederiksen
2//
3// SPDX-License-Identifier: GPL-3.0-or-later
4
5#pragma once
6
7// Provider-agnostic OpenAI-compatible chat/completions protocol for the VLM
8// attributes pass (#373).
9//
10// Deliberately header-only and network-free: the two functions here โ€” building
11// the request body and parsing the response body โ€” are pure string/JSON
12// transforms with NO dependency on libcurl or reusex_vision. That lets them be
13// unit-tested directly in the LIGHT test binary (which does not link
14// reusex_vision), which is the whole point: the transport is a thin wrapper
15// nobody tests in CI, but the request shape and the (defensive) parser are
16// where the bugs live, so those must be testable without a server.
17
18#include <nlohmann/json.hpp>
19
20#include <optional>
21#include <string>
22#include <utility>
23#include <vector>
24
25namespace reusex::vision {
26
31struct VlmResult {
32 std::string description;
33 std::vector<std::pair<std::string, std::string>>
35 std::string raw_json;
36 bool ok = false;
38};
39
51inline nlohmann::json build_chat_request_json(const std::string &model,
52 const std::string &b64_jpeg,
53 const std::string &prompt) {
54 nlohmann::json image_url;
55 image_url["url"] = "data:image/jpeg;base64," + b64_jpeg;
56
57 nlohmann::json content = nlohmann::json::array();
58 content.push_back({{"type", "text"}, {"text", prompt}});
59 content.push_back({{"type", "image_url"}, {"image_url", image_url}});
60
61 nlohmann::json message;
62 message["role"] = "user";
63 message["content"] = content;
64
65 nlohmann::json req;
66 req["model"] = model;
67 req["messages"] = nlohmann::json::array({message});
68 req["temperature"] = 0;
69 req["stream"] = false;
70 // Ollama's native key.
71 req["format"] = "json";
72 // OpenAI's key (ignored by servers that don't support it).
73 req["response_format"] = {{"type", "json_object"}};
74 return req;
75}
76
77namespace detail {
78
82inline std::string strip_json_fence(std::string s) {
83 auto trim = [](std::string &t) {
84 const char *ws = " \t\r\n";
85 t.erase(0, t.find_first_not_of(ws));
86 const auto end = t.find_last_not_of(ws);
87 t.erase(end == std::string::npos ? 0 : end + 1);
88 };
89 trim(s);
90 if (s.rfind("```", 0) == 0) {
91 // Drop the opening fence line (```json or ```), then the closing fence.
92 const auto nl = s.find('\n');
93 if (nl != std::string::npos)
94 s.erase(0, nl + 1);
95 else
96 s.erase(0, 3);
97 const auto close = s.rfind("```");
98 if (close != std::string::npos)
99 s.erase(close);
100 trim(s);
101 }
102 return s;
103}
104
108inline bool is_chat_envelope(const nlohmann::json &body) {
109 if (!body.is_object())
110 return false;
111 if (body.contains("choices") && body["choices"].is_array() &&
112 !body["choices"].empty()) {
113 const auto &first = body["choices"][0];
114 if (first.is_object() && first.contains("message") &&
115 first["message"].is_object() && first["message"].contains("content"))
116 return true;
117 }
118 // Ollama's /api/chat shape: { "message": { "content": "..." } }
119 if (body.contains("message") && body["message"].is_object() &&
120 body["message"].contains("content"))
121 return true;
122 return false;
123}
124
131inline std::string extract_message_content(const nlohmann::json &body) {
132 if (body.is_object() && body.contains("choices") &&
133 body["choices"].is_array() && !body["choices"].empty()) {
134 const auto &first = body["choices"][0];
135 if (first.contains("message") && first["message"].contains("content") &&
136 first["message"]["content"].is_string())
137 return first["message"]["content"].get<std::string>();
138 }
139 // Ollama's /api/chat shape: { "message": { "content": "..." } }
140 if (body.is_object() && body.contains("message") &&
141 body["message"].contains("content") &&
142 body["message"]["content"].is_string())
143 return body["message"]["content"].get<std::string>();
144 return {};
145}
146
149inline std::optional<std::string> scalar_to_string(const nlohmann::json &v) {
150 if (v.is_string())
151 return v.get<std::string>();
152 if (v.is_boolean())
153 return v.get<bool>() ? std::string("true") : std::string("false");
154 if (v.is_number_integer())
155 return std::to_string(v.get<long long>());
156 if (v.is_number_unsigned())
157 return std::to_string(v.get<unsigned long long>());
158 if (v.is_number_float())
159 return v.dump(); // shortest round-trippable form
160 return std::nullopt;
161}
162
164inline bool is_description_key(const std::string &k) {
165 return k == "description" || k == "desc" || k == "summary";
166}
167
168} // namespace detail
169
194inline VlmResult parse_vlm_response(const std::string &body) {
195 VlmResult result;
196 result.raw_json = body;
197
198 nlohmann::json envelope = nlohmann::json::parse(body, nullptr, false);
199
200 std::string content;
201 if (!envelope.is_discarded() && detail::is_chat_envelope(envelope)) {
202 // A chat envelope was returned: the payload is its assistant content, and
203 // ONLY that. If the content is empty the model returned nothing usable โ€”
204 // do not fall back to re-parsing the envelope object itself.
205 content = detail::extract_message_content(envelope);
206 if (content.empty())
207 return result; // ok stays false
208 } else {
209 // No chat envelope (or the body did not parse): treat the whole body as the
210 // candidate payload, so a bare attributes JSON object still parses.
211 content = body;
212 }
213
214 const std::string inner = detail::strip_json_fence(content);
215 nlohmann::json obj = nlohmann::json::parse(inner, nullptr, false);
216 if (obj.is_discarded() || !obj.is_object())
217 return result; // ok stays false
218
219 // Collect attributes de-duplicated (last value wins), preserving first-seen
220 // key order so a caller sees a stable list even before the DB re-sorts it.
221 std::vector<std::pair<std::string, std::string>> attrs;
222 auto put_attr = [&attrs](const std::string &key, const std::string &value) {
223 for (auto &kv : attrs) {
224 if (kv.first == key) {
225 kv.second = value;
226 return;
227 }
228 }
229 attrs.emplace_back(key, value);
230 };
231
232 // Description: a description/desc/summary key at the top level.
233 for (auto it = obj.begin(); it != obj.end(); ++it) {
234 if (detail::is_description_key(it.key()) && it.value().is_string()) {
235 result.description = it.value().get<std::string>();
236 break;
237 }
238 }
239
240 // Nested attributes object, if present.
241 if (obj.contains("attributes") && obj["attributes"].is_object()) {
242 for (auto it = obj["attributes"].begin(); it != obj["attributes"].end();
243 ++it) {
244 if (auto v = detail::scalar_to_string(it.value()))
245 put_attr(it.key(), *v);
246 }
247 }
248
249 // Flat attributes: every OTHER top-level scalar field. Skips the description
250 // key(s) and the nested `attributes` object handled above.
251 for (auto it = obj.begin(); it != obj.end(); ++it) {
252 if (detail::is_description_key(it.key()))
253 continue;
254 if (it.key() == "attributes" && it.value().is_object())
255 continue;
256 if (auto v = detail::scalar_to_string(it.value()))
257 put_attr(it.key(), *v);
258 }
259
260 result.attributes = std::move(attrs);
261
262 // Success requires a non-empty description OR at least one attribute: never
263 // report ok on an object that carried neither (STANDARDS ยง5, no empty row).
264 result.ok = !result.description.empty() || !result.attributes.empty();
265 return result;
266}
267
268} // namespace reusex::vision
std::string strip_json_fence(std::string s)
Strip a leading/trailing markdown json ... fence if present, and trim surrounding whitespace.
std::optional< std::string > scalar_to_string(const nlohmann::json &v)
Stringify a JSON scalar (string / number / bool).
bool is_chat_envelope(const nlohmann::json &body)
Whether a parsed body is an OpenAI-/Ollama-style chat envelope, i.e.
std::string extract_message_content(const nlohmann::json &body)
Pull the assistant message text out of an OpenAI-style chat response.
bool is_description_key(const std::string &k)
True for any of the keys treated as the free-text description slot.
nlohmann::json build_chat_request_json(const std::string &model, const std::string &b64_jpeg, const std::string &prompt)
Build an OpenAI-compatible chat/completions request body for one image.
VlmResult parse_vlm_response(const std::string &body)
Parse an OpenAI-compatible chat/completions response into a VlmResult.
The description + arbitrary attributes a vision-language model returned for one crop.
std::string raw_json
The raw response body, verbatim.
bool ok
True only when a non-empty description OR at least one attribute was recovered (never fabricated).
std::vector< std::pair< std::string, std::string > > attributes
Arbitrary key/value pairs the model returned.
std::string description
Free-text description of the object/material.