Skip to content

Commit 51faeca

Browse files
peopleworksclaude
andcommitted
Docs + Phase D+: optional server-side automatic web search (off by default)
README fully rewritten to cover both tools (AI-writing linter + Originality checker A–D), the predictability meter, current architecture, and the optional web-search configuration. Phase D+ — an optional automatic web spot-check for presentations, OFF unless the operator configures it (the on-device one-click searches remain the default): - Server: WebSearchOptions ("WebSearch" section, disabled by default; key from config or BRAVE_API_KEY env — the key never touches the browser). WebSearchService + BraveSearchProvider (provider-abstracted), POST /api/webcheck, and GET / advertises webSearchReady. Quota guards: per-phrase cache, caps on phrases/doc and results/phrase, and verbatim verification so only genuine matches are badged. - Client: WebCheckClient + an "Auto web search: ON" panel that appears only when the server advertises the capability; hits show URL, snippet and a verbatim badge. Fail-safe: disabled/quota/error falls back to the manual one-click searches — it never breaks. No secrets committed (feature ships disabled). Client is safe to deploy as-is; the server feature stays inert until an operator sets a key + redeploys. Full suite 40/40 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ef0b919 commit 51faeca

10 files changed

Lines changed: 512 additions & 89 deletions

File tree

README.md

Lines changed: 113 additions & 86 deletions
Large diffs are not rendered by default.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace SignsOfAI.Perplexity.Api.Config;
2+
3+
/// <summary>
4+
/// Config for the <b>optional</b> automatic web spot-check (Phase D+). It is <b>off unless configured</b>:
5+
/// the on-device, one-click-search experience is the default. When an operator supplies a search provider
6+
/// and key (here or via the <c>BRAVE_API_KEY</c> environment variable — the key never touches the browser),
7+
/// the server can automatically report web pages that contain a passage verbatim. Useful for live demos /
8+
/// presentations; kept behind config so the hosted default stays dependency-free.
9+
/// </summary>
10+
public sealed class WebSearchOptions
11+
{
12+
/// <summary>Master switch. Even when true, the feature only activates if a key resolves.</summary>
13+
public bool Enabled { get; init; }
14+
15+
/// <summary>Search provider. Currently "brave" (Brave Search API); provider-abstracted for others.</summary>
16+
public string Provider { get; init; } = "brave";
17+
18+
/// <summary>API key. Prefer the environment variable over committing it to appsettings.</summary>
19+
public string? ApiKey { get; init; }
20+
21+
/// <summary>Cap on how many distinctive phrases we search per document (quota control for live demos).</summary>
22+
public int MaxPhrasesPerDoc { get; init; } = 8;
23+
24+
/// <summary>Cap on results returned per phrase.</summary>
25+
public int MaxResultsPerPhrase { get; init; } = 5;
26+
27+
/// <summary>The key from config, falling back to the BRAVE_API_KEY environment variable.</summary>
28+
public string? ResolveKey() =>
29+
!string.IsNullOrWhiteSpace(ApiKey) ? ApiKey : Environment.GetEnvironmentVariable("BRAVE_API_KEY");
30+
31+
/// <summary>The feature is genuinely usable (enabled AND a key is present).</summary>
32+
public bool IsActive => Enabled && !string.IsNullOrWhiteSpace(ResolveKey());
33+
}

src/SignsOfAI.Perplexity.Api/Model/Contracts.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,39 @@ public sealed record ServiceInfo
7272
public bool EmbeddingReady { get; init; }
7373
/// <summary>Selectable embedding models (for the paraphrase check). Empty when the feature is off.</summary>
7474
public ModelInfo[] EmbeddingModels { get; init; } = [];
75+
/// <summary>The operator has configured an automatic web search (Phase D+). Off by default.</summary>
76+
public bool WebSearchReady { get; init; }
77+
}
78+
79+
/// <summary>One web page returned for a searched phrase.</summary>
80+
public sealed record WebHit
81+
{
82+
public string Url { get; init; } = "";
83+
public string Title { get; init; } = "";
84+
public string Snippet { get; init; } = "";
85+
/// <summary>The returned snippet visibly contains the phrase (strong evidence, not just a loose match).</summary>
86+
public bool Verbatim { get; init; }
87+
}
88+
89+
/// <summary>The web hits found for one distinctive phrase.</summary>
90+
public sealed record PhraseHits
91+
{
92+
public string Phrase { get; init; } = "";
93+
public WebHit[] Hits { get; init; } = [];
94+
}
95+
96+
/// <summary>Request body for POST /api/webcheck.</summary>
97+
public sealed record WebCheckRequest
98+
{
99+
/// <summary>Distinctive phrases to look up on the web (exact-phrase). Required, non-empty.</summary>
100+
public string[] Phrases { get; init; } = [];
101+
}
102+
103+
/// <summary>Response body for POST /api/webcheck.</summary>
104+
public sealed record WebCheckResponse
105+
{
106+
public PhraseHits[] Results { get; init; } = [];
107+
public long ElapsedMs { get; init; }
75108
}
76109

