"""Tools für Pakete aus Hugin (→ Konzept 7.9): Skills laden und Rückmeldung an Hugin geben.
Angeboten nur, wenn der Workspace passende Pakete festgeschrieben hat (Arten hugin:skill bzw. hugin).
Die Inhalte sind in Hugin geprüft, werden aber wie alle Texte im Prompt entschärft und verleihen
keine Rechte.
"""
from typing import Literal
from pydantic import Field
from sqlalchemy import func, select
from app.core.database import Database
from app.hugin import service as hugin
from app.hugin.client import HuginError
from app.security import untrusted
from app.tools.context import ToolContext
from app.tools.models import ToolCall
from app.tools.registry import Args, Tool, ToolError, register
from app.workspaces.models import Workspace
SKILLS, PACKAGES = "hugin:skill", "hugin"
MAX_FILE_CHARS = 20000
# Rückmeldungen je Antwort; Hugin begrenzt zusätzlich je Key
MAX_FEEDBACK = 3
def kinds(packages: hugin.Loaded) -> set[str]:
"""Welche dieser Tools ein Run angeboten bekommt."""
return ({PACKAGES} if packages.packages else set()) | ({SKILLS} if packages.of("skill") else set())
def loaded(context: ToolContext) -> hugin.Loaded:
if context.db is None:
raise ToolError("Pakete aus Hugin gibt es nur im Workspace.")
with context.db.session() as session:
return hugin.load(session, context.workspace_id)
def skill(context: ToolContext, name: str) -> hugin.Package:
found = loaded(context)
package = found.get("skill", name)
if package is None:
known = ", ".join(p.id for p in found.of("skill")) or "keine"
raise ToolError(f"Den Skill {name} gibt es in diesem Workspace nicht. Verfügbar: {known}.")
return package
class LoadSkillArgs(Args):
name: str = Field(min_length=1, max_length=64, description="ID des Skills, wie in der Liste im System-Prompt.")
def load_skill(context: ToolContext, args: LoadSkillArgs) -> str:
package = skill(context, args.name)
files = "\n".join(f"- {p}" for p in package.helper_files)
return (f"\n"
f"{untrusted.neutralize(package.content.strip())}\n"
+ (f"\nHilfsdateien (mit read_skill_file lesen):\n{files}" if files else ""))
class ReadSkillFileArgs(Args):
name: str = Field(min_length=1, max_length=64, description="ID des Skills.")
path: str = Field(min_length=1, max_length=300, description="Pfad der Hilfsdatei, wie von load_skill genannt.")
def read_skill_file(context: ToolContext, args: ReadSkillFileArgs) -> str:
package = skill(context, args.name)
if args.path not in package.helper_files:
raise ToolError(f"Die Datei {args.path} gibt es im Skill {package.id} nicht.")
data = package.files[args.path]
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
return f"{args.path} ist keine Textdatei ({len(data)} Bytes)."
cut = f"\n[… gekürzt nach {MAX_FILE_CHARS} Zeichen]" if len(text) > MAX_FILE_CHARS else ""
return (f"\n"
f"{untrusted.neutralize(text[:MAX_FILE_CHARS])}{cut}\n")
class FeedbackArgs(Args):
package: str = Field(min_length=1, max_length=64, description="ID des Pakets aus Hugin.")
kind: Literal["unclear", "wrong", "deprecated", "suggestion"] = Field(
description="unclear (unklar), wrong (falsch), deprecated (veraltet) oder suggestion (Vorschlag).")
text: str = Field(min_length=10, max_length=2000,
description="Konkret: was steht im Paket, was war unklar oder richtig? Keine vertraulichen Daten.")
def feedback(context: ToolContext, args: FeedbackArgs) -> str:
package = next((p for p in loaded(context).packages if p.id == args.package), None)
if package is None:
raise ToolError(f"Das Paket {args.package} nutzt dieser Workspace nicht.")
db: Database = context.db
with db.session() as session:
sent = session.scalar(select(func.count()).select_from(ToolCall).where(
ToolCall.run_id == context.run_id, ToolCall.tool == "hugin_feedback", ToolCall.status == "ok")) or 0
if sent >= MAX_FEEDBACK:
raise ToolError(f"Höchstens {MAX_FEEDBACK} Rückmeldungen je Antwort.")
workspace = session.get(Workspace, context.workspace_id)
try:
with hugin.client(session, context.settings, context.vault, workspace) as client:
result = client.feedback(package.id, package.version, args.kind, args.text)
except HuginError as e:
raise ToolError(str(e)) from e
return f"Rückmeldung zu {package.id} {package.version} an Hugin übermittelt (Nr. {result.get('id')})."
register(Tool(
"load_skill", SKILLS,
"Einen Skill aus Hugin laden: die Anleitung für eine bestimmte Aufgabe, dazu die Liste seiner Hilfsdateien.",
LoadSkillArgs, "read", run=load_skill, untrusted=False))
register(Tool(
"read_skill_file", SKILLS,
"Eine Hilfsdatei eines Skills aus Hugin lesen (z. B. Vorlage oder Beispiel).",
ReadSkillFileArgs, "read", run=read_skill_file, untrusted=False))
register(Tool(
"hugin_feedback", PACKAGES,
"Rückmeldung an Hugin zu einem Paket (Regeln, Skill, Befehl), das in diesem Workspace gilt: unklar, falsch, "
"veraltet oder ein Vorschlag. Geht an die Autoren des Pakets.",
FeedbackArgs, "write_external", run=feedback, untrusted=False))