119 lines
5.3 KiB
Python
119 lines
5.3 KiB
Python
"""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"<hugin_skill paket=\"{package.id}\" version=\"{package.version}\">\n"
|
|
f"{untrusted.neutralize(package.content.strip())}\n</hugin_skill>"
|
|
+ (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"<hugin_datei paket=\"{package.id}\" pfad=\"{args.path}\">\n"
|
|
f"{untrusted.neutralize(text[:MAX_FILE_CHARS])}{cut}\n</hugin_datei>")
|
|
|
|
|
|
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))
|