77110
/// <summary>Request body for POST /api/embed — the paraphrase/semantic-similarity embedding endpoint.</summary>
@@ -106,4 +139,6 @@ public sealed record EmbedResponse
106139
[JsonSerializable(typeof(EmbedRequest))]
107140
[JsonSerializable(typeof(EmbedResponse))]
108141
[JsonSerializable(typeof(float[][]))]
142+
[JsonSerializable(typeof(WebCheckRequest))]
143+
[JsonSerializable(typeof(WebCheckResponse))]
109144
public partial class ApiJsonContext : JsonSerializerContext;

src/SignsOfAI.Perplexity.Api/Program.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
builder.Services.AddSingleton<EmbeddingRegistry>();
2323
builder.Services.AddHostedService<EmbeddingLifecycleService>();
2424

25+
// ── Optional automatic web spot-check (Phase D+) — inert unless an operator configured a provider + key ──
26+
var webSearchOptions = builder.Configuration.GetSection("WebSearch").Get<WebSearchOptions>() ?? new WebSearchOptions();
27+
builder.Services.AddSingleton(webSearchOptions);
28+
builder.Services.AddSingleton<SignsOfAI.Perplexity.Api.Search.WebSearchService>();
29+
2530
// Source-generated JSON so we stay trim/AOT-friendly.
2631
builder.Services.Configure<JsonOptions>(o =>
2732
o.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default));
@@ -36,7 +41,7 @@
3641
app.UseCors();
3742

3843
// ── Endpoints ─────────────────────────────────────────────────────────────────
39-
app.MapGet("/", (PerplexityRegistry reg, EmbeddingRegistry embed) => Results.Ok(new ServiceInfo
44+
app.MapGet("/", (PerplexityRegistry reg, EmbeddingRegistry embed, SignsOfAI.Perplexity.Api.Search.WebSearchService web) => Results.Ok(new ServiceInfo
4045
{
4146
ModelReady = reg.Default.FilesReady,
4247
Languages = [.. reg.Default.Profile.Baselines.Keys],
@@ -51,6 +56,7 @@
5156
Id = e.ModelId, Label = e.Profile.Label, Note = e.Profile.Note,
5257
IsDefault = e == embed.Default, Loaded = e.IsLoaded,
5358
})],
59+
WebSearchReady = web.IsActive,
5460
}));
5561

5662
app.MapGet("/healthz", (PerplexityRegistry reg) =>
@@ -127,4 +133,16 @@
127133
}
128134
});
129135

