diff --git a/README.md b/README.md index 5c51fb9ab9..5be1f5b322 100644 --- a/README.md +++ b/README.md @@ -1530,6 +1530,8 @@ uses neither `main` nor `master` reports its current tree instead of a stale bra Users can explicitly activate an enabled skill for a single turn by starting the request with `/skill-name`, for example `/data-analysis analyze uploads/foo.csv`. DeerFlow loads that skill's `SKILL.md` as hidden current-turn context while leaving the base prompt limited to skill metadata. Slash activation respects disabled skills, custom-agent skill whitelists, and existing channel commands such as `/new` and `/help`. +Data-analysis Markdown exports escape pipes and backslashes in column names and values, and render cell line breaks as `
` so multiline data stays within its table cell. + The **data-analysis** skill preserves each CSV file or Excel sheet when names collide across inputs. [Prepare its Python environment](skills/public/data-analysis/SKILL.md#environment-setup) explicitly with DuckDB and openpyxl; missing packages report setup instructions instead of installing themselves during analysis. Use the SQL names printed by `inspect` (such as `report`, `report_1`) for queries and summaries. Its cache follows ordered input basenames and contents, so renaming or reordering inputs rebuilds their table map instead of silently selecting a different source. Quoted original names also resolve without rewriting query text; apostrophes in upload paths and double quotes in column names are preserved through loading, inspection, queries, and summaries. After an answer loads skills, its toolbar includes **Skills used**. Hover over or click the icon to see the skills and their sources; hovering a skill name underlines it. Select a skill to inspect its `SKILL.md` in the resizable side panel (a drawer on mobile). The view uses snapshots captured during successful configured read-tool loads or explicit slash activation, so later edits or removal of a skill do not rewrite its history. Repeated loads appear once per run, in first-load order. Range reads and bounded snapshots are labeled as partial; older conversations without captured evidence do not show this menu. Copy returns the captured Markdown, including YAML frontmatter. Package-relative links and images remain readable references rather than navigating away from the conversation. Skill loads performed through other tools, such as shell commands, are not inferred from answer text. diff --git a/skills/public/data-analysis/AGENTS.md b/skills/public/data-analysis/AGENTS.md index 5f6f51b154..d06b6344a9 100644 --- a/skills/public/data-analysis/AGENTS.md +++ b/skills/public/data-analysis/AGENTS.md @@ -23,3 +23,7 @@ Keep these views connection-local and preserve normal CTE shadowing. Prepare DuckDB and openpyxl explicitly in the execution environment. Missing packages produce setup instructions without launching installers; keep those import-failure regressions offline in the root skill tests. + +Markdown exports use the same cell escaping for headers and values. Escape +backslashes before pipes and normalize CRLF, CR, and LF to inline `
` breaks; +raw newlines must not split one query-result row across Markdown table rows. diff --git a/skills/public/data-analysis/scripts/analyze.py b/skills/public/data-analysis/scripts/analyze.py index 7eba519b13..2d550d0aa6 100644 --- a/skills/public/data-analysis/scripts/analyze.py +++ b/skills/public/data-analysis/scripts/analyze.py @@ -337,6 +337,18 @@ def _format_table(columns: list[str], rows: list[tuple]) -> str: return result +def _markdown_cell(value: object) -> str: + """Keep table delimiters and line breaks inside a single Markdown cell.""" + return ( + str(value) + .replace("\\", "\\\\") + .replace("|", "\\|") + .replace("\r\n", "\n") + .replace("\r", "\n") + .replace("\n", "
") + ) + + def _export_results(columns: list[str], rows: list[tuple], output_file: str) -> str: """Export query results to a file (CSV, JSON, or Markdown).""" os.makedirs(os.path.dirname(output_file), exist_ok=True) @@ -369,13 +381,11 @@ def _export_results(columns: list[str], rows: list[tuple], output_file: str) -> elif ext == ".md": with open(output_file, "w", encoding="utf-8") as f: # Header - f.write("| " + " | ".join(columns) + " |\n") + f.write("| " + " | ".join(_markdown_cell(c) for c in columns) + " |\n") f.write("| " + " | ".join("---" for _ in columns) + " |\n") # Rows for row in rows: - f.write( - "| " + " | ".join(str(v).replace("|", "\\|") for v in row) + " |\n" - ) + f.write("| " + " | ".join(_markdown_cell(v) for v in row) + " |\n") else: msg = f"Unsupported output format: {ext}. Use .csv, .json, or .md" print(msg) diff --git a/tests/skills/test_data_analysis_markdown_export.py b/tests/skills/test_data_analysis_markdown_export.py new file mode 100644 index 0000000000..5cc8f1cfb0 --- /dev/null +++ b/tests/skills/test_data_analysis_markdown_export.py @@ -0,0 +1,39 @@ +"""Markdown exports preserve the shape of real DuckDB query results.""" + +import runpy +from pathlib import Path + +import pytest + +duckdb = pytest.importorskip("duckdb") +pytest.importorskip("openpyxl") +SCRIPT = ( + Path(__file__).resolve().parents[2] + / "skills/public/data-analysis/scripts/analyze.py" +) + + +@pytest.mark.parametrize( + "header, value, expected_header, expected_value", + [ + ("left|right", "a|b", r"left\|right", r"a\|b"), + ("notes", "first\nsecond", "notes", "first
second"), + ("first\r\nsecond", "a\rb\r\nc", "first
second", "a
b
c"), + ("path", r"a\|b", "path", r"a\\\|b"), + ("数量", 12, "数量", "12"), + ], +) +def test_query_markdown_export_keeps_each_cell_on_one_line( + tmp_path, header, value, expected_header, expected_value +): + analysis = runpy.run_path(str(SCRIPT)) + output = tmp_path / "result.md" + with duckdb.connect() as con: + con.execute('CREATE TABLE input ("' + header.replace('"', '""') + '" VARCHAR)') + con.execute("INSERT INTO input VALUES (?)", [value]) + analysis["action_query"](con, "SELECT * FROM input", {}, str(output)) + assert output.read_text(encoding="utf-8").splitlines() == [ + f"| {expected_header} |", + "| --- |", + f"| {expected_value} |", + ]