#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:
@@ -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.
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user