feat(facts): pictures as part of a statement, kept next to the raw source #13

Merged
k.renner merged 1 commits from feat/fact-images into main 2026-09-25 07:55:43 +00:00
19 changed files with 290 additions and 21 deletions
@@ -1,4 +1,5 @@
using System.Text;
using System.Text.Json.Nodes;
using Wiki.Core.Model;
using Wiki.Core.Ports;
@@ -26,8 +27,24 @@ public sealed class AnthropicFactDrafter : IFactDrafter
throw new ArgumentException("the statement is empty", nameof(statement));
var (system, user) = FactDraftPrompt.Build(statement);
var content = new JsonArray();
foreach (var a in statement.Attachments)
{
content.Add(new JsonObject
{
["type"] = "image",
["source"] = new JsonObject
{
["type"] = "base64",
["media_type"] = a.MediaType,
["data"] = Convert.ToBase64String(a.Bytes),
},
});
}
content.Add(new JsonObject { ["type"] = "text", ["text"] = user });
var text = new StringBuilder();
await foreach (var chunk in _transport.StreamAsync(system, user, ModelId, ct).ConfigureAwait(false))
await foreach (var chunk in _transport.StreamAsync(system, content, ModelId, ct).ConfigureAwait(false))
{
if (chunk is AnswerChunk.Text t) text.Append(t.Delta);
}
@@ -14,6 +14,7 @@ public static class FactDraftPrompt
"title: a short noun phrase in the language of the statement, no trailing period. " +
"content: the statement restated faithfully as complete sentences in the language of the statement; keep every detail and every exception, add nothing that was not said, at least 40 characters. " +
"tags: one to five lowercase ASCII slugs (a-z, 0-9, hyphen) naming the topics, English. " +
"Images attached to the statement are part of it: transcribe every identifier printed on them exactly as printed (manufacturer, model, type, serial number, ratings, dates), mark characters you cannot read as [unreadable], never guess. " +
"No markdown, no code fences, no commentary.";
public static (string System, string User) Build(FactStatement statement)
@@ -23,6 +24,8 @@ public static class FactDraftPrompt
user.Append("Stated by ").Append(statement.By.Name)
.Append(" on ").Append(statement.StatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(":\n\n");
user.Append(statement.Text);
if (statement.Attachments.Count > 0)
user.Append("\n\n(").Append(statement.Attachments.Count.ToString(CultureInfo.InvariantCulture)).Append(" image(s) attached.)");
return (SystemPrompt, user.ToString());
}
@@ -28,11 +28,16 @@ internal sealed class MessagesTransport
_delay = delay ?? Task.Delay;
}
public async IAsyncEnumerable<AnswerChunk> StreamAsync(string system, string user, string modelId, [EnumeratorCancellation] CancellationToken ct)
/// <summary>One user turn of text.</summary>
public IAsyncEnumerable<AnswerChunk> StreamAsync(string system, string user, string modelId, CancellationToken ct) =>
StreamAsync(system, JsonValue.Create(user), modelId, ct);
/// <summary>One user turn of content blocks (text, images).</summary>
public async IAsyncEnumerable<AnswerChunk> StreamAsync(string system, JsonNode userContent, string modelId, [EnumeratorCancellation] CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var body = BuildRequestBody(system, user);
var body = BuildRequestBody(system, userContent);
var stopwatch = Stopwatch.StartNew();
using var response = await PostWithRetryAsync(body, ct).ConfigureAwait(false);
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
@@ -133,7 +138,7 @@ internal sealed class MessagesTransport
}
}
private JsonObject BuildRequestBody(string system, string user) => new()
private JsonObject BuildRequestBody(string system, JsonNode userContent) => new()
{
["model"] = _options.Model,
["max_tokens"] = _options.MaxTokens,
@@ -143,7 +148,7 @@ internal sealed class MessagesTransport
new JsonObject
{
["role"] = "user",
["content"] = user,
["content"] = userContent,
},
},
["stream"] = true,
+8 -2
View File
@@ -79,13 +79,19 @@ public sealed class GiteaRepository : INoteStore
}
/// <summary>POST contents: Gitea answers 422 when the path already exists on the branch.</summary>
public async Task<StoreWrite> CreateAsync(string path, string text, string message, CancellationToken ct)
public Task<StoreWrite> CreateAsync(string path, string text, string message, CancellationToken ct) =>
CreateAsync(path, Encoding.UTF8.GetBytes(text), message, ct);
public Uri? BrowseUrl(string path) =>
new($"{_options.BaseUrl.TrimEnd('/')}/{_options.Repository}/src/branch/{_options.Branch}/{path}");
public async Task<StoreWrite> CreateAsync(string path, byte[] content, string message, CancellationToken ct)
{
var repo = GiteaUrls.EncodeSegments(_options.Repository);
var encodedPath = GiteaUrls.EncodeSegments(path);
var request = new
{
content = Convert.ToBase64String(Encoding.UTF8.GetBytes(text)),
content = Convert.ToBase64String(content),
message,
branch = _options.Branch,
};
@@ -24,28 +24,42 @@ public sealed class DepositFactUseCase(INoteStore store, TimeProvider clock)
var now = clock.GetUtcNow();
var datePrefix = now.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var slug = Slugify(fact.Title);
var text = BuildNote(fact, by, datePrefix);
var message = $"raw: {fact.Title}";
var path = $"raw/{datePrefix}-{slug}.md";
for (var attempt = 2; ; attempt++)
{
// Attachments take the note's stem, so they sort next to it and survive a suffix.
var stem = path[(path.LastIndexOf('/') + 1)..^3];
var names = fact.Attachments.Select((a, i) => $"{stem}-{i + 1}.{a.Extension}").ToList();
var text = BuildNote(fact, by, datePrefix, names);
StoreWrite written;
try
{
var written = await store.CreateAsync(path, text, message, ct);
return new FactReceipt(written.Path, written.Commit, now);
written = await store.CreateAsync(path, text, message, ct);
}
catch (DocumentExistsException) when (attempt <= MaxSuffixAttempts)
{
path = $"raw/{datePrefix}-{slug}-{attempt}.md";
continue;
}
for (var i = 0; i < names.Count; i++)
written = await store.CreateAsync($"raw/{names[i]}", fact.Attachments[i].Bytes, $"{message} (attachment {i + 1})", ct);
return new FactReceipt(path, written.Commit, now);
}
}
public static string BuildNote(RawSource fact, Identity by, string capturedDate)
public static string BuildNote(RawSource fact, Identity by, string capturedDate, IReadOnlyList<string>? attachments = null)
{
attachments ??= [];
var body = fact.Content;
if (!body.EndsWith('\n')) body += "\n";
if (attachments.Count > 0)
{
body += "\n";
for (var i = 0; i < attachments.Count; i++)
body += $"![{fact.Attachments[i].Caption ?? $"attachment {i + 1}"}]({attachments[i]})\n";
}
var sb = new StringBuilder();
sb.Append("---\n");
@@ -56,6 +70,7 @@ public sealed class DepositFactUseCase(INoteStore store, TimeProvider clock)
sb.Append("captured_by: ").Append(by.Name).Append('\n');
sb.Append("observed: ").Append(fact.ObservedAt.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)).Append('\n');
sb.Append("tags: [").Append(string.Join(", ", fact.Tags)).Append("]\n");
if (attachments.Count > 0) sb.Append("attachments: [").Append(string.Join(", ", attachments)).Append("]\n");
sb.Append("---\n");
sb.Append('\n');
sb.Append(body);
@@ -13,21 +13,22 @@ public sealed record RecordedStatement(FactDraft Draft, RawSource Fact, FactRece
/// </summary>
public sealed class RecordStatementUseCase(DepositFactUseCase deposit, TimeProvider clock)
{
public async Task<RecordedStatement> RecordAsync(string statement, Identity by, IFactDrafter drafter, CancellationToken ct)
public async Task<RecordedStatement> RecordAsync(string statement, IReadOnlyList<Attachment> attachments, Identity by, IFactDrafter drafter, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(statement);
ArgumentNullException.ThrowIfNull(attachments);
ArgumentNullException.ThrowIfNull(by);
ArgumentNullException.ThrowIfNull(drafter);
var now = clock.GetUtcNow();
var text = statement.Trim();
var draft = await drafter.DraftAsync(new FactStatement(text, by, now), ct);
var draft = await drafter.DraftAsync(new FactStatement(text, by, now) { Attachments = attachments }, ct);
var fact = new RawSource(
draft.Title,
$"stated by {by.Name} on {now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)} via the wiki",
Body(draft.Content, text),
now,
draft.Tags);
draft.Tags) { Attachments = attachments };
var errors = fact.Validate();
if (errors.Count > 0)
throw new InvalidOperationException($"the draft is not a valid raw source: {string.Join("; ", errors)}");
+20
View File
@@ -0,0 +1,20 @@
namespace Wiki.Core.Model;
/// <summary>A binary that belongs to a raw source and is kept next to it as evidence, a photo of a type label for instance.</summary>
public sealed record Attachment(string MediaType, byte[] Bytes, string? Caption = null)
{
public const int MaxBytes = 8 * 1024 * 1024;
private static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
{
["image/jpeg"] = "jpg",
["image/png"] = "png",
["image/webp"] = "webp",
["image/gif"] = "gif",
};
/// <summary>The file extension for a supported media type, or null.</summary>
public static string? ExtensionFor(string mediaType) => Extensions.TryGetValue(mediaType, out var e) ? e : null;
public string? Extension => ExtensionFor(MediaType);
}
+5 -1
View File
@@ -1,7 +1,11 @@
namespace Wiki.Core.Model;
/// <summary>What a person said, by whom and when. The drafter sees nothing else.</summary>
public sealed record FactStatement(string Text, Identity By, DateTimeOffset StatedAt);
public sealed record FactStatement(string Text, Identity By, DateTimeOffset StatedAt)
{
/// <summary>Pictures that are part of the statement, a type label for instance.</summary>
public IReadOnlyList<Attachment> Attachments { get; init; } = [];
}
/// <summary>A statement normalised into the fields of a raw source. Source and provenance are the application's to add.</summary>
public sealed record FactDraft(string Title, string Content, IReadOnlyList<string> Tags);
+10
View File
@@ -13,6 +13,9 @@ public sealed record RawSource(
{
public const int MinimumContentLength = 40;
/// <summary>Binaries stored next to the note as evidence. Empty for most facts.</summary>
public IReadOnlyList<Attachment> Attachments { get; init; } = [];
/// <summary>Validation errors, empty when the fact may be stored.</summary>
public IReadOnlyList<string> Validate()
{
@@ -21,6 +24,13 @@ public sealed record RawSource(
if (string.IsNullOrWhiteSpace(Source)) errors.Add("source is required: where the fact was observed or quoted from");
if (Content is null || Content.Trim().Length < MinimumContentLength)
errors.Add($"content must be at least {MinimumContentLength} characters of observation");
for (var i = 0; i < Attachments.Count; i++)
{
var a = Attachments[i];
if (a.Extension is null) errors.Add($"attachment {i + 1}: unsupported media type {a.MediaType}");
if (a.Bytes.Length == 0) errors.Add($"attachment {i + 1}: empty");
if (a.Bytes.Length > Attachment.MaxBytes) errors.Add($"attachment {i + 1}: larger than {Attachment.MaxBytes / (1024 * 1024)} MiB");
}
return errors;
}
}
+6
View File
@@ -26,6 +26,12 @@ public interface INoteStore : IAdapterHealth
/// <summary>Creates a new document at the path on the current head. Throws <see cref="DocumentExistsException"/> when the path is taken.</summary>
Task<StoreWrite> CreateAsync(string path, string text, string message, CancellationToken ct);
/// <summary>Creates a new binary (an attachment) at the path. Same rules as the text overload.</summary>
Task<StoreWrite> CreateAsync(string path, byte[] content, string message, CancellationToken ct);
/// <summary>Where a person can look at the path in the store's own UI, or null when the store has none.</summary>
Uri? BrowseUrl(string path);
}
/// <summary>Text to vector. One instance per configured model.</summary>
+1
View File
@@ -18,6 +18,7 @@
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="@Assets["facts.js"]"></script>
</body>
</html>
+60 -3
View File
@@ -6,12 +6,13 @@
@inject IFactDrafter Drafter
@inject NoteQueries Notes
@inject ISnackbar Snackbar
@inject IJSRuntime JS
<PageTitle>Facts</PageTitle>
<MudText Typo="Typo.h4" GutterBottom="true">Facts</MudText>
<MudText Typo="Typo.body2" Class="mb-4" Color="Color.Secondary">
Say what you observed, in one sentence or a few. @Drafter.ModelId turns it into a raw source under your name, with your words kept verbatim, and the nightly distill turns that into wiki notes. Observations only, never hypotheses.
Say what you observed, in one sentence or a few, and add or paste a picture when the thing itself says it best, a type label for instance. @Drafter.ModelId turns it into a raw source under your name, with your words kept verbatim and the pictures next to the note, and the nightly distill turns that into wiki notes. Observations only, never hypotheses.
</MudText>
@if (_user is null)
@@ -22,8 +23,24 @@ else
{
<form @onsubmit="RecordAsync" @onsubmit:preventDefault="true">
<MudTextField T="string" @bind-Value="_statement" Immediate="true" Lines="3" Label="What did you observe?" Variant="Variant.Outlined" Disabled="@_running" Class="mb-2" />
<InputFile id="fact-files" OnChange="OnFilesAsync" multiple accept="image/*" style="display:none" />
@if (_images.Count > 0)
{
<MudStack Row Wrap="Wrap.Wrap" Spacing="2" Class="mb-2">
@foreach (var img in _images)
{
<MudPaper Outlined="true" Class="pa-1" Style="position: relative">
<img src="@img.DataUrl" alt="attachment" style="max-height: 140px; display: block" />
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small" Style="position: absolute; top: 0; right: 0" OnClick="@(() => _images.Remove(img))" Disabled="@_running" />
</MudPaper>
}
</MudStack>
}
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
<MudText Typo="Typo.caption" Color="Color.Secondary">captured_by: @_user</MudText>
<MudStack Row AlignItems="AlignItems.Center" Spacing="2">
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Outlined.AddPhotoAlternate" OnClick="PickAsync" Disabled="@_running">Add picture</MudButton>
<MudText Typo="Typo.caption" Color="Color.Secondary">or paste one · captured_by: @_user</MudText>
</MudStack>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Disabled="@(_running || string.IsNullOrWhiteSpace(_statement))">Record</MudButton>
</MudStack>
</form>
@@ -91,6 +108,9 @@ else
private string? _error;
private RecordedStatement? _recorded;
private IReadOnlyList<RawQueueEntry>? _raw;
private readonly List<PendingImage> _images = [];
private sealed record PendingImage(string MediaType, byte[] Bytes, string DataUrl);
protected override async Task OnInitializedAsync()
{
@@ -102,8 +122,43 @@ else
await LoadRawAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && _user is not null)
await JS.InvokeVoidAsync("wikiFacts.listenPaste", "fact-files");
}
private async Task LoadRawAsync() => _raw = await Notes.RawQueueAsync(CancellationToken.None);
private async Task PickAsync() => await JS.InvokeVoidAsync("wikiFacts.pick", "fact-files");
private async Task OnFilesAsync(InputFileChangeEventArgs e)
{
foreach (var file in e.GetMultipleFiles(10))
{
if (!file.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add($"{file.Name}: not an image", Severity.Warning);
continue;
}
try
{
// Resized in the browser: labels stay legible at 1600px and the upload stays small.
var resized = await file.RequestImageFileAsync("image/jpeg", 1600, 1600);
using var ms = new MemoryStream();
await using (var stream = resized.OpenReadStream(Attachment.MaxBytes))
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
_images.Add(new PendingImage("image/jpeg", bytes, "data:image/jpeg;base64," + Convert.ToBase64String(bytes)));
}
catch (IOException ex)
{
Snackbar.Add($"{file.Name}: {ex.Message}", Severity.Error);
}
}
StateHasChanged();
}
private async Task RecordAsync()
{
if (_running || _user is null || string.IsNullOrWhiteSpace(_statement)) return;
@@ -112,9 +167,11 @@ else
_recorded = null;
try
{
_recorded = await Record.RecordAsync(_statement, new Identity(_user, IdentityKind.Human), Drafter, CancellationToken.None);
var attachments = _images.Select(i => new Attachment(i.MediaType, i.Bytes)).ToList();
_recorded = await Record.RecordAsync(_statement, attachments, new Identity(_user, IdentityKind.Human), Drafter, CancellationToken.None);
Snackbar.Add($"recorded {_recorded.Receipt.Path}", Severity.Success);
_statement = null;
_images.Clear();
await LoadRawAsync();
}
catch (Exception e) when (e is HttpRequestException or InvalidOperationException)
+21
View File
@@ -1,6 +1,7 @@
@page "/raw/{slug}"
@rendermode InteractiveServer
@inject NoteQueries Notes
@inject INoteStore Store
<PageTitle>@(_doc?.Title ?? Slug)</PageTitle>
@@ -33,6 +34,19 @@ else
<div class="wiki-body">
@((MarkupString)_html!)
</div>
@if (Attachments(_doc) is { Count: > 0 } attachments)
{
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-1">Attachments</MudText>
<MudList T="string" Dense="true">
@foreach (var a in attachments)
{
var href = Store.BrowseUrl($"raw/{a}")?.ToString();
<MudListItem T="string" Icon="@Icons.Material.Outlined.Image">
@if (href is null) { @a } else { <MudLink Href="@href" Target="_blank">@a</MudLink> }
</MudListItem>
}
</MudList>
}
}
@code {
@@ -52,4 +66,11 @@ else
}
private static string? Scalar(Document d, string key) => d.Frontmatter.TryGetValue(key, out var v) ? v.Scalar : null;
private static IReadOnlyList<string> Attachments(Document d)
{
if (!d.Frontmatter.TryGetValue("attachments", out var v)) return [];
if (v.List is { } list) return list;
return (v.Scalar ?? "").Trim('[', ']').Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
}
+31
View File
@@ -0,0 +1,31 @@
// Pasted images become files on the page's hidden <input type="file">, so Blazor's InputFile
// streams them like a picked file. One document listener; it looks the input up per paste.
window.wikiFacts = (() => {
let armed = false;
return {
listenPaste(inputId) {
if (armed) return;
armed = true;
document.addEventListener('paste', (ev) => {
const input = document.getElementById(inputId);
if (!input || !ev.clipboardData) return;
const files = [];
for (const item of ev.clipboardData.items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const f = item.getAsFile();
if (f) files.push(f);
}
}
if (files.length === 0) return;
ev.preventDefault();
const dt = new DataTransfer();
for (const f of files) dt.items.add(f);
input.files = dt.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
});
},
pick(inputId) {
document.getElementById(inputId)?.click();
},
};
})();
+12
View File
@@ -44,6 +44,18 @@ public sealed class FakeStore : INoteStore
return Task.FromResult(new StoreWrite(path, Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(text)))));
}
public Dictionary<string, byte[]> Binaries { get; } = new();
public Task<StoreWrite> CreateAsync(string path, byte[] content, string message, CancellationToken ct)
{
if (Files.ContainsKey(path) || Binaries.ContainsKey(path)) throw new DocumentExistsException(path);
Binaries[path] = content;
Created.Add(($"{message}", path));
return Task.FromResult(new StoreWrite(path, Convert.ToHexStringLower(SHA1.HashData(content))));
}
public Uri? BrowseUrl(string path) => null;
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
}
@@ -34,7 +34,7 @@ public class RecordStatementUseCaseTests
var drafter = new FakeFactDrafter();
var useCase = new RecordStatementUseCase(new DepositFactUseCase(store, new FakeTimeProvider(Now)), new FakeTimeProvider(Now));
var recorded = await useCase.RecordAsync(" " + Statement + "\n", By, drafter, CancellationToken.None);
var recorded = await useCase.RecordAsync(" " + Statement + "\n", [], By, drafter, CancellationToken.None);
var sent = Assert.Single(drafter.Statements);
Assert.Equal(Statement, sent.Text);
@@ -55,7 +55,7 @@ public class RecordStatementUseCaseTests
var drafter = new FakeFactDrafter { Draft = new FactDraft(" ", "the verbatim statement always makes the body long enough; only a missing title fails", []) };
var useCase = new RecordStatementUseCase(new DepositFactUseCase(store, new FakeTimeProvider(Now)), new FakeTimeProvider(Now));
await Assert.ThrowsAsync<InvalidOperationException>(() => useCase.RecordAsync(Statement, By, drafter, CancellationToken.None));
await Assert.ThrowsAsync<InvalidOperationException>(() => useCase.RecordAsync(Statement, [], By, drafter, CancellationToken.None));
Assert.Empty(store.Files);
}
@@ -64,7 +64,25 @@ public class RecordStatementUseCaseTests
{
var drafter = new FakeFactDrafter();
var useCase = new RecordStatementUseCase(new DepositFactUseCase(new FakeStore(), new FakeTimeProvider(Now)), new FakeTimeProvider(Now));
await Assert.ThrowsAsync<ArgumentException>(() => useCase.RecordAsync(" \n", By, drafter, CancellationToken.None));
await Assert.ThrowsAsync<ArgumentException>(() => useCase.RecordAsync(" \n", [], By, drafter, CancellationToken.None));
Assert.Empty(drafter.Statements);
}
[Fact]
public async Task PicturesReachTheDrafterAndLandNextToTheNote()
{
var store = new FakeStore();
var drafter = new FakeFactDrafter { Draft = new FactDraft("Geschirrspüler Typenschild", "Der Geschirrspüler ist ein Bosch SMV4HCX48E, Seriennummer [unreadable].", ["dishwasher"]) };
var useCase = new RecordStatementUseCase(new DepositFactUseCase(store, new FakeTimeProvider(Now)), new FakeTimeProvider(Now));
var photo = new Attachment("image/jpeg", [0xFF, 0xD8, 0xFF, 1, 2, 3], "type label");
var recorded = await useCase.RecordAsync("Das ist das Typenschild unseres Geschirrspülers.", [photo], By, drafter, CancellationToken.None);
Assert.Same(photo, Assert.Single(Assert.Single(drafter.Statements).Attachments));
Assert.Equal("raw/2026-09-25-geschirrsp-ler-typenschild.md", recorded.Receipt.Path);
Assert.Equal(photo.Bytes, store.Binaries["raw/2026-09-25-geschirrsp-ler-typenschild-1.jpg"]);
var text = store.Files[recorded.Receipt.Path];
Assert.Contains("attachments: [2026-09-25-geschirrsp-ler-typenschild-1.jpg]\n", text);
Assert.EndsWith("![type label](2026-09-25-geschirrsp-ler-typenschild-1.jpg)\n", text);
}
}
@@ -26,6 +26,25 @@ public sealed class AnthropicFactDrafterContractTests : FactDrafterContract
return Task.FromResult(drafter);
}
[Fact]
public async Task PicturesTravelAsBase64ImageBlocksBeforeTheText()
{
var handler = new FakeAnthropicHandler { ResponseText = DraftJson };
var drafter = new AnthropicFactDrafter(new HttpClient(handler), new AnthropicOptions { BaseUrl = "https://anthropic.test", ApiKey = "k" }, delay: (_, _) => Task.CompletedTask);
var statement = Statement() with { Attachments = [new Wiki.Core.Model.Attachment("image/jpeg", [1, 2, 3])] };
await drafter.DraftAsync(statement, CancellationToken.None);
using var request = System.Text.Json.JsonDocument.Parse(handler.LastRequestJson!);
var content = request.RootElement.GetProperty("messages")[0].GetProperty("content");
Assert.Equal(2, content.GetArrayLength());
Assert.Equal("image", content[0].GetProperty("type").GetString());
Assert.Equal("image/jpeg", content[0].GetProperty("source").GetProperty("media_type").GetString());
Assert.Equal("AQID", content[0].GetProperty("source").GetProperty("data").GetString());
Assert.Equal("text", content[1].GetProperty("type").GetString());
Assert.Contains("1 image(s) attached", content[1].GetProperty("text").GetString());
}
[Fact]
public void ParseTakesTheObjectOutOfSurroundingTextAndNormalisesTags()
{
+12
View File
@@ -76,6 +76,18 @@ public abstract class NoteStoreContract
store.CreateAsync("wiki/udm-dns.md", "other", "x", CancellationToken.None));
}
[Fact]
public async Task CreatesABinaryAndRefusesAnExistingPath()
{
var store = await CreateStoreAsync();
var bytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3 };
var written = await store.CreateAsync("raw/2026-09-24-new-1.jpg", bytes, "raw: New (attachment 1)", CancellationToken.None);
Assert.Equal("raw/2026-09-24-new-1.jpg", written.Path);
Assert.False(string.IsNullOrWhiteSpace(written.Commit));
await Assert.ThrowsAsync<DocumentExistsException>(() =>
store.CreateAsync("raw/2026-09-24-new-1.jpg", bytes, "x", CancellationToken.None));
}
[Fact]
public async Task HealthReportsOk()
{
+11
View File
@@ -41,6 +41,17 @@ public sealed class FakeStore : INoteStore
return Task.FromResult(new StoreWrite(path, "c"));
}
public Dictionary<string, byte[]> Binaries { get; } = new();
public Task<StoreWrite> CreateAsync(string path, byte[] content, string message, CancellationToken ct)
{
if (Files.ContainsKey(path) || Binaries.ContainsKey(path)) throw new DocumentExistsException(path);
Binaries[path] = content;
return Task.FromResult(new StoreWrite(path, Convert.ToHexStringLower(SHA1.HashData(content))));
}
public Uri? BrowseUrl(string path) => null;
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
}