Skip to content

Commit 228ad5e

Browse files
committed
feat(vaultai): GetStructuredMessageAsync — structured output(JSON schema) + 멀티모달 이미지 인라인 (0.6.0)
- IVaultAiClient에 GetStructuredMessageAsync(agentId, prompt, outputSchema, images?) 신설 + VaultAiImage(MimeType, Data, FileName?) record - 이미지는 vault-ai wire 계약(file 콘텐츠 + ImageBlock data[base64])으로 인라인 전달 — 채널 세션 파일업로드 경유 불필요(서버-투-서버) - 응답 최상위 output 1순위, 폴백=마지막 text 파트 JSON 파싱, 둘 다 없으면 예외(silent-failure 방지) - 소비처: yesung-oms 주문서 smart-fill (라이브 실증: gemini structured output + 이미지 인식) - 모노버전 0.5.0 → 0.6.0 (additive minor)
1 parent 277711e commit 228ad5e

3 files changed

Lines changed: 101 additions & 1 deletion

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
<PropertyGroup>
1313
<!-- 버전 (모노 버전: 5개 라이브러리 모두 동일) -->
14-
<Version>0.5.0</Version>
14+
<Version>0.6.0</Version>
1515
</PropertyGroup>
1616

1717
<PropertyGroup>

src/Iyu.VaultAi/IVaultAiClient.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
1+
using System.Text.Json.Nodes;
2+
13
namespace Iyu.VaultAi;
24

5+
/// <summary>agent 메시지에 첨부할 이미지(멀티모달 입력). MimeType: image/png·jpeg·gif·webp.</summary>
6+
public sealed record VaultAiImage(string MimeType, byte[] Data, string? FileName = null);
7+
38
public interface IVaultAiClient
49
{
510
Task<string> GetMessageAsync(Guid agentId, string prompt, CancellationToken ct = default);
11+
12+
/// <summary>
13+
/// structured output 호출 — vault-ai agent에 JSON schema(<paramref name="outputSchema"/>)를 지정해
14+
/// 스키마 준수 JSON을 응답 output으로 직접 수신한다. 이미지(멀티모달)를 함께 보낼 수 있다.
15+
/// 응답에 유효한 output이 없으면 예외를 던진다(silent-failure 방지).
16+
/// </summary>
17+
Task<JsonNode> GetStructuredMessageAsync(
18+
Guid agentId,
19+
string prompt,
20+
JsonNode outputSchema,
21+
IReadOnlyList<VaultAiImage>? images = null,
22+
CancellationToken ct = default);
623
}

src/Iyu.VaultAi/VaultAiClient.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Net.Http.Headers;
33
using System.Net.Http.Json;
44
using System.Text.Json;
5+
using System.Text.Json.Nodes;
56

67
namespace Iyu.VaultAi;
78

@@ -75,4 +76,86 @@ public async Task<string> GetMessageAsync(Guid agentId, string prompt, Cancellat
7576

7677
return text ?? string.Empty;
7778
}
79+
80+
public async Task<JsonNode> GetStructuredMessageAsync(
81+
Guid agentId,
82+
string prompt,
83+
JsonNode outputSchema,
84+
IReadOnlyList<VaultAiImage>? images = null,
85+
CancellationToken ct = default)
86+
{
87+
// content 파트 조립 — 텍스트 + (선택) 이미지. 이미지는 vault-ai wire 계약상
88+
// "file" 콘텐츠의 data 블록([{type:"image", mimeType, data(base64)}])으로 실린다
89+
// (VaultAI.WebServer VaultFileContent/ImageBlock — 채널 파일업로드 경유 없이 인라인 전달).
90+
var content = new List<object> { new { type = "text", text = prompt } };
91+
foreach (var img in images ?? [])
92+
{
93+
content.Add(new
94+
{
95+
type = "file",
96+
id = Guid.NewGuid().ToString(),
97+
fileName = img.FileName ?? "image",
98+
contentType = img.MimeType,
99+
contentSize = img.Data.LongLength,
100+
data = new object[]
101+
{
102+
new { type = "image", mimeType = img.MimeType, data = Convert.ToBase64String(img.Data) },
103+
},
104+
});
105+
}
106+
107+
var response = await _http.PostAsJsonAsync(
108+
$"api/agents/{agentId}/messages",
109+
new
110+
{
111+
messages = new[]
112+
{
113+
new
114+
{
115+
role = "user",
116+
id = Guid.NewGuid().ToString(),
117+
content = (object)content,
118+
createdAt = DateTimeOffset.UtcNow.ToString("o")
119+
}
120+
},
121+
stream = false,
122+
output = outputSchema, // JSON schema → 응답 output으로 구조화 수신
123+
}, ct);
124+
125+
if (!response.IsSuccessStatusCode)
126+
{
127+
var errorBody = await response.Content.ReadAsStringAsync(ct);
128+
var snippet = errorBody.Length > 1000 ? errorBody[..1000] + "…(중략)" : errorBody;
129+
throw new HttpRequestException(
130+
$"vault-ai 응답 {(int)response.StatusCode} ({response.StatusCode}): {snippet}");
131+
}
132+
133+
var json = await response.Content.ReadAsStringAsync(ct);
134+
var root = JsonNode.Parse(json);
135+
136+
// 1순위: 응답 최상위 output(서버가 파싱해 준 구조화 결과).
137+
var output = root?["output"];
138+
if (output is not null) return output;
139+
140+
// 폴백: 마지막 text 파트를 JSON으로 파싱(구버전 서버·output 미주입 경로 호환).
141+
var contentArr = root?["message"]?["content"] as JsonArray;
142+
var lastText = contentArr?
143+
.OfType<JsonObject>()
144+
.Where(o => (string?)o["type"] == "text")
145+
.Select(o => (string?)o["text"])
146+
.LastOrDefault(t => !string.IsNullOrWhiteSpace(t));
147+
if (lastText is not null)
148+
{
149+
try { return JsonNode.Parse(lastText) ?? throw new JsonException("null JSON"); }
150+
catch (JsonException ex)
151+
{
152+
var snippet = lastText.Length > 500 ? lastText[..500] + "…(중략)" : lastText;
153+
throw new HttpRequestException($"vault-ai 응답 text가 유효한 JSON이 아닙니다: {ex.Message}{snippet}");
154+
}
155+
}
156+
157+
// output도 text도 없음 — 스키마 불일치를 조용히 흘리지 않는다(silent-failure 방지).
158+
var bodySnippet = json.Length > 1000 ? json[..1000] + "…(중략)" : json;
159+
throw new HttpRequestException($"vault-ai 응답에 output/text가 없습니다: {bodySnippet}");
160+
}
78161
}

0 commit comments

Comments
 (0)