From 7eeb0393a60f98b3299b43d9244e350a8da73c20 Mon Sep 17 00:00:00 2001 From: TyperBody Date: Mon, 28 Sep 2026 01:56:00 +0800 Subject: [PATCH] feat(operation-trace): collapsible retention policy and assistant trace tool - Collapse the retention policy section by default, showing a one-line summary when collapsed. - Add the owner/admin-only list_operation_logs assistant tool backed by audit.view. - Enforce per-tool permissions on both tool listing and invocation. - Add a substring search filter (search) to query_logs and the tool so callers can ask narrow questions (plugin name, account, verb) instead of dumping the full history. - Project records to a compact shape so a page stays under the assistant result budget. - Localize the new assistant tool label and retention summary across all locales. --- src/langbot/pkg/api/http/service/assistant.py | 4 + .../pkg/api/http/service/assistant_tools.py | 182 +++++++++++++++++- src/langbot/pkg/operation_trace/service.py | 19 ++ .../home/components/AssistantToolResult.tsx | 12 +- .../OperationTracePanel.tsx | 145 ++++++++------ web/src/i18n/locales/en-US.ts | 2 + web/src/i18n/locales/es-ES.ts | 2 + web/src/i18n/locales/ja-JP.ts | 2 + web/src/i18n/locales/ru-RU.ts | 2 + web/src/i18n/locales/th-TH.ts | 2 + web/src/i18n/locales/vi-VN.ts | 2 + web/src/i18n/locales/zh-Hans.ts | 2 + web/src/i18n/locales/zh-Hant.ts | 2 + 13 files changed, 309 insertions(+), 69 deletions(-) diff --git a/src/langbot/pkg/api/http/service/assistant.py b/src/langbot/pkg/api/http/service/assistant.py index 19701b9c8..74e7073ae 100644 --- a/src/langbot/pkg/api/http/service/assistant.py +++ b/src/langbot/pkg/api/http/service/assistant.py @@ -25,6 +25,10 @@ Do not claim the application has been tested: this experiment has no chat-test o After creation/configuration show the returned resource URL so the user can open the normal editor, upload documents and use its existing debug chat. If an operation failed or its result is unknown, do not repeat a write automatically; explain the result and ask the user to inspect the resource. +When the list_operation_logs tool is available (owner / admin only), use it to answer "who changed or +viewed what" questions with the recorded before/after fields instead of guessing. Always query it with +a narrow filter (search / resource_type / actor) rather than an unfiltered dump, and never repeat the +same empty query: if a filter matched nothing, widen it once instead of re-asking. """ diff --git a/src/langbot/pkg/api/http/service/assistant_tools.py b/src/langbot/pkg/api/http/service/assistant_tools.py index f5d468194..4d81f29e0 100644 --- a/src/langbot/pkg/api/http/service/assistant_tools.py +++ b/src/langbot/pkg/api/http/service/assistant_tools.py @@ -20,6 +20,129 @@ class ListResources(Arguments): kind: Literal['models', 'embedding_models', 'pipelines', 'knowledge_bases', 'knowledge_engines'] +#: Canonical resource types, mirrored from the trace service action table. The +#: enum lets the model pick a value that exists instead of guessing a string +#: that matches nothing. +_RESOURCE_TYPES = ( + 'bot', + 'adapter', + 'model_provider', + 'llm_model', + 'pipeline', + 'user', + 'workspace', + 'monitoring', + 'webhook', + 'api_key', + 'workspace_settings', + 'member', + 'member_invitation', + 'operation_log', + 'plugin', + 'plugin_page', + 'skill', + 'knowledge_base', + 'mcp_server', + 'runtime', + 'system', + 'resource', +) + + +class ListOperationLogs(Arguments): + """Read the Workspace operation trace (owner / admin only).""" + + #: Optional free-text filter is mapped to a substring match so the caller + #: can name a plugin, an account or a verb ("install") without knowing the + #: stored action key. Prefer this over ``action`` when unsure of the key. + search: str | None = Field(default=None, max_length=100) + #: Exact stored action key, e.g. ``plugin_install`` (see the action list in + #: the tool description). Only use it when the exact key is known. + action: str | None = Field(default=None, max_length=64) + resource_type: str | None = Field( + default=None, + max_length=64, + description='Optional resource type filter. Valid values: ' + ', '.join(_RESOURCE_TYPES) + '.', + ) + actor: str | None = Field( + default=None, + max_length=64, + description='Optional actor filter: an account UUID or part of the actor name.', + ) + limit: int = Field( + default=20, + ge=1, + le=50, + description='Maximum records to return. Keep it small (10-20) for a targeted answer.', + ) + + +#: Forensics fields the settings panel needs but an answer does not; dropping +#: them keeps a page of records far below the assistant's per-result budget. +_COMPACT_CHANGE_FIELDS = 6 +_COMPACT_CHANGE_CHARS = 160 + + +def _brief(value, limit: int = _COMPACT_CHANGE_CHARS): + """Render a diff value as a bounded string (mirrors the trace service).""" + + if value is None or isinstance(value, (int, float, bool)): + return value + text = value if isinstance(value, str) else str(value) + return text if len(text) <= limit else text[: max(limit - 1, 0)] + '…' + + +def _compact_operation_records(result: dict) -> dict: + """Project operation records onto the fields a "who did what" answer needs. + + ``query_logs`` returns the full panel payload -- hash chain, user agent, + payload details -- which is roughly a kilobyte per row. A whole page of + those overflows the assistant's per-result budget and arrives as a raw + truncated preview the model cannot read, which is why an answer can end up + claiming the data is unavailable. Only the actor, the action, the target, + the timestamp and the verification verdicts are kept here; the heavy + forensic columns stay available through the settings panel export. + """ + + def record(row: dict) -> dict: + changes = [ + { + 'field': change.get('field'), + 'before': _brief(change.get('before')), + 'after': _brief(change.get('after')), + } + for change in (row.get('changes') or [])[:_COMPACT_CHANGE_FIELDS] + ] + return { + 'id': row.get('id'), + 'created_at': row.get('created_at'), + 'actor': row.get('actor_name') or row.get('actor_account_uuid'), + 'actor_role': row.get('actor_role'), + 'action': row.get('action'), + 'action_i18n_key': row.get('action_i18n_key'), + 'resource_type': row.get('resource_type'), + 'resource_id': row.get('resource_id'), + 'summary': row.get('summary'), + 'changes': changes, + 'outcome': row.get('outcome'), + 'http_method': row.get('http_method'), + 'route': row.get('route'), + 'integrity_ok': row.get('integrity_ok'), + 'chain_ok': row.get('chain_ok'), + 'tampered': row.get('tampered'), + } + + records = result.get('records') or [] + return { + 'total': result.get('total', len(records)), + 'returned': len(records), + 'tampered_count': result.get('tampered_count', 0), + 'integrity_failed_count': result.get('integrity_failed_count', 0), + 'chain_failed_count': result.get('chain_failed_count', 0), + 'records': [record(row) for row in records], + } + + class PipelineID(Arguments): pipeline_uuid: UUID @@ -45,29 +168,55 @@ class CreateKnowledgeBase(CreatePipeline): retrieval_settings: dict = Field(default_factory=dict) +#: Each entry is ``(schema, description, writes, extra_permission)``. ``writes`` +#: selects resource.manage vs resource.view; ``extra_permission`` narrows a tool +#: further (e.g. the audit surface needs ``audit.view``, which only the owner +#: and admin hold) and both are enforced identically when listing and calling. TOOLS = { - 'list_resources': (ListResources, 'List existing Workspace resources. Discover IDs before using them.', False), - 'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False), - 'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False), - 'create_pipeline': (CreatePipeline, 'Create an unconnected Pipeline draft. Requires user confirmation.', True), + 'list_resources': (ListResources, 'List existing Workspace resources. Discover IDs before using them.', False, None), + 'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False, None), + 'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False, None), + 'list_operation_logs': ( + ListOperationLogs, + 'Read the Workspace operation trace (who changed or viewed what, with before/after fields). ' + 'Owner and admin only. Use it to answer "who did what" questions about this Workspace.', + False, + Permission.AUDIT_VIEW, + ), + 'create_pipeline': (CreatePipeline, 'Create an unconnected Pipeline draft. Requires user confirmation.', True, None), 'configure_pipeline': ( ConfigurePipeline, 'Set the local-agent model, system prompt and complete knowledge-base binding list. Requires confirmation.', True, + None, ), 'create_knowledge_base': ( CreateKnowledgeBase, 'Create a knowledge base using an installed engine. Read its schema first. Requires confirmation.', True, + None, ), } +def _required_permission(writes: bool, extra: Permission | None) -> Permission: + """Return the permission a tool needs beyond the base resource verbs.""" + + if extra is not None: + return extra + return Permission.RESOURCE_MANAGE if writes else Permission.RESOURCE_VIEW + + +def _authorized(context, name: str) -> bool: + _schema, _description, writes, extra = TOOLS[name] + return _required_permission(writes, extra).value in context.workspace.permissions + + def validate_call(context, name: str, arguments: dict) -> Arguments: if name not in TOOLS: raise ValueError('Unknown management tool') - schema, _, writes = TOOLS[name] - require_permission(context, Permission.RESOURCE_MANAGE if writes else Permission.RESOURCE_VIEW) + schema, _, writes, extra = TOOLS[name] + require_permission(context, _required_permission(writes, extra)) return schema.model_validate(arguments) @@ -80,8 +229,8 @@ def tool_definitions(context) -> list[LLMTool]: parameters=schema.model_json_schema(), func=execute_tool, ) - for name, (schema, description, writes) in TOOLS.items() - if not writes or Permission.RESOURCE_MANAGE in context.workspace.permissions + for name, (schema, description, _writes, _extra) in TOOLS.items() + if _authorized(context, name) ] @@ -101,6 +250,23 @@ async def execute_tool(ap, context, name: str, arguments: dict): fields = ('uuid', 'name', 'description', 'abilities', 'knowledge_engine_plugin_id') resources = [{key: item[key] for key in fields if key in item} for item in resources] return {'total': len(resources), 'items': redact_secrets(resources)} + if name == 'list_operation_logs': + # Delegates to the operation-trace service, so the assistant shows the + # same records and tamper verification as the settings panel, then + # compacts them so a full page still fits the per-result budget. + result = await ap.workspace_settings_service.query_logs( + context.workspace_uuid, + limit=args['limit'], + search=args['search'], + action=args['action'], + resource_type=args['resource_type'], + actor_account_uuid=args['actor'], + ) + compact = _compact_operation_records(result) + # Echo the applied filters so the model can see why a result is empty + # and widen a filter instead of repeating an empty targeted query. + compact['query'] = {key: args[key] for key in ('search', 'action', 'resource_type', 'actor') if args.get(key)} + return compact if name == 'get_pipeline': return await ap.pipeline_service.get_pipeline(context, args['pipeline_uuid']) if name == 'get_knowledge_schema': diff --git a/src/langbot/pkg/operation_trace/service.py b/src/langbot/pkg/operation_trace/service.py index 8df706640..9ceab345a 100644 --- a/src/langbot/pkg/operation_trace/service.py +++ b/src/langbot/pkg/operation_trace/service.py @@ -1653,6 +1653,7 @@ class WorkspaceSettingsService: since: datetime.datetime | None = None, until: datetime.datetime | None = None, integrity: str | None = None, + search: str | None = None, ) -> dict[str, typing.Any]: """Return one page of operation records plus a Workspace-wide summary. @@ -1664,6 +1665,13 @@ class WorkspaceSettingsService: ``integrity`` optionally narrows the listing to the records that failed verification, which lets the panel make its counters actionable instead of decorative. + + ``search`` is a case-insensitive substring match over the action, + resource type, resource id, actor name and summary. It exists so a + caller can ask a narrow question ("install", a plugin name, an account) + without pulling the whole history: the exact ``action`` filter compares + the stored key, so an approximate term like ``install`` would otherwise + return nothing and force a full dump. """ resolved_limit = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE)) @@ -1681,6 +1689,17 @@ class WorkspaceSettingsService: filters.append(model.resource_type == resource_type) if actor_account_uuid: filters.append(model.actor_account_uuid == actor_account_uuid) + if search: + needle = f'%{str(search).strip()}%' + filters.append( + sqlalchemy.or_( + model.action.ilike(needle), + model.resource_type.ilike(needle), + model.resource_id.ilike(needle), + model.actor_name.ilike(needle), + model.summary.ilike(needle), + ) + ) if level is not None: filters.append(model.level == int(level)) if since is not None: diff --git a/web/src/app/home/components/AssistantToolResult.tsx b/web/src/app/home/components/AssistantToolResult.tsx index 8b7e489e3..4eabe60c1 100644 --- a/web/src/app/home/components/AssistantToolResult.tsx +++ b/web/src/app/home/components/AssistantToolResult.tsx @@ -45,7 +45,9 @@ export default function AssistantToolResult({ ? result : Array.isArray(data.items) ? data.items - : null; + : Array.isArray(data.records) + ? data.records + : null; const total = typeof data.total === 'number' ? data.total : items?.length; const kind = tool?.arguments.kind; const label = @@ -142,7 +144,13 @@ export default function AssistantToolResult({ : {}; return (
  • - {String(entry.name || entry.uuid || '—')} + {String( + entry.name || + entry.summary || + entry.action || + entry.uuid || + '—', + )}
  • ); })} diff --git a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx index 530a033c1..31ed3d04d 100644 --- a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx +++ b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx @@ -15,6 +15,11 @@ import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; import { Input } from '@/components/ui/input'; import { Item, ItemContent, ItemMedia, ItemTitle } from '@/components/ui/item'; import { @@ -341,6 +346,9 @@ export default function OperationTracePanel({ const [retentionDays, setRetentionDays] = useState(30); const [maxRows, setMaxRows] = useState(20000); const [dedupeSeconds, setDedupeSeconds] = useState(60); + // Retention is an advanced, rarely-touched policy: it collapses by default so + // the capture level and the records list stay the focus of the panel. + const [retentionOpen, setRetentionOpen] = useState(false); // Only one record shows its full change detail at a time, so the list stays // scannable: the payload diff is long and is opt-in per row. const [expandedId, setExpandedId] = useState(null); @@ -631,66 +639,85 @@ export default function OperationTracePanel({ -
    -

    - {t('operationTrace.retention')} -

    -
    -
    -
    +

    + {t('operationTrace.retention')} +

    + {!retentionOpen && ( + + {t('operationTrace.retentionSummary', { + days: retentionDays, + rows: maxRows, + })} + + )} + + +
    + + + + +
    +
    + +
    diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 574201209..b36f78c88 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -21,6 +21,7 @@ const enUS = { create_knowledge_base: 'Create knowledge base', get_pipeline: 'Read Pipeline', get_knowledge_schema: 'Read knowledge engine schema', + list_operation_logs: 'Read operation trace', }, resources: { models: 'Find chat models', @@ -2724,6 +2725,7 @@ const enUS = { description: 'Trace admin and owner edits and views, and what changed.', captureLevel: 'Capture level', retention: 'Retention', + retentionSummary: 'Keep {{days}} days · up to {{rows}} records', retentionDays: 'Retention days', maxRows: 'Max records', dedupeWindow: 'Dedupe window (s)', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 0875caa77..a5a1b09e6 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -21,6 +21,7 @@ const esES = { create_knowledge_base: 'Crear base de conocimiento', get_pipeline: 'Consultar Pipeline', get_knowledge_schema: 'Consultar el esquema del motor de conocimiento', + list_operation_logs: 'Consultar la trazabilidad de operaciones', }, resources: { models: 'Buscar modelos de chat', @@ -2779,6 +2780,7 @@ const esES = { description: 'Rastrea cambios y consultas de los administradores', captureLevel: 'Nivel de captura', retention: 'Retención', + retentionSummary: 'Conservar {{days}} días · hasta {{rows}} registros', retentionDays: 'Días de retención', maxRows: 'Registros máximos', dedupeWindow: 'Ventana de deduplicación (s)', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index d1788fe67..8dcf174ef 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -21,6 +21,7 @@ const jaJP = { create_knowledge_base: 'ナレッジベース作成', get_pipeline: 'Pipeline 参照', get_knowledge_schema: 'エンジン設定の参照', + list_operation_logs: '操作トレーサビリティの参照', }, resources: { models: 'チャットモデル検索', @@ -2742,6 +2743,7 @@ const jaJP = { description: '管理者とオーナーの操作を追跡します。', captureLevel: 'キャプチャレベル', retention: '保持ポリシー', + retentionSummary: '{{days}} 日間保持 · 最大 {{rows}} 件', retentionDays: '保持日数', maxRows: '最大レコード数', dedupeWindow: '重複排除ウィンドウ(秒)', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index d42db13ea..069dd2f6a 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -21,6 +21,7 @@ const ruRU = { create_knowledge_base: 'Создать базу знаний', get_pipeline: 'Посмотреть Pipeline', get_knowledge_schema: 'Посмотреть схему движка знаний', + list_operation_logs: 'Просмотр трассировки операций', }, resources: { models: 'Найти чат-модели', @@ -2749,6 +2750,7 @@ const ruRU = { description: 'Отслеживание изменений и просмотров администраторов', captureLevel: 'Уровень записи', retention: 'Хранение', + retentionSummary: 'Хранить {{days}} дн. · до {{rows}} записей', retentionDays: 'Дней хранения', maxRows: 'Максимум записей', dedupeWindow: 'Окно дедупликации (с)', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 7542b0f81..149c7d822 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -21,6 +21,7 @@ const thTH = { create_knowledge_base: 'สร้างฐานความรู้', get_pipeline: 'ดู Pipeline', get_knowledge_schema: 'ดูสคีมาของ knowledge engine', + list_operation_logs: 'ดูการติดตามการดำเนินการ', }, resources: { models: 'ค้นหาโมเดลแชต', @@ -2676,6 +2677,7 @@ const thTH = { description: 'ติดตามการแก้ไขและการดูของผู้ดูแลและเจ้าของ', captureLevel: 'ระดับการบันทึก', retention: 'นโยบายการเก็บรักษา', + retentionSummary: 'เก็บ {{days}} วัน · สูงสุด {{rows}} รายการ', retentionDays: 'จำนวนวันเก็บรักษา', maxRows: 'จำนวนบันทึกสูงสุด', dedupeWindow: 'ช่วงเวลาลบรายการซ้ำ (วินาที)', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index bd4814195..84dd9d7f8 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -22,6 +22,7 @@ const viVN = { create_knowledge_base: 'Tạo cơ sở tri thức', get_pipeline: 'Xem Pipeline', get_knowledge_schema: 'Xem lược đồ knowledge engine', + list_operation_logs: 'Truy vấn truy vết thao tác', }, resources: { models: 'Tìm mô hình trò chuyện', @@ -2711,6 +2712,7 @@ const viVN = { description: 'Theo dõi thao tác sửa và xem của quản trị viên', captureLevel: 'Mức ghi nhận', retention: 'Chính sách lưu trữ', + retentionSummary: 'Lưu {{days}} ngày · tối đa {{rows}} bản ghi', retentionDays: 'Số ngày lưu', maxRows: 'Số bản ghi tối đa', dedupeWindow: 'Khoảng khử trùng lặp (giây)', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index f1a5c0346..6b8929318 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -19,6 +19,7 @@ const zhHans = { create_knowledge_base: '创建知识库', get_pipeline: '查看 Pipeline', get_knowledge_schema: '查看知识引擎配置', + list_operation_logs: '查询操作溯源', }, resources: { models: '查询聊天模型', @@ -2574,6 +2575,7 @@ const zhHans = { description: '追踪管理员与拥有者的修改和查看操作,记录“什么改成了什么”。', captureLevel: '操作等级', retention: '保留策略', + retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 条', retentionDays: '保留天数', maxRows: '最大记录数', dedupeWindow: '去重窗口(秒)', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 47067ffec..135627fa0 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -19,6 +19,7 @@ const zhHant = { create_knowledge_base: '建立知識庫', get_pipeline: '查看 Pipeline', get_knowledge_schema: '查看知識引擎設定', + list_operation_logs: '查詢操作溯源', }, resources: { models: '查詢聊天模型', @@ -2575,6 +2576,7 @@ const zhHant = { description: '追蹤管理員與擁有者的修改與查看操作,記錄「什麼改成了什麼」。', captureLevel: '操作等級', retention: '保留策略', + retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 條', retentionDays: '保留天數', maxRows: '最大記錄數', dedupeWindow: '去重視窗(秒)',