136+
// ── Optional automatic web spot-check (Phase D+) ──
137+
app.MapPost("/api/webcheck", async (WebCheckRequest req, SignsOfAI.Perplexity.Api.Search.WebSearchService web, CancellationToken ct) =>
138+
{
139+
if (!web.IsActive) return Results.Json(new { enabled = false }, statusCode: 404);
140+
if (req.Phrases is null || req.Phrases.Length == 0)
141+
return Results.BadRequest(new { error = "phrases is required" });
142+
143+
var sw = System.Diagnostics.Stopwatch.StartNew();
144+
var results = await web.CheckAsync(req.Phrases, ct);
145+
return Results.Ok(new WebCheckResponse { Results = [.. results], ElapsedMs = sw.ElapsedMilliseconds });
146+
});
147+
130148
app.Run();
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
using System.Collections.Concurrent;
2+
using System.Globalization;
3+
using System.Text;
4+
using System.Text.Json;
5+
using SignsOfAI.Perplexity.Api.Config;
6+
using SignsOfAI.Perplexity.Api.Model;
7+
8+
namespace SignsOfAI.Perplexity.Api.Search;
9+
10+
/// <summary>A search backend that finds web pages for an exact phrase.</summary>
11+
public interface IWebSearchProvider
12+
{
13+
Task<IReadOnlyList<WebHit>> SearchAsync(string phrase, int count, CancellationToken ct);
14+
}
15+
16+
/// <summary>
17+
/// Orchestrates the optional automatic web spot-check: caps how much we search (quota safety for live
18+
/// demos), caches per-phrase results in memory so re-runs don't burn quota, and verifies whether each
19+
/// returned snippet actually contains the phrase so we only badge genuine verbatim matches. Entirely
20+
/// inert unless an operator configured a provider + key (see <see cref="WebSearchOptions"/>).
21+
/// </summary>
22+
public sealed class WebSearchService
23+
{
24+
private readonly WebSearchOptions _options;
25+
private readonly IWebSearchProvider? _provider;
26+
private readonly ConcurrentDictionary<string, IReadOnlyList<WebHit>> _cache = new();
27+
28+
public WebSearchService(WebSearchOptions options, ILogger<WebSearchService> log)
29+
{
30+
_options = options;
31+
if (!options.IsActive) return;
32+
var key = options.ResolveKey()!;
33+
_provider = options.Provider.ToLowerInvariant() switch
34+
{
35+
"brave" => new BraveSearchProvider(key, log),
36+
_ => new BraveSearchProvider(key, log), // default provider; abstracted for others
37+
};
38+
}
39+
40+
public bool IsActive => _provider is not null;
41+
42+
public async Task<IReadOnlyList<PhraseHits>> CheckAsync(IReadOnlyList<string> phrases, CancellationToken ct)
43+
{
44+
if (_provider is null) return [];
45+
var results = new List<PhraseHits>();
46+
foreach (var raw in phrases.Take(_options.MaxPhrasesPerDoc))
47+
{
48+
var phrase = (raw ?? "").Trim();
49+
if (phrase.Length < 8) continue; // too short to be a meaningful fingerprint
50+
ct.ThrowIfCancellationRequested();
51+
52+
if (!_cache.TryGetValue(phrase, out var hits))
53+
{
54+
var found = await _provider.SearchAsync(phrase, _options.MaxResultsPerPhrase, ct);
55+
hits = found.Select(h => h with { Verbatim = SnippetContains(h, phrase) }).ToArray();
56+
_cache[phrase] = hits;
57+
}
58+
results.Add(new PhraseHits { Phrase = phrase, Hits = [.. hits] });
59+
}
60+
return results;
61+
}
62+
63+
/// <summary>True when the hit's snippet/title visibly contains the phrase (accent/case-insensitive).</summary>
64+
private static bool SnippetContains(WebHit hit, string phrase)
65+
{
66+
var hay = Fold(hit.Snippet + " " + hit.Title);
67+
return hay.Contains(Fold(phrase), StringComparison.Ordinal);
68+
}
69+
70+
internal static string Fold(string s)
71+
{
72+
var sb = new StringBuilder(s.Length);
73+
foreach (var c in s.ToLowerInvariant().Normalize(NormalizationForm.FormD))
74+
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)
75+
sb.Append(char.IsWhiteSpace(c) ? ' ' : c);
76+
// collapse runs of spaces
77+
var collapsed = new StringBuilder(sb.Length);
78+
bool prevSpace = false;
79+
foreach (var c in sb.ToString())
80+
{
81+
if (c == ' ') { if (!prevSpace) collapsed.Append(' '); prevSpace = true; }
82+
else { collapsed.Append(c); prevSpace = false; }
83+
}
84+
return collapsed.ToString().Trim();
85+
}
86+
}
87+
88+
/// <summary>Brave Search API provider. Queries the exact (quoted) phrase and returns the web results.</summary>
89+
public sealed class BraveSearchProvider : IWebSearchProvider
90+
{
91+
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(12) };
92+
private readonly string _apiKey;
93+
private readonly ILogger _log;
94+
95+
public BraveSearchProvider(string apiKey, ILogger log) { _apiKey = apiKey; _log = log; }
96+
97+
public async Task<IReadOnlyList<WebHit>> SearchAsync(string phrase, int count, CancellationToken ct)
98+
{
99+
try
100+
{
101+
var q = Uri.EscapeDataString("\"" + phrase + "\"");
102+
var url = $"https://api.search.brave.com/res/v1/web/search?q={q}&count={Math.Clamp(count, 1, 20)}";
103+
using var req = new HttpRequestMessage(HttpMethod.Get, url);
104+
req.Headers.TryAddWithoutValidation("X-Subscription-Token", _apiKey);
105+
req.Headers.TryAddWithoutValidation("Accept", "application/json");
106+
107+
using var resp = await Http.SendAsync(req, ct);
108+
if (!resp.IsSuccessStatusCode)
109+
{
110+
_log.LogWarning("Brave search returned {Status} for a phrase query.", (int)resp.StatusCode);
111+
return [];
112+
}
113+
114+
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
115+
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
116+
if (!doc.RootElement.TryGetProperty("web", out var web) ||
117+
!web.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array)
118+
return [];
119+
120+
var hits = new List<WebHit>();
121+
foreach (var r in results.EnumerateArray())
122+
{
123+
var u = Str(r, "url");
124+
if (string.IsNullOrWhiteSpace(u)) continue;
125+
hits.Add(new WebHit
126+
{
127+
Url = u,
128+
Title = StripHtml(Str(r, "title")),
129+
Snippet = StripHtml(Str(r, "description")),
130+
});
131+
if (hits.Count >= count) break;
132+
}
133+
return hits;
134+
}
135+
catch (Exception ex)
136+
{
137+
_log.LogWarning(ex, "Brave search failed; falling back to no results.");
138+
return [];
139+
}
140+
}
141+
142+
private static string Str(JsonElement e, string prop) =>
143+
e.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
144+
145+
private static string StripHtml(string s)
146+
{
147+
if (string.IsNullOrEmpty(s) || s.IndexOf('<') < 0) return s;
148+
var sb = new StringBuilder(s.Length);
149+
bool inTag = false;
150+
foreach (var c in s)
151+
{
152+
if (c == '<') inTag = true;
153+
else if (c == '>') inTag = false;
154+
else if (!inTag) sb.Append(c);
155+
}
156+
return sb.ToString();
157+
}
158+
}

0 commit comments

Comments
 (0)