Freigabe-Hygiene (#631, #579, #609, #610) und JSON-Filter fuer WebFetch (#632)

#631: Derselbe Vorschlag (gleicher Agent, gleiches Werkzeug, gleiche Argumente unabhaengig
von Schluesselreihenfolge) wird nicht erneut angelegt - der Agent erfaehrt die bestehende
Nummer. Offene Vorschlaege verfallen nach 24 h (Status Expired).

#609: Werkzeuge koennen den Freigabeschluessel aus dem Inhalt bestimmen
(IAgentTool.StagingAktion). Database: SELECT/SHOW/DESCRIBE -> query, INSERT/UPDATE/DELETE
-> write, DDL -> schema; write und schema sind freigabepflichtig. Ausgefuehrt wird nach
Statement-Art, schreibende Statements melden die Zeilenzahl.

#579: Database-Vorpruefung (Zugriffsebene, Tabellenfreigabe, SqlGuard) - was das Werkzeug
ablehnen wuerde, kommt nicht mehr in die Freigabeliste.

#610: SqlGuard ueberspringt IF [NOT] EXISTS und erkennt DESCRIBE/DESC <tabelle>.

#632: WebFetch fetch mit filter liefert aus JSON/JSONP nur passende Eintraege; beim
Blaettern durch grosses JSON empfiehlt der Hinweis den filter.

