diff --git a/docs/runbooks/NODE1_FILE_TOOL_SYNC_RUNBOOK.md b/docs/runbooks/NODE1_FILE_TOOL_SYNC_RUNBOOK.md index 5ef6e6f3..ff6a01c7 100644 --- a/docs/runbooks/NODE1_FILE_TOOL_SYNC_RUNBOOK.md +++ b/docs/runbooks/NODE1_FILE_TOOL_SYNC_RUNBOOK.md @@ -57,6 +57,12 @@ Implemented in actual NODE1 stack (`services/router/*` + gateway): - `zip_bundle` - `docx_create` - `docx_update` +- `pptx_create` +- `pptx_update` +- `ods_create` +- `ods_update` +- `parquet_create` +- `parquet_update` - `pdf_merge` - `pdf_split` - `pdf_fill` @@ -66,6 +72,9 @@ Implemented in actual NODE1 stack (`services/router/*` + gateway): - `markdown_update` - `xml_export` - `html_export` +- `image_create` +- `image_edit` +- `image_convert` ### Standard output contract For file-producing tool calls, router now propagates: @@ -101,11 +110,15 @@ Run inside `dagi-router-node1` to validate actions deterministically: - Excel create/update - Text/Markdown create/update - XML/HTML export +- PPTX create/update +- ODS create/update +- Parquet create/update - CSV create/update - JSON/YAML export - ZIP bundle - DOCX create/update - PDF merge/split/fill +- Image create/edit/convert Also verify infer endpoint still works: - `POST http://127.0.0.1:9102/v1/agents/devtools/infer` @@ -117,6 +130,12 @@ Also verify infer endpoint still works: - `rollback_backups/file_tool_step4_tool_manager.py.bak_20260215_012309` - `services/router/tool_manager.py.bak_20260215_020902` - `services/router/tool_manager.py.bak_20260215_112313` +- `services/router/tool_manager.py.bak_20260215_112459` +- `services/router/requirements.txt.bak_20260215_112459` +- `services/router/tool_manager.py.bak_20260215_112652` +- `services/router/requirements.txt.bak_20260215_112652` +- `services/router/tool_manager.py.bak_20260215_112841` +- `services/router/tool_manager.py.bak_20260215_112912` ## Rollback (NODE1) ```bash diff --git a/services/router/requirements.txt b/services/router/requirements.txt index 4f915cbf..ef1fa8c8 100644 --- a/services/router/requirements.txt +++ b/services/router/requirements.txt @@ -8,6 +8,9 @@ neo4j>=5.14.0 openpyxl>=3.1.2 python-docx>=1.1.2 pypdf>=5.1.0 +python-pptx>=0.6.23 +odfpy>=1.4.1 +pyarrow>=18.0.0 # Memory Retrieval v3.0 asyncpg>=0.29.0 diff --git a/services/router/tool_manager.py b/services/router/tool_manager.py index a8e99b81..767a2699 100644 --- a/services/router/tool_manager.py +++ b/services/router/tool_manager.py @@ -12,6 +12,7 @@ import logging import hashlib import base64 import csv +import tempfile import httpx from typing import Dict, List, Any, Optional from dataclasses import dataclass @@ -324,10 +325,13 @@ TOOL_DEFINITIONS = [ "type": "string", "enum": [ "excel_create", "excel_update", "docx_create", "docx_update", + "pptx_create", "pptx_update", + "ods_create", "ods_update", "parquet_create", "parquet_update", "csv_create", "csv_update", "pdf_fill", "pdf_merge", "pdf_split", "json_export", "yaml_export", "zip_bundle", "text_create", "text_update", "markdown_create", "markdown_update", - "xml_export", "html_export" + "xml_export", "html_export", + "image_create", "image_edit", "image_convert" ], "description": "Дія file tool" }, @@ -610,6 +614,14 @@ class ToolManager: return self._file_csv_create(args) if action == "csv_update": return self._file_csv_update(args) + if action == "ods_create": + return self._file_ods_create(args) + if action == "ods_update": + return self._file_ods_update(args) + if action == "parquet_create": + return self._file_parquet_create(args) + if action == "parquet_update": + return self._file_parquet_update(args) if action == "text_create": return self._file_text_create(args) if action == "text_update": @@ -622,6 +634,12 @@ class ToolManager: return self._file_xml_export(args) if action == "html_export": return self._file_html_export(args) + if action == "image_create": + return self._file_image_create(args) + if action == "image_edit": + return self._file_image_edit(args) + if action == "image_convert": + return self._file_image_convert(args) if action == "json_export": return self._file_json_export(args) if action == "yaml_export": @@ -632,6 +650,10 @@ class ToolManager: return self._file_docx_create(args) if action == "docx_update": return self._file_docx_update(args) + if action == "pptx_create": + return self._file_pptx_create(args) + if action == "pptx_update": + return self._file_pptx_update(args) if action == "pdf_merge": return self._file_pdf_merge(args) if action == "pdf_split": @@ -712,6 +734,200 @@ class ToolManager: file_mime="text/csv", ) + @staticmethod + def _rows_to_objects(rows_raw: Any, headers: Optional[List[str]] = None) -> List[Dict[str, Any]]: + if not isinstance(rows_raw, list): + return [] + result: List[Dict[str, Any]] = [] + for idx, row in enumerate(rows_raw): + if isinstance(row, dict): + result.append(dict(row)) + continue + if isinstance(row, list): + if headers: + obj = {str(headers[i]): row[i] if i < len(row) else None for i in range(len(headers))} + else: + obj = {f"col_{i+1}": v for i, v in enumerate(row)} + result.append(obj) + continue + key = headers[0] if headers else "value" + result.append({str(key): row}) + return result + + def _file_ods_create(self, args: Dict[str, Any]) -> ToolResult: + from odf.opendocument import OpenDocumentSpreadsheet + from odf.table import Table, TableCell, TableRow + from odf.text import P + + file_name = self._sanitize_file_name(args.get("file_name"), "sheet.ods", force_ext=".ods") + headers = args.get("headers") or [] + rows_raw = args.get("rows") or [] + rows = self._normalize_rows(rows_raw, headers=headers if headers else None) + if rows and not headers and isinstance(rows_raw[0], dict): + headers = list(rows_raw[0].keys()) + rows = self._normalize_rows(rows_raw, headers=headers) + + doc = OpenDocumentSpreadsheet() + table = Table(name=str(args.get("sheet_name") or "Sheet1")) + + if headers: + hrow = TableRow() + for value in headers: + cell = TableCell(valuetype="string") + cell.addElement(P(text=str(value))) + hrow.addElement(cell) + table.addElement(hrow) + + for row in rows: + trow = TableRow() + for value in row: + cell = TableCell(valuetype="string") + cell.addElement(P(text="" if value is None else str(value))) + trow.addElement(cell) + table.addElement(trow) + + doc.spreadsheet.addElement(table) + with tempfile.NamedTemporaryFile(suffix=".ods") as tmp: + doc.save(tmp.name) + tmp.seek(0) + payload = tmp.read() + + return ToolResult( + success=True, + result={"message": f"ODS created: {file_name}"}, + file_base64=self._b64_from_bytes(payload), + file_name=file_name, + file_mime="application/vnd.oasis.opendocument.spreadsheet", + ) + + def _file_ods_update(self, args: Dict[str, Any]) -> ToolResult: + from odf.opendocument import OpenDocumentSpreadsheet, load + from odf.table import Table, TableCell, TableRow + from odf.text import P + + src_b64 = args.get("file_base64") + if not src_b64: + return ToolResult(success=False, result=None, error="file_base64 is required for ods_update") + file_name = self._sanitize_file_name(args.get("file_name"), "updated.ods", force_ext=".ods") + operation = str(args.get("operation") or "append").strip().lower() + if operation not in {"append", "replace"}: + return ToolResult(success=False, result=None, error="operation must be append|replace") + + headers = args.get("headers") or [] + rows_raw = args.get("rows") or [] + rows = self._normalize_rows(rows_raw, headers=headers if headers else None) + if rows and not headers and isinstance(rows_raw[0], dict): + headers = list(rows_raw[0].keys()) + rows = self._normalize_rows(rows_raw, headers=headers) + + with tempfile.NamedTemporaryFile(suffix=".ods") as src: + src.write(self._bytes_from_b64(src_b64)) + src.flush() + doc = load(src.name) + + # Rebuild first table to keep update deterministic. + tables = doc.spreadsheet.getElementsByType(Table) + existing: List[List[str]] = [] + if tables and operation == "append": + first = tables[0] + for r in first.getElementsByType(TableRow): + vals = [] + for c in r.getElementsByType(TableCell): + text_nodes = c.getElementsByType(P) + vals.append("".join((p.firstChild.data if p.firstChild else "") for p in text_nodes)) + existing.append(vals) + doc.spreadsheet.removeChild(first) + elif tables: + doc.spreadsheet.removeChild(tables[0]) + + table = Table(name=str(args.get("sheet_name") or "Sheet1")) + out_rows: List[List[Any]] = [] + if operation == "append" and existing: + out_rows.extend(existing) + out_rows.extend(rows) + else: + if headers: + out_rows.append(headers) + out_rows.extend(rows) + + for row in out_rows: + trow = TableRow() + for value in row: + cell = TableCell(valuetype="string") + cell.addElement(P(text="" if value is None else str(value))) + trow.addElement(cell) + table.addElement(trow) + + doc.spreadsheet.addElement(table) + with tempfile.NamedTemporaryFile(suffix=".ods") as dst: + doc.save(dst.name) + dst.seek(0) + payload = dst.read() + + return ToolResult( + success=True, + result={"message": f"ODS updated: {file_name}"}, + file_base64=self._b64_from_bytes(payload), + file_name=file_name, + file_mime="application/vnd.oasis.opendocument.spreadsheet", + ) + + def _file_parquet_create(self, args: Dict[str, Any]) -> ToolResult: + import pyarrow as pa + import pyarrow.parquet as pq + + file_name = self._sanitize_file_name(args.get("file_name"), "data.parquet", force_ext=".parquet") + headers = args.get("headers") or [] + rows_raw = args.get("rows") or [] + objects = self._rows_to_objects(rows_raw, headers=headers if headers else None) + table = pa.Table.from_pylist(objects if objects else [{"value": None}]) + out = BytesIO() + pq.write_table(table, out) + payload = out.getvalue() + + return ToolResult( + success=True, + result={"message": f"Parquet created: {file_name}"}, + file_base64=self._b64_from_bytes(payload), + file_name=file_name, + file_mime="application/vnd.apache.parquet", + ) + + def _file_parquet_update(self, args: Dict[str, Any]) -> ToolResult: + import pyarrow as pa + import pyarrow.parquet as pq + + src_b64 = args.get("file_base64") + if not src_b64: + return ToolResult(success=False, result=None, error="file_base64 is required for parquet_update") + file_name = self._sanitize_file_name(args.get("file_name"), "updated.parquet", force_ext=".parquet") + operation = str(args.get("operation") or "append").strip().lower() + if operation not in {"append", "replace"}: + return ToolResult(success=False, result=None, error="operation must be append|replace") + + headers = args.get("headers") or [] + rows_raw = args.get("rows") or [] + new_rows = self._rows_to_objects(rows_raw, headers=headers if headers else None) + + existing_rows: List[Dict[str, Any]] = [] + if operation == "append": + table = pq.read_table(BytesIO(self._bytes_from_b64(src_b64))) + existing_rows = table.to_pylist() + + merged = new_rows if operation == "replace" else (existing_rows + new_rows) + table = pa.Table.from_pylist(merged if merged else [{"value": None}]) + out = BytesIO() + pq.write_table(table, out) + payload = out.getvalue() + + return ToolResult( + success=True, + result={"message": f"Parquet updated: {file_name}"}, + file_base64=self._b64_from_bytes(payload), + file_name=file_name, + file_mime="application/vnd.apache.parquet", + ) + def _file_json_export(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "export.json", force_ext=".json") content = args.get("content") @@ -826,6 +1042,142 @@ class ToolManager: file_mime="text/html", ) + @staticmethod + def _image_format_for_name(file_name: str, fallback: str = "PNG") -> str: + suffix = PurePath(file_name).suffix.lower() + mapping = { + ".png": "PNG", + ".jpg": "JPEG", + ".jpeg": "JPEG", + ".webp": "WEBP", + ".gif": "GIF", + ".bmp": "BMP", + ".tif": "TIFF", + ".tiff": "TIFF", + } + return mapping.get(suffix, fallback) + + @staticmethod + def _mime_for_image_format(fmt: str) -> str: + mapping = { + "PNG": "image/png", + "JPEG": "image/jpeg", + "WEBP": "image/webp", + "GIF": "image/gif", + "BMP": "image/bmp", + "TIFF": "image/tiff", + } + return mapping.get(fmt.upper(), "application/octet-stream") + + def _file_image_create(self, args: Dict[str, Any]) -> ToolResult: + from PIL import Image, ImageDraw + + file_name = self._sanitize_file_name(args.get("file_name"), "image.png") + fmt = self._image_format_for_name(file_name, fallback=str(args.get("format") or "PNG")) + width = max(1, int(args.get("width") or 1024)) + height = max(1, int(args.get("height") or 1024)) + color = args.get("background_color") or args.get("color") or "white" + + image = Image.new("RGB", (width, height), color=color) + text = args.get("text") + if text: + draw = ImageDraw.Draw(image) + draw.text((20, 20), str(text), fill=args.get("text_color") or "black") + + out = BytesIO() + image.save(out, format=fmt) + payload = out.getvalue() + return ToolResult( + success=True, + result={"message": f"Image created: {file_name}"}, + file_base64=self._b64_from_bytes(payload), + file_name=file_name, + file_mime=self._mime_for_image_format(fmt), + ) + + def _file_image_edit(self, args: Dict[str, Any]) -> ToolResult: + from PIL import Image, ImageDraw + + src_b64 = args.get("file_base64") + operations = args.get("operations") or [] + if not src_b64: + return ToolResult(success=False, result=None, error="file_base64 is required for image_edit") + if not isinstance(operations, list) or not operations: + return ToolResult(success=False, result=None, error="operations must be non-empty array") + + file_name = self._sanitize_file_name(args.get("file_name"), "edited.png") + fmt = self._image_format_for_name(file_name, fallback=str(args.get("format") or "PNG")) + image = Image.open(BytesIO(self._bytes_from_b64(src_b64))) + + for op in operations: + if not isinstance(op, dict): + return ToolResult(success=False, result=None, error="Each operation must be object") + op_type = str(op.get("type") or "").strip().lower() + if op_type == "resize": + width = max(1, int(op.get("width") or image.width)) + height = max(1, int(op.get("height") or image.height)) + image = image.resize((width, height)) + elif op_type == "crop": + left = int(op.get("left") or 0) + top = int(op.get("top") or 0) + right = int(op.get("right") or image.width) + bottom = int(op.get("bottom") or image.height) + image = image.crop((left, top, right, bottom)) + elif op_type == "rotate": + angle = float(op.get("angle") or 0) + image = image.rotate(angle, expand=bool(op.get("expand", True))) + elif op_type == "flip_horizontal": + image = image.transpose(Image.FLIP_LEFT_RIGHT) + elif op_type == "flip_vertical": + image = image.transpose(Image.FLIP_TOP_BOTTOM) + elif op_type == "draw_text": + draw = ImageDraw.Draw(image) + x = int(op.get("x") or 0) + y = int(op.get("y") or 0) + draw.text((x, y), str(op.get("text") or ""), fill=op.get("color") or "black") + else: + return ToolResult(success=False, result=None, error=f"Unsupported image_edit operation: {op_type}") + + out = BytesIO() + image.save(out, format=fmt) + payload = out.getvalue() + return ToolResult( + success=True, + result={"message": f"Image edited: {file_name}"}, + file_base64=self._b64_from_bytes(payload), + file_name=file_name, + file_mime=self._mime_for_image_format(fmt), + ) + + def _file_image_convert(self, args: Dict[str, Any]) -> ToolResult: + from PIL import Image + + src_b64 = args.get("file_base64") + if not src_b64: + return ToolResult(success=False, result=None, error="file_base64 is required for image_convert") + file_name = self._sanitize_file_name(args.get("file_name"), "converted.png") + fmt = str(args.get("target_format") or self._image_format_for_name(file_name)).upper() + if fmt == "JPG": + fmt = "JPEG" + + image = Image.open(BytesIO(self._bytes_from_b64(src_b64))) + if fmt in {"JPEG"} and image.mode not in {"RGB", "L"}: + image = image.convert("RGB") + + out = BytesIO() + save_kwargs: Dict[str, Any] = {} + if fmt in {"JPEG", "WEBP"} and args.get("quality") is not None: + save_kwargs["quality"] = int(args.get("quality")) + image.save(out, format=fmt, **save_kwargs) + payload = out.getvalue() + return ToolResult( + success=True, + result={"message": f"Image converted: {file_name}"}, + file_base64=self._b64_from_bytes(payload), + file_name=file_name, + file_mime=self._mime_for_image_format(fmt), + ) + def _file_yaml_export(self, args: Dict[str, Any]) -> ToolResult: file_name = self._sanitize_file_name(args.get("file_name"), "export.yaml", force_ext=".yaml") content = args.get("content") @@ -1117,6 +1469,119 @@ class ToolManager: file_mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document", ) + def _file_pptx_create(self, args: Dict[str, Any]) -> ToolResult: + from pptx import Presentation + + file_name = self._sanitize_file_name(args.get("file_name"), "slides.pptx", force_ext=".pptx") + prs = Presentation() + + title = str(args.get("title") or "").strip() + subtitle = str(args.get("subtitle") or "").strip() + if title or subtitle: + slide = prs.slides.add_slide(prs.slide_layouts[0]) + if title and slide.shapes.title: + slide.shapes.title.text = title + if subtitle and len(slide.placeholders) > 1: + slide.placeholders[1].text = subtitle + + for entry in args.get("slides") or []: + if not isinstance(entry, dict): + continue + slide = prs.slides.add_slide(prs.slide_layouts[1]) + if slide.shapes.title: + slide.shapes.title.text = str(entry.get("title") or "") + body = None + if len(slide.placeholders) > 1: + body = slide.placeholders[1].text_frame + lines = entry.get("bullets") + if lines is None: + lines = entry.get("lines") + if lines is None: + lines = [entry.get("text")] if entry.get("text") is not None else [] + if body is not None: + body.clear() + first = True + for line in lines: + if first: + body.text = str(line) + first = False + else: + p = body.add_paragraph() + p.text = str(line) + + out = BytesIO() + prs.save(out) + return ToolResult( + success=True, + result={"message": f"PPTX created: {file_name}"}, + file_base64=self._b64_from_bytes(out.getvalue()), + file_name=file_name, + file_mime="application/vnd.openxmlformats-officedocument.presentationml.presentation", + ) + + def _file_pptx_update(self, args: Dict[str, Any]) -> ToolResult: + from pptx import Presentation + + src_b64 = args.get("file_base64") + operations = args.get("operations") or [] + if not src_b64: + return ToolResult(success=False, result=None, error="file_base64 is required for pptx_update") + if not isinstance(operations, list) or not operations: + return ToolResult(success=False, result=None, error="operations must be non-empty array") + + file_name = self._sanitize_file_name(args.get("file_name"), "updated.pptx", force_ext=".pptx") + prs = Presentation(BytesIO(self._bytes_from_b64(src_b64))) + + for op in operations: + if not isinstance(op, dict): + return ToolResult(success=False, result=None, error="Each operation must be object") + op_type = str(op.get("type") or "").strip().lower() + + if op_type == "append_slide": + slide = prs.slides.add_slide(prs.slide_layouts[1]) + if slide.shapes.title: + slide.shapes.title.text = str(op.get("title") or "") + lines = op.get("bullets") + if lines is None: + lines = op.get("lines") + if lines is None: + lines = [op.get("text")] if op.get("text") is not None else [] + if len(slide.placeholders) > 1: + body = slide.placeholders[1].text_frame + body.clear() + first = True + for line in lines: + if first: + body.text = str(line) + first = False + else: + p = body.add_paragraph() + p.text = str(line) + elif op_type == "replace_text": + old = str(op.get("old") or "") + new = str(op.get("new") or "") + if not old: + return ToolResult(success=False, result=None, error="replace_text requires old") + for slide in prs.slides: + for shape in slide.shapes: + if not hasattr(shape, "text"): + continue + text = shape.text or "" + if old in text: + shape.text = text.replace(old, new) + else: + return ToolResult(success=False, result=None, error=f"Unsupported pptx_update operation: {op_type}") + + out = BytesIO() + prs.save(out) + return ToolResult( + success=True, + result={"message": f"PPTX updated: {file_name}"}, + file_base64=self._b64_from_bytes(out.getvalue()), + file_name=file_name, + file_mime="application/vnd.openxmlformats-officedocument.presentationml.presentation", + ) + def _file_pdf_merge(self, args: Dict[str, Any]) -> ToolResult: from pypdf import PdfReader, PdfWriter