Version 0.1.10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-18 18:51:06 +02:00
co-authored by Claude Opus 5
parent f5731c50f8
commit f74e49ba35
18 changed files with 727 additions and 29 deletions
@@ -0,0 +1,104 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace ClawdDotNet.Tools.WebFetch;
/// <summary>
/// Holt aus einer grossen JSON-Antwort nur die Eintraege, die einen Suchtext enthalten (#632).
///
/// <para>Anlass: Die Unwetterwarnungen des DWD kommen als eine Datei fuer ganz Deutschland
/// (~60 KB, JSONP). Ein Agent, der nur Berlin brauchte, blaetterte alle fuenf Abschnitte durch -
/// 160.000 Tokens, weil jeder Abschnitt bei jedem weiteren Schritt erneut mitgeschickt wird.</para>
///
/// <para>Gesucht wird ohne Gross-/Kleinschreibung in Werten und Schluesselnamen. Treffer ist das
/// innerste Objekt, dessen eigener Wert passt - so kommt eine Warnung als Ganzes mit, nicht nur
/// das Feld "regionName". Passt ein Schluesselname (etwa eine Warnzellen-Id), kommt sein Wert mit.
/// JSONP ("callback({...});") wird ausgepackt.</para>
/// </summary>
public static class JsonFilter
{
public const int MaxTreffer = 200;
private static readonly Regex Jsonp = new(
@"^\s*[A-Za-z_$][\w$.]*\s*\((?<json>.*)\)\s*;?\s*$",
RegexOptions.Singleline | RegexOptions.Compiled);
public static (string Text, string Bereich, string? Hinweis) Anwenden(string body, string filter)
{
var roh = body.Trim();
if (Jsonp.Match(roh) is { Success: true } jsonp)
roh = jsonp.Groups["json"].Value;
JsonNode? wurzel;
try
{
wurzel = JsonNode.Parse(roh);
}
catch (JsonException)
{
return (body.Trim(), "roh", "filter nicht angewendet: Der Inhalt ist kein gueltiges JSON - ungefiltert geliefert.");
}
var treffer = new List<object>();
var gesamt = 0;
Suche(wurzel, "$", filter, treffer, ref gesamt);
var ergebnis = new { filter, treffer = gesamt, eintraege = treffer };
var text = JsonSerializer.Serialize(ergebnis, WebInhalt.JsonOptionen);
var hinweis = gesamt == 0
? $"Keine Eintraege mit '{filter}' gefunden (Antwort insgesamt {roh.Length} Zeichen)."
: gesamt > MaxTreffer
? $"{gesamt} Treffer fuer '{filter}', die ersten {MaxTreffer} geliefert - grenze den filter ein."
: $"{gesamt} Treffer fuer '{filter}' aus {roh.Length} Zeichen Gesamtantwort.";
return (text, "gefiltert", hinweis);
}
private static void Suche(JsonNode? knoten, string pfad, string filter, List<object> treffer, ref int gesamt)
{
switch (knoten)
{
case JsonObject obj:
if (obj.Any(p => p.Value is JsonValue v && (Passt(Text(v), filter) || Passt(p.Key, filter))))
{
Merke(pfad, obj, treffer, ref gesamt);
return;
}
foreach (var (schluessel, wert) in obj)
{
var unterpfad = $"{pfad}.{schluessel}";
if (Passt(schluessel, filter) && wert is JsonObject or JsonArray)
Merke(unterpfad, wert, treffer, ref gesamt);
else
Suche(wert, unterpfad, filter, treffer, ref gesamt);
}
break;
case JsonArray arr:
for (var i = 0; i < arr.Count; i++)
{
if (arr[i] is JsonValue v && Passt(Text(v), filter))
Merke($"{pfad}[{i}]", v, treffer, ref gesamt);
else
Suche(arr[i], $"{pfad}[{i}]", filter, treffer, ref gesamt);
}
break;
}
}
private static void Merke(string pfad, JsonNode wert, List<object> treffer, ref int gesamt)
{
gesamt++;
if (treffer.Count < MaxTreffer)
treffer.Add(new { pfad, wert = wert.DeepClone() });
}
private static string Text(JsonValue wert)
=> wert.TryGetValue<string>(out var s) ? s : wert.ToJsonString();
private static bool Passt(string text, string filter)
=> text.Contains(filter, StringComparison.OrdinalIgnoreCase);
}
@@ -21,7 +21,9 @@ public sealed class WebFetchTool : IAgentTool
den du brauchst - noch guenstiger. Linkziele (URLs) kommen nur mit links=true - nur
setzen, wenn du die Adressen wirklich brauchst. mode="full" liefert den ganzen Seitentext und kostet
ein Vielfaches: nur verwenden, wenn der Hauptinhalt fehlt oder unvollstaendig ist.
JSON, Text und XML kommen unveraendert. Lange Inhalte kommen in Abschnitten: die Antwort
JSON, Text und XML kommen unveraendert. Brauchst du aus einer grossen JSON-Antwort nur
bestimmte Eintraege (z.B. eine Stadt), nimm "filter": dann kommen nur die Objekte, die den
Text enthalten - viel guenstiger als Blaettern. Lange Inhalte kommen in Abschnitten: die Antwort
nennt nextOffset - nur weiterlesen, wenn der Rest fuer den Auftrag wirklich gebraucht wird.
rss: Titel, Link, Datum und kurze Beschreibung je Eintrag ("limit" begrenzt die Anzahl).
@@ -65,6 +67,8 @@ public sealed class WebFetchTool : IAgentTool
"description": "fetch: auto (Standard) = Hauptinhalt als Markdown; full = ganzer Seitentext, nur wenn auto nicht reicht" },
"links": { "type": "boolean",
"description": "fetch: Linkziele als [Text](URL) mitliefern (Standard false - kostet auf linkreichen Seiten viel)" },
"filter": { "type": "string",
"description": "fetch, nur JSON/JSONP: nur Eintraege liefern, die diesen Text enthalten (ohne Gross-/Kleinschreibung), z.B. 'Berlin'" },
"offset": { "type": "integer",
"description": "fetch: Fortsetzung eines langen Inhalts - Wert aus nextOffset der vorigen Antwort" },
"limit": { "type": "integer",
@@ -152,7 +156,8 @@ public sealed class WebFetchTool : IAgentTool
GetInt(input, "offset") ?? 0,
GetConfigInt(config, "maxContentChars", WebInhalt.StandardMaxZeichen),
mitLinks: input.ValueKind == JsonValueKind.Object
&& input.TryGetProperty("links", out var l) && l.ValueKind == JsonValueKind.True);
&& input.TryGetProperty("links", out var l) && l.ValueKind == JsonValueKind.True,
filter: GetString(input, "filter"));
// Stand der Daten: Last-Modified oder das Datum aus der Seite. Nicht mehr der
// Date-Header - der ist die Uhrzeit des Servers beim Abruf, also fetchedAt.
+20 -2
View File
@@ -57,7 +57,7 @@ public static class WebInhalt
public static AufbereiteterInhalt Aufbereiten(
string body, string? mediaType, Uri quelle, AbrufModus modus, string? selector,
int offset = 0, int maxZeichen = StandardMaxZeichen, bool mitLinks = false)
int offset = 0, int maxZeichen = StandardMaxZeichen, bool mitLinks = false, string? filter = null)
{
var art = Art(mediaType, body);
@@ -66,7 +66,12 @@ public static class WebInhalt
string? titel = null, sprache = null, kanonisch = null, hinweis = null;
DateTimeOffset? datum = null;
if (art != "html")
if (art != "html" && !string.IsNullOrWhiteSpace(filter))
{
(text, bereich, hinweis) = JsonFilter.Anwenden(body, filter.Trim());
if (bereich == "gefiltert") art = "json";
}
else if (art != "html")
{
text = body.Trim();
}
@@ -95,10 +100,23 @@ public static class WebInhalt
}
}
if (art == "html" && !string.IsNullOrWhiteSpace(filter))
{
const string nurJson = "filter gilt nur fuer JSON - fuer HTML nimm selector.";
hinweis = hinweis is null ? nurJson : $"{hinweis} {nurJson}";
}
var (teil, start, naechster) = Abschnitt(text, offset, maxZeichen);
if (naechster is not null)
{
var fortsetzung = $"Inhalt gekuerzt ({teil.Length} von {text.Length} Zeichen) - mit offset={naechster} weiterlesen, nur falls noetig.";
// #632: Jeder weitere Abschnitt bleibt im Kontext und wird bei jedem folgenden Schritt
// erneut bezahlt. Fuer die DWD-Warnungen las ein Agent fuenf Abschnitte - 160.000 Tokens
// fuer eine Frage nach einer einzigen Stadt.
if (art == "json" && bereich != "gefiltert")
fortsetzung += " Suchst du bestimmte Eintraege, hole sie mit 'filter' (z.B. filter=\"Berlin\") statt weiterzublaettern.";
hinweis = hinweis is null ? fortsetzung : $"{hinweis} {fortsetzung}";
}