altr-oss 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- altr/__init__.py +24 -0
- altr/agent.py +111 -0
- altr/cli.py +104 -0
- altr/markdown.py +103 -0
- altr/pdf.py +39 -0
- altr/prompts.py +28 -0
- altr/renderers/__init__.py +25 -0
- altr/renderers/docx.py +85 -0
- altr/renderers/pptx.py +91 -0
- altr/renderers/xlsx.py +70 -0
- altr/schemas.py +195 -0
- altr/tools.py +99 -0
- altr_oss-0.2.0.dist-info/METADATA +203 -0
- altr_oss-0.2.0.dist-info/RECORD +17 -0
- altr_oss-0.2.0.dist-info/WHEEL +4 -0
- altr_oss-0.2.0.dist-info/entry_points.txt +2 -0
- altr_oss-0.2.0.dist-info/licenses/LICENSE +21 -0
altr/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""altr - open source doc/excel/ppt skills for LLMs."""
|
|
2
|
+
|
|
3
|
+
from .agent import DEFAULT_MODEL, GROQ_BASE_URL, OfficeAgent, RunResult
|
|
4
|
+
from .markdown import markdown_to_blocks
|
|
5
|
+
from .pdf import soffice_available, to_pdf
|
|
6
|
+
from .prompts import SYSTEM_PROMPT
|
|
7
|
+
from .schemas import DocumentSpec, PresentationSpec, SpreadsheetSpec
|
|
8
|
+
from .tools import dispatch, get_tools
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"OfficeAgent",
|
|
12
|
+
"RunResult",
|
|
13
|
+
"SYSTEM_PROMPT",
|
|
14
|
+
"DEFAULT_MODEL",
|
|
15
|
+
"GROQ_BASE_URL",
|
|
16
|
+
"DocumentSpec",
|
|
17
|
+
"SpreadsheetSpec",
|
|
18
|
+
"PresentationSpec",
|
|
19
|
+
"get_tools",
|
|
20
|
+
"dispatch",
|
|
21
|
+
"markdown_to_blocks",
|
|
22
|
+
"to_pdf",
|
|
23
|
+
"soffice_available",
|
|
24
|
+
]
|
altr/agent.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""A minimal tool-calling loop for any OpenAI-compatible endpoint.
|
|
2
|
+
|
|
3
|
+
Defaults target Groq and openai/gpt-oss-120b, but base_url/model can point at
|
|
4
|
+
Ollama, vLLM, LM Studio, or anything else that speaks chat completions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from openai import OpenAI
|
|
15
|
+
|
|
16
|
+
from .prompts import SYSTEM_PROMPT
|
|
17
|
+
from .tools import dispatch, get_tools
|
|
18
|
+
|
|
19
|
+
GROQ_BASE_URL = "https://api.groq.com/openai/v1"
|
|
20
|
+
DEFAULT_MODEL = "openai/gpt-oss-120b"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RunResult:
|
|
25
|
+
"""Files created during a run, plus the model's final text reply."""
|
|
26
|
+
|
|
27
|
+
files: list[Path] = field(default_factory=list)
|
|
28
|
+
reply: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OfficeAgent:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
model: str = DEFAULT_MODEL,
|
|
35
|
+
base_url: str = GROQ_BASE_URL,
|
|
36
|
+
api_key: str | None = None,
|
|
37
|
+
out_dir: str | Path = "output",
|
|
38
|
+
max_rounds: int = 8,
|
|
39
|
+
docx_template: str | Path | None = None,
|
|
40
|
+
pptx_template: str | Path | None = None,
|
|
41
|
+
client: OpenAI | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
api_key = api_key or os.environ.get("GROQ_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
44
|
+
if client is None and not api_key:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"no API key: pass api_key= or set GROQ_API_KEY (or OPENAI_API_KEY)"
|
|
47
|
+
)
|
|
48
|
+
self.client = client or OpenAI(base_url=base_url, api_key=api_key)
|
|
49
|
+
self.model = model
|
|
50
|
+
self.out_dir = Path(out_dir)
|
|
51
|
+
self.max_rounds = max_rounds
|
|
52
|
+
self.templates: dict[str, Path] = {}
|
|
53
|
+
if docx_template:
|
|
54
|
+
self.templates["docx"] = Path(docx_template)
|
|
55
|
+
if pptx_template:
|
|
56
|
+
self.templates["pptx"] = Path(pptx_template)
|
|
57
|
+
|
|
58
|
+
def run(self, prompt: str) -> RunResult:
|
|
59
|
+
"""Ask the model to fulfil `prompt`, executing its tool calls."""
|
|
60
|
+
messages: list[dict] = [
|
|
61
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
62
|
+
{"role": "user", "content": prompt},
|
|
63
|
+
]
|
|
64
|
+
result = RunResult()
|
|
65
|
+
|
|
66
|
+
for _ in range(self.max_rounds):
|
|
67
|
+
response = self.client.chat.completions.create(
|
|
68
|
+
model=self.model,
|
|
69
|
+
messages=messages,
|
|
70
|
+
tools=get_tools(),
|
|
71
|
+
tool_choice="auto",
|
|
72
|
+
)
|
|
73
|
+
message = response.choices[0].message
|
|
74
|
+
|
|
75
|
+
if not message.tool_calls:
|
|
76
|
+
result.reply = message.content or ""
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
messages.append(
|
|
80
|
+
{
|
|
81
|
+
"role": "assistant",
|
|
82
|
+
"content": message.content or "",
|
|
83
|
+
"tool_calls": [
|
|
84
|
+
{
|
|
85
|
+
"id": tc.id,
|
|
86
|
+
"type": "function",
|
|
87
|
+
"function": {
|
|
88
|
+
"name": tc.function.name,
|
|
89
|
+
"arguments": tc.function.arguments,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
for tc in message.tool_calls
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
for tc in message.tool_calls:
|
|
97
|
+
outcome = dispatch(
|
|
98
|
+
tc.function.name, tc.function.arguments, self.out_dir, self.templates
|
|
99
|
+
)
|
|
100
|
+
if outcome.get("ok"):
|
|
101
|
+
result.files.append(Path(outcome["file"]))
|
|
102
|
+
messages.append(
|
|
103
|
+
{
|
|
104
|
+
"role": "tool",
|
|
105
|
+
"tool_call_id": tc.id,
|
|
106
|
+
"content": json.dumps(outcome),
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
result.reply = "stopped: reached max tool-call rounds"
|
|
111
|
+
return result
|
altr/cli.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Command-line entry points.
|
|
2
|
+
|
|
3
|
+
altr make "Create a pitch deck about solar drones"
|
|
4
|
+
altr render presentation examples/pitch.json
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .agent import DEFAULT_MODEL, GROQ_BASE_URL, OfficeAgent
|
|
15
|
+
from .pdf import to_pdf
|
|
16
|
+
from .renderers import render_document, render_presentation, render_spreadsheet
|
|
17
|
+
from .schemas import DocumentSpec, PresentationSpec, SpreadsheetSpec
|
|
18
|
+
|
|
19
|
+
_RENDERERS = {
|
|
20
|
+
"document": (DocumentSpec, render_document),
|
|
21
|
+
"spreadsheet": (SpreadsheetSpec, render_spreadsheet),
|
|
22
|
+
"presentation": (PresentationSpec, render_presentation),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv: list[str] | None = None) -> int:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="altr",
|
|
29
|
+
description="Office document skills (docx/xlsx/pptx) for open-weight models.",
|
|
30
|
+
)
|
|
31
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
32
|
+
|
|
33
|
+
make = sub.add_parser("make", help="ask a model to create documents from a prompt")
|
|
34
|
+
make.add_argument("prompt", help="what to create, in plain language")
|
|
35
|
+
make.add_argument("--model", default=DEFAULT_MODEL)
|
|
36
|
+
make.add_argument("--base-url", default=GROQ_BASE_URL, help="any OpenAI-compatible endpoint")
|
|
37
|
+
make.add_argument("--api-key", default=None, help="defaults to $GROQ_API_KEY / $OPENAI_API_KEY")
|
|
38
|
+
make.add_argument("--out", default="output", help="output directory (default: ./output)")
|
|
39
|
+
make.add_argument("--max-rounds", type=int, default=8)
|
|
40
|
+
make.add_argument("--docx-template", default=None, help="template .docx for brand styling")
|
|
41
|
+
make.add_argument("--pptx-template", default=None, help="template .pptx for brand styling")
|
|
42
|
+
make.add_argument("--pdf", action="store_true", help="also export each file to PDF (needs LibreOffice)")
|
|
43
|
+
|
|
44
|
+
render = sub.add_parser("render", help="render a JSON spec to a file, no model involved")
|
|
45
|
+
render.add_argument("type", choices=sorted(_RENDERERS))
|
|
46
|
+
render.add_argument("spec", help="path to a JSON spec file")
|
|
47
|
+
render.add_argument("--out", default="output", help="output directory (default: ./output)")
|
|
48
|
+
render.add_argument("--template", default=None, help="template .docx/.pptx for brand styling")
|
|
49
|
+
render.add_argument("--pdf", action="store_true", help="also export to PDF (needs LibreOffice)")
|
|
50
|
+
|
|
51
|
+
args = parser.parse_args(argv)
|
|
52
|
+
|
|
53
|
+
if args.command == "render":
|
|
54
|
+
return _render(args)
|
|
55
|
+
return _make(args)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _render(args: argparse.Namespace) -> int:
|
|
59
|
+
spec_cls, render_fn = _RENDERERS[args.type]
|
|
60
|
+
spec = spec_cls.model_validate(json.loads(Path(args.spec).read_text()))
|
|
61
|
+
if args.type == "spreadsheet":
|
|
62
|
+
if args.template:
|
|
63
|
+
print("altr: --template is not supported for spreadsheets", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
path = render_fn(spec, Path(args.out))
|
|
66
|
+
else:
|
|
67
|
+
template = Path(args.template) if args.template else None
|
|
68
|
+
path = render_fn(spec, Path(args.out), template=template)
|
|
69
|
+
print(f"created {path}")
|
|
70
|
+
if args.pdf:
|
|
71
|
+
print(f"created {to_pdf(path)}")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _make(args: argparse.Namespace) -> int:
|
|
76
|
+
try:
|
|
77
|
+
agent = OfficeAgent(
|
|
78
|
+
model=args.model,
|
|
79
|
+
base_url=args.base_url,
|
|
80
|
+
api_key=args.api_key,
|
|
81
|
+
out_dir=args.out,
|
|
82
|
+
max_rounds=args.max_rounds,
|
|
83
|
+
docx_template=args.docx_template,
|
|
84
|
+
pptx_template=args.pptx_template,
|
|
85
|
+
)
|
|
86
|
+
except ValueError as e:
|
|
87
|
+
print(f"altr: {e}", file=sys.stderr)
|
|
88
|
+
return 2
|
|
89
|
+
|
|
90
|
+
result = agent.run(args.prompt)
|
|
91
|
+
for path in result.files:
|
|
92
|
+
print(f"created {path}")
|
|
93
|
+
if args.pdf:
|
|
94
|
+
print(f"created {to_pdf(path)}")
|
|
95
|
+
if result.reply:
|
|
96
|
+
print(result.reply)
|
|
97
|
+
if not result.files:
|
|
98
|
+
print("altr: the model created no files", file=sys.stderr)
|
|
99
|
+
return 1
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
sys.exit(main())
|
altr/markdown.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""A small markdown-to-blocks converter.
|
|
2
|
+
|
|
3
|
+
Supports the subset that maps cleanly onto document blocks: #-headings,
|
|
4
|
+
bullet (-/*) and numbered (1.) lists, pipe tables, and paragraphs. Inline
|
|
5
|
+
formatting (**bold**, *italic*, `code`) is left in the text; the docx
|
|
6
|
+
renderer turns it into styled runs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from .schemas import BulletList, Heading, NumberedList, Paragraph, Table
|
|
14
|
+
|
|
15
|
+
_HEADING = re.compile(r"^(#{1,9})\s+(.*)$")
|
|
16
|
+
_BULLET = re.compile(r"^[-*]\s+(.*)$")
|
|
17
|
+
_NUMBERED = re.compile(r"^\d+[.)]\s+(.*)$")
|
|
18
|
+
_TABLE_DIVIDER = re.compile(r"^\|?[\s:|-]+\|?$")
|
|
19
|
+
|
|
20
|
+
MarkdownBlock = Heading | Paragraph | BulletList | NumberedList | Table
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def markdown_to_blocks(text: str) -> list[MarkdownBlock]:
|
|
24
|
+
"""Convert markdown text into document blocks."""
|
|
25
|
+
blocks: list[MarkdownBlock] = []
|
|
26
|
+
paragraph: list[str] = []
|
|
27
|
+
bullets: list[str] = []
|
|
28
|
+
numbered: list[str] = []
|
|
29
|
+
table: list[list[str]] = []
|
|
30
|
+
|
|
31
|
+
def flush() -> None:
|
|
32
|
+
nonlocal paragraph, bullets, numbered, table
|
|
33
|
+
if paragraph:
|
|
34
|
+
blocks.append(Paragraph(text=" ".join(paragraph)))
|
|
35
|
+
paragraph = []
|
|
36
|
+
if bullets:
|
|
37
|
+
blocks.append(BulletList(items=bullets))
|
|
38
|
+
bullets = []
|
|
39
|
+
if numbered:
|
|
40
|
+
blocks.append(NumberedList(items=numbered))
|
|
41
|
+
numbered = []
|
|
42
|
+
if table:
|
|
43
|
+
blocks.append(Table(headers=table[0], rows=table[1:]))
|
|
44
|
+
table = []
|
|
45
|
+
|
|
46
|
+
for raw in text.splitlines():
|
|
47
|
+
line = raw.strip()
|
|
48
|
+
if not line:
|
|
49
|
+
flush()
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if m := _HEADING.match(line):
|
|
53
|
+
flush()
|
|
54
|
+
blocks.append(Heading(level=len(m.group(1)), text=m.group(2).strip()))
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
if line.startswith("|") and "|" in line[1:]:
|
|
58
|
+
if _TABLE_DIVIDER.match(line):
|
|
59
|
+
continue # the |---|---| row under the header
|
|
60
|
+
if paragraph or bullets or numbered:
|
|
61
|
+
flush()
|
|
62
|
+
cells = [c.strip() for c in line.strip("|").split("|")]
|
|
63
|
+
table.append(cells)
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if m := _BULLET.match(line):
|
|
67
|
+
if paragraph or numbered or table:
|
|
68
|
+
flush()
|
|
69
|
+
bullets.append(m.group(1).strip())
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
if m := _NUMBERED.match(line):
|
|
73
|
+
if paragraph or bullets or table:
|
|
74
|
+
flush()
|
|
75
|
+
numbered.append(m.group(1).strip())
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if bullets or numbered or table:
|
|
79
|
+
flush()
|
|
80
|
+
paragraph.append(line)
|
|
81
|
+
|
|
82
|
+
flush()
|
|
83
|
+
return blocks
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_INLINE = re.compile(r"(\*\*.+?\*\*|\*.+?\*|`.+?`)")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def split_inline(text: str) -> list[tuple[str, str]]:
|
|
90
|
+
"""Split text into (style, text) runs; style is '', 'bold', 'italic', or 'code'."""
|
|
91
|
+
runs: list[tuple[str, str]] = []
|
|
92
|
+
for part in _INLINE.split(text):
|
|
93
|
+
if not part:
|
|
94
|
+
continue
|
|
95
|
+
if part.startswith("**") and part.endswith("**") and len(part) > 4:
|
|
96
|
+
runs.append(("bold", part[2:-2]))
|
|
97
|
+
elif part.startswith("`") and part.endswith("`") and len(part) > 2:
|
|
98
|
+
runs.append(("code", part[1:-1]))
|
|
99
|
+
elif part.startswith("*") and part.endswith("*") and len(part) > 2:
|
|
100
|
+
runs.append(("italic", part[1:-1]))
|
|
101
|
+
else:
|
|
102
|
+
runs.append(("", part))
|
|
103
|
+
return runs
|
altr/pdf.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""PDF export via LibreOffice's headless converter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def soffice_available() -> bool:
|
|
11
|
+
return _soffice() is not None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _soffice() -> str | None:
|
|
15
|
+
return shutil.which("soffice") or shutil.which("libreoffice")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_pdf(path: str | Path, out_dir: str | Path | None = None) -> Path:
|
|
19
|
+
"""Convert a docx/xlsx/pptx file to PDF. Requires LibreOffice on PATH."""
|
|
20
|
+
soffice = _soffice()
|
|
21
|
+
if soffice is None:
|
|
22
|
+
raise RuntimeError(
|
|
23
|
+
"PDF export requires LibreOffice: install it so 'soffice' is on PATH"
|
|
24
|
+
)
|
|
25
|
+
path = Path(path)
|
|
26
|
+
out_dir = Path(out_dir) if out_dir else path.parent
|
|
27
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
[soffice, "--headless", "--convert-to", "pdf", "--outdir", str(out_dir), str(path)],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
timeout=180,
|
|
34
|
+
)
|
|
35
|
+
pdf_path = out_dir / (path.stem + ".pdf")
|
|
36
|
+
if result.returncode != 0 or not pdf_path.is_file():
|
|
37
|
+
detail = result.stderr.strip() or result.stdout.strip() or "no output produced"
|
|
38
|
+
raise RuntimeError(f"PDF conversion failed for {path.name}: {detail}")
|
|
39
|
+
return pdf_path
|
altr/prompts.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The system prompt that turns a bare model into a document-making skill."""
|
|
2
|
+
|
|
3
|
+
SYSTEM_PROMPT = """\
|
|
4
|
+
You are a document production assistant. You create real files using the
|
|
5
|
+
provided tools:
|
|
6
|
+
|
|
7
|
+
- create_document - Word documents (.docx): reports, letters, guides, notes.
|
|
8
|
+
- create_spreadsheet - Excel workbooks (.xlsx): budgets, trackers, datasets.
|
|
9
|
+
- create_presentation - PowerPoint decks (.pptx): pitches, talks, overviews.
|
|
10
|
+
|
|
11
|
+
Rules:
|
|
12
|
+
1. Always produce the file by calling a tool. Never paste document content
|
|
13
|
+
into the chat instead of calling a tool.
|
|
14
|
+
2. Write complete, substantive content in one call. Do not use placeholders
|
|
15
|
+
like "TODO" or "add content here" - invent sensible, specific content when
|
|
16
|
+
the user gives only a topic.
|
|
17
|
+
3. Choose descriptive filenames (e.g. 'q3-marketing-report.docx').
|
|
18
|
+
4. In spreadsheets, use formulas (cells starting with '=') for totals and
|
|
19
|
+
derived values instead of precomputing numbers. Add a chart when the data
|
|
20
|
+
would benefit from one.
|
|
21
|
+
5. In presentations, start with a 'title' slide and keep bullets short -
|
|
22
|
+
put detail into speaker notes. Use 'chart' slides for numeric comparisons.
|
|
23
|
+
6. Only reference image files the user explicitly told you exist; never
|
|
24
|
+
invent image paths.
|
|
25
|
+
7. If a tool returns an error, fix your arguments and call it again.
|
|
26
|
+
8. If the user asks for multiple files, make one tool call per file.
|
|
27
|
+
After the tools succeed, reply with one short sentence per file created.
|
|
28
|
+
"""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Renderers turn validated specs into real files on disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .docx import render_document
|
|
8
|
+
from .pptx import render_presentation
|
|
9
|
+
from .xlsx import render_spreadsheet
|
|
10
|
+
|
|
11
|
+
__all__ = ["render_document", "render_spreadsheet", "render_presentation", "output_path"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def output_path(out_dir: Path, filename: str, ext: str) -> Path:
|
|
15
|
+
"""Resolve a model-supplied filename to a safe path inside out_dir.
|
|
16
|
+
|
|
17
|
+
Only the base name is kept, so a hostile or confused model cannot write
|
|
18
|
+
outside the output directory.
|
|
19
|
+
"""
|
|
20
|
+
name = Path(filename.strip()).name or f"output{ext}"
|
|
21
|
+
if not name.lower().endswith(ext):
|
|
22
|
+
name += ext
|
|
23
|
+
out_dir = Path(out_dir)
|
|
24
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
return out_dir / name
|
altr/renderers/docx.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from docx import Document
|
|
6
|
+
from docx.shared import Inches
|
|
7
|
+
|
|
8
|
+
from ..markdown import markdown_to_blocks, split_inline
|
|
9
|
+
from ..schemas import (
|
|
10
|
+
BulletList,
|
|
11
|
+
DocumentSpec,
|
|
12
|
+
Heading,
|
|
13
|
+
Image,
|
|
14
|
+
Markdown,
|
|
15
|
+
NumberedList,
|
|
16
|
+
PageBreak,
|
|
17
|
+
Paragraph,
|
|
18
|
+
Table,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_document(
|
|
23
|
+
spec: DocumentSpec, out_dir: Path, template: Path | None = None
|
|
24
|
+
) -> Path:
|
|
25
|
+
from . import output_path
|
|
26
|
+
|
|
27
|
+
doc = Document(str(template)) if template else Document()
|
|
28
|
+
if spec.title:
|
|
29
|
+
doc.add_heading(spec.title, 0)
|
|
30
|
+
|
|
31
|
+
for block in spec.blocks:
|
|
32
|
+
_add_block(doc, block)
|
|
33
|
+
|
|
34
|
+
path = output_path(out_dir, spec.filename, ".docx")
|
|
35
|
+
doc.save(str(path))
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _add_block(doc: Document, block) -> None:
|
|
40
|
+
if isinstance(block, Heading):
|
|
41
|
+
doc.add_heading(block.text, block.level)
|
|
42
|
+
elif isinstance(block, Paragraph):
|
|
43
|
+
_add_styled_paragraph(doc, block.text)
|
|
44
|
+
elif isinstance(block, BulletList):
|
|
45
|
+
for item in block.items:
|
|
46
|
+
_add_styled_paragraph(doc, item, style="List Bullet")
|
|
47
|
+
elif isinstance(block, NumberedList):
|
|
48
|
+
for item in block.items:
|
|
49
|
+
_add_styled_paragraph(doc, item, style="List Number")
|
|
50
|
+
elif isinstance(block, Table):
|
|
51
|
+
table = doc.add_table(rows=1, cols=len(block.headers))
|
|
52
|
+
table.style = "Table Grid"
|
|
53
|
+
for i, header in enumerate(block.headers):
|
|
54
|
+
run = table.rows[0].cells[i].paragraphs[0].add_run(header)
|
|
55
|
+
run.bold = True
|
|
56
|
+
for row in block.rows:
|
|
57
|
+
cells = table.add_row().cells
|
|
58
|
+
for i, value in enumerate(row[: len(block.headers)]):
|
|
59
|
+
cells[i].text = value
|
|
60
|
+
elif isinstance(block, Image):
|
|
61
|
+
image_path = Path(block.path)
|
|
62
|
+
if not image_path.is_file():
|
|
63
|
+
raise FileNotFoundError(f"image not found: {block.path}")
|
|
64
|
+
width = Inches(block.width_inches) if block.width_inches else None
|
|
65
|
+
doc.add_picture(str(image_path), width=width)
|
|
66
|
+
if block.caption:
|
|
67
|
+
doc.add_paragraph(block.caption, style="Caption")
|
|
68
|
+
elif isinstance(block, Markdown):
|
|
69
|
+
for sub_block in markdown_to_blocks(block.text):
|
|
70
|
+
_add_block(doc, sub_block)
|
|
71
|
+
elif isinstance(block, PageBreak):
|
|
72
|
+
doc.add_page_break()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _add_styled_paragraph(doc: Document, text: str, style: str | None = None) -> None:
|
|
76
|
+
"""Add a paragraph, turning inline **bold**/*italic*/`code` into runs."""
|
|
77
|
+
para = doc.add_paragraph(style=style)
|
|
78
|
+
for kind, part in split_inline(text):
|
|
79
|
+
run = para.add_run(part)
|
|
80
|
+
if kind == "bold":
|
|
81
|
+
run.bold = True
|
|
82
|
+
elif kind == "italic":
|
|
83
|
+
run.italic = True
|
|
84
|
+
elif kind == "code":
|
|
85
|
+
run.font.name = "Courier New"
|
altr/renderers/pptx.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pptx import Presentation
|
|
6
|
+
from pptx.chart.data import CategoryChartData
|
|
7
|
+
from pptx.enum.chart import XL_CHART_TYPE
|
|
8
|
+
from pptx.util import Inches
|
|
9
|
+
|
|
10
|
+
from ..schemas import PresentationSpec, Slide
|
|
11
|
+
|
|
12
|
+
# Layout indexes in the standard template. Custom templates must keep the
|
|
13
|
+
# stock layout order (0 title, 1 title+content, 2 section header, 5 title only).
|
|
14
|
+
_TITLE_LAYOUT = 0
|
|
15
|
+
_BULLETS_LAYOUT = 1
|
|
16
|
+
_SECTION_LAYOUT = 2
|
|
17
|
+
_TITLE_ONLY_LAYOUT = 5
|
|
18
|
+
|
|
19
|
+
_CHART_TYPES = {
|
|
20
|
+
"bar": XL_CHART_TYPE.COLUMN_CLUSTERED,
|
|
21
|
+
"line": XL_CHART_TYPE.LINE,
|
|
22
|
+
"pie": XL_CHART_TYPE.PIE,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Body area below the title on a 10 x 7.5 inch slide.
|
|
26
|
+
_BODY = (Inches(0.8), Inches(1.8), Inches(8.4), Inches(5.0))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_presentation(
|
|
30
|
+
spec: PresentationSpec, out_dir: Path, template: Path | None = None
|
|
31
|
+
) -> Path:
|
|
32
|
+
from . import output_path
|
|
33
|
+
|
|
34
|
+
prs = Presentation(str(template)) if template else Presentation()
|
|
35
|
+
|
|
36
|
+
for slide_spec in spec.slides:
|
|
37
|
+
slide = prs.slides.add_slide(prs.slide_layouts[_layout_index(slide_spec)])
|
|
38
|
+
slide.shapes.title.text = slide_spec.title
|
|
39
|
+
|
|
40
|
+
if slide_spec.layout in ("title", "section") and slide_spec.subtitle:
|
|
41
|
+
slide.placeholders[1].text = slide_spec.subtitle
|
|
42
|
+
|
|
43
|
+
if slide_spec.layout == "bullets" and slide_spec.bullets:
|
|
44
|
+
body = slide.placeholders[1].text_frame
|
|
45
|
+
for i, bullet in enumerate(slide_spec.bullets):
|
|
46
|
+
para = body.paragraphs[0] if i == 0 else body.add_paragraph()
|
|
47
|
+
para.text = bullet.text
|
|
48
|
+
para.level = bullet.level
|
|
49
|
+
|
|
50
|
+
if slide_spec.layout == "chart":
|
|
51
|
+
_add_chart(slide, slide_spec)
|
|
52
|
+
|
|
53
|
+
if slide_spec.layout == "image":
|
|
54
|
+
_add_image(slide, slide_spec)
|
|
55
|
+
|
|
56
|
+
if slide_spec.notes:
|
|
57
|
+
slide.notes_slide.notes_text_frame.text = slide_spec.notes
|
|
58
|
+
|
|
59
|
+
path = output_path(out_dir, spec.filename, ".pptx")
|
|
60
|
+
prs.save(str(path))
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _layout_index(slide_spec: Slide) -> int:
|
|
65
|
+
if slide_spec.layout == "title":
|
|
66
|
+
return _TITLE_LAYOUT
|
|
67
|
+
if slide_spec.layout == "section":
|
|
68
|
+
return _SECTION_LAYOUT
|
|
69
|
+
if slide_spec.layout in ("chart", "image"):
|
|
70
|
+
return _TITLE_ONLY_LAYOUT
|
|
71
|
+
return _BULLETS_LAYOUT
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _add_chart(slide, slide_spec: Slide) -> None:
|
|
75
|
+
chart = slide_spec.chart
|
|
76
|
+
data = CategoryChartData()
|
|
77
|
+
data.categories = chart.categories
|
|
78
|
+
for series in chart.series:
|
|
79
|
+
# Pad or trim so every series aligns with the category axis.
|
|
80
|
+
values = list(series.values[: len(chart.categories)])
|
|
81
|
+
values += [0.0] * (len(chart.categories) - len(values))
|
|
82
|
+
data.add_series(series.name, values)
|
|
83
|
+
slide.shapes.add_chart(_CHART_TYPES[chart.kind], *_BODY, data)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _add_image(slide, slide_spec: Slide) -> None:
|
|
87
|
+
image_path = Path(slide_spec.image.path)
|
|
88
|
+
if not image_path.is_file():
|
|
89
|
+
raise FileNotFoundError(f"image not found: {slide_spec.image.path}")
|
|
90
|
+
left, top, width, _ = _BODY
|
|
91
|
+
slide.shapes.add_picture(str(image_path), left, top, width=width)
|
altr/renderers/xlsx.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from openpyxl import Workbook
|
|
6
|
+
from openpyxl.chart import BarChart, LineChart, PieChart, Reference
|
|
7
|
+
from openpyxl.styles import Font
|
|
8
|
+
from openpyxl.utils import get_column_letter
|
|
9
|
+
from openpyxl.worksheet.worksheet import Worksheet
|
|
10
|
+
|
|
11
|
+
from ..schemas import Sheet, SpreadsheetSpec
|
|
12
|
+
|
|
13
|
+
_CHART_CLASSES = {"bar": BarChart, "line": LineChart, "pie": PieChart}
|
|
14
|
+
_CHART_ROWS = 15 # vertical space to leave per chart when stacking
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def render_spreadsheet(spec: SpreadsheetSpec, out_dir: Path) -> Path:
|
|
18
|
+
from . import output_path
|
|
19
|
+
|
|
20
|
+
wb = Workbook()
|
|
21
|
+
wb.remove(wb.active)
|
|
22
|
+
|
|
23
|
+
for sheet in spec.sheets:
|
|
24
|
+
ws = wb.create_sheet(title=sheet.name)
|
|
25
|
+
first_data_row = 1
|
|
26
|
+
if sheet.columns:
|
|
27
|
+
for i, col in enumerate(sheet.columns, start=1):
|
|
28
|
+
cell = ws.cell(row=1, column=i, value=col.header)
|
|
29
|
+
cell.font = Font(bold=True)
|
|
30
|
+
if col.width:
|
|
31
|
+
ws.column_dimensions[get_column_letter(i)].width = col.width
|
|
32
|
+
if sheet.freeze_header:
|
|
33
|
+
ws.freeze_panes = "A2"
|
|
34
|
+
first_data_row = 2
|
|
35
|
+
|
|
36
|
+
for r, row in enumerate(sheet.rows, start=first_data_row):
|
|
37
|
+
for c, value in enumerate(row, start=1):
|
|
38
|
+
ws.cell(row=r, column=c, value=value)
|
|
39
|
+
|
|
40
|
+
_add_charts(ws, sheet, first_data_row)
|
|
41
|
+
|
|
42
|
+
path = output_path(out_dir, spec.filename, ".xlsx")
|
|
43
|
+
wb.save(str(path))
|
|
44
|
+
return path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _add_charts(ws: Worksheet, sheet: Sheet, first_data_row: int) -> None:
|
|
48
|
+
if not sheet.charts or not sheet.rows:
|
|
49
|
+
return
|
|
50
|
+
last_row = first_data_row + len(sheet.rows) - 1
|
|
51
|
+
data_cols = max(len(row) for row in sheet.rows)
|
|
52
|
+
anchor_col = get_column_letter(data_cols + 2)
|
|
53
|
+
|
|
54
|
+
for i, chart_spec in enumerate(sheet.charts):
|
|
55
|
+
chart = _CHART_CLASSES[chart_spec.kind]()
|
|
56
|
+
chart.title = chart_spec.title
|
|
57
|
+
has_header = bool(sheet.columns)
|
|
58
|
+
for col in chart_spec.value_columns:
|
|
59
|
+
data = Reference(
|
|
60
|
+
ws,
|
|
61
|
+
min_col=col,
|
|
62
|
+
min_row=1 if has_header else first_data_row,
|
|
63
|
+
max_row=last_row,
|
|
64
|
+
)
|
|
65
|
+
chart.add_data(data, titles_from_data=has_header)
|
|
66
|
+
categories = Reference(
|
|
67
|
+
ws, min_col=chart_spec.label_column, min_row=first_data_row, max_row=last_row
|
|
68
|
+
)
|
|
69
|
+
chart.set_categories(categories)
|
|
70
|
+
ws.add_chart(chart, f"{anchor_col}{2 + i * _CHART_ROWS}")
|
altr/schemas.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Pydantic specs for the three document types.
|
|
2
|
+
|
|
3
|
+
These models serve double duty: they validate what the model sends, and their
|
|
4
|
+
JSON schemas become the tool-call parameter schemas the model sees. Keep the
|
|
5
|
+
field descriptions model-facing - they are effectively prompt text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated, Literal, Union
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, model_validator
|
|
13
|
+
|
|
14
|
+
ChartType = Literal["bar", "line", "pie"]
|
|
15
|
+
|
|
16
|
+
# --- Word documents (.docx) -------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Heading(BaseModel):
|
|
20
|
+
type: Literal["heading"] = "heading"
|
|
21
|
+
level: int = Field(1, ge=1, le=9, description="1 is the largest heading.")
|
|
22
|
+
text: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Paragraph(BaseModel):
|
|
26
|
+
type: Literal["paragraph"] = "paragraph"
|
|
27
|
+
text: str = Field(
|
|
28
|
+
description="Supports inline markdown: **bold**, *italic*, `code`."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BulletList(BaseModel):
|
|
33
|
+
type: Literal["bullet_list"] = "bullet_list"
|
|
34
|
+
items: list[str] = Field(min_length=1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NumberedList(BaseModel):
|
|
38
|
+
type: Literal["numbered_list"] = "numbered_list"
|
|
39
|
+
items: list[str] = Field(min_length=1)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Table(BaseModel):
|
|
43
|
+
type: Literal["table"] = "table"
|
|
44
|
+
headers: list[str] = Field(min_length=1)
|
|
45
|
+
rows: list[list[str]] = Field(
|
|
46
|
+
default_factory=list,
|
|
47
|
+
description="Each row must have the same number of cells as headers.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Image(BaseModel):
|
|
52
|
+
type: Literal["image"] = "image"
|
|
53
|
+
path: str = Field(
|
|
54
|
+
description="Path to an existing local image file (.png/.jpg). Only "
|
|
55
|
+
"reference files the user explicitly told you about."
|
|
56
|
+
)
|
|
57
|
+
width_inches: float | None = Field(None, gt=0, le=10)
|
|
58
|
+
caption: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Markdown(BaseModel):
|
|
62
|
+
type: Literal["markdown"] = "markdown"
|
|
63
|
+
text: str = Field(
|
|
64
|
+
description="Markdown converted to document content. Supports #-headings, "
|
|
65
|
+
"-/1. lists, | pipe tables |, paragraphs, and inline **bold**/*italic*/`code`."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PageBreak(BaseModel):
|
|
70
|
+
type: Literal["page_break"] = "page_break"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
Block = Annotated[
|
|
74
|
+
Union[Heading, Paragraph, BulletList, NumberedList, Table, Image, Markdown, PageBreak],
|
|
75
|
+
Field(discriminator="type"),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class DocumentSpec(BaseModel):
|
|
80
|
+
"""A Word document composed of ordered content blocks."""
|
|
81
|
+
|
|
82
|
+
filename: str = Field(description="Output file name, e.g. 'report.docx'.")
|
|
83
|
+
title: str | None = Field(None, description="Optional document title heading.")
|
|
84
|
+
blocks: list[Block] = Field(min_length=1, description="Document content, in order.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- Spreadsheets (.xlsx) ---------------------------------------------------
|
|
88
|
+
|
|
89
|
+
Cell = Union[str, int, float, bool, None]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Column(BaseModel):
|
|
93
|
+
header: str
|
|
94
|
+
width: float | None = Field(None, gt=0, description="Column width in characters.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SheetChart(BaseModel):
|
|
98
|
+
"""A chart embedded in a worksheet, built from the sheet's own data."""
|
|
99
|
+
|
|
100
|
+
kind: ChartType = "bar"
|
|
101
|
+
title: str
|
|
102
|
+
label_column: int = Field(
|
|
103
|
+
1, ge=1, description="1-based index of the column holding category labels."
|
|
104
|
+
)
|
|
105
|
+
value_columns: list[int] = Field(
|
|
106
|
+
min_length=1,
|
|
107
|
+
description="1-based indexes of numeric columns to plot as series.",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Sheet(BaseModel):
|
|
112
|
+
name: str = Field(max_length=31, description="Worksheet tab name.")
|
|
113
|
+
columns: list[Column] = Field(
|
|
114
|
+
default_factory=list, description="Header row; rendered bold."
|
|
115
|
+
)
|
|
116
|
+
rows: list[list[Cell]] = Field(
|
|
117
|
+
default_factory=list,
|
|
118
|
+
description="Data rows. String cells starting with '=' are Excel formulas, "
|
|
119
|
+
"e.g. '=SUM(B2:B10)'.",
|
|
120
|
+
)
|
|
121
|
+
freeze_header: bool = Field(True, description="Keep the header row visible on scroll.")
|
|
122
|
+
charts: list[SheetChart] = Field(
|
|
123
|
+
default_factory=list,
|
|
124
|
+
description="Charts plotting this sheet's data, placed beside it.",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class SpreadsheetSpec(BaseModel):
|
|
129
|
+
"""An Excel workbook with one or more worksheets."""
|
|
130
|
+
|
|
131
|
+
filename: str = Field(description="Output file name, e.g. 'budget.xlsx'.")
|
|
132
|
+
sheets: list[Sheet] = Field(min_length=1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- Presentations (.pptx) --------------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class BulletPoint(BaseModel):
|
|
139
|
+
text: str
|
|
140
|
+
level: int = Field(0, ge=0, le=4, description="Indent level; 0 is top level.")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ChartSeries(BaseModel):
|
|
144
|
+
name: str
|
|
145
|
+
values: list[float] = Field(min_length=1)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SlideChart(BaseModel):
|
|
149
|
+
"""A chart drawn on a slide from inline data."""
|
|
150
|
+
|
|
151
|
+
kind: ChartType = "bar"
|
|
152
|
+
categories: list[str] = Field(min_length=1)
|
|
153
|
+
series: list[ChartSeries] = Field(
|
|
154
|
+
min_length=1,
|
|
155
|
+
description="Each series must have exactly one value per category.",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SlideImage(BaseModel):
|
|
160
|
+
path: str = Field(
|
|
161
|
+
description="Path to an existing local image file (.png/.jpg). Only "
|
|
162
|
+
"reference files the user explicitly told you about."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Slide(BaseModel):
|
|
167
|
+
layout: Literal["title", "bullets", "section", "chart", "image"] = Field(
|
|
168
|
+
"bullets",
|
|
169
|
+
description="'title' for the opening slide, 'section' for a divider, "
|
|
170
|
+
"'bullets' for a regular content slide, 'chart' for a chart slide, "
|
|
171
|
+
"'image' for a full-width image slide.",
|
|
172
|
+
)
|
|
173
|
+
title: str
|
|
174
|
+
subtitle: str | None = Field(None, description="Only used by title/section layouts.")
|
|
175
|
+
bullets: list[BulletPoint] = Field(
|
|
176
|
+
default_factory=list, description="Body content for the 'bullets' layout."
|
|
177
|
+
)
|
|
178
|
+
chart: SlideChart | None = Field(None, description="Required for the 'chart' layout.")
|
|
179
|
+
image: SlideImage | None = Field(None, description="Required for the 'image' layout.")
|
|
180
|
+
notes: str | None = Field(None, description="Speaker notes for this slide.")
|
|
181
|
+
|
|
182
|
+
@model_validator(mode="after")
|
|
183
|
+
def _check_layout_content(self) -> "Slide":
|
|
184
|
+
if self.layout == "chart" and self.chart is None:
|
|
185
|
+
raise ValueError("a 'chart' slide needs the 'chart' field")
|
|
186
|
+
if self.layout == "image" and self.image is None:
|
|
187
|
+
raise ValueError("an 'image' slide needs the 'image' field")
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class PresentationSpec(BaseModel):
|
|
192
|
+
"""A PowerPoint deck built from a list of slides."""
|
|
193
|
+
|
|
194
|
+
filename: str = Field(description="Output file name, e.g. 'pitch.pptx'.")
|
|
195
|
+
slides: list[Slide] = Field(min_length=1)
|
altr/tools.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Tool-call definitions and dispatch.
|
|
2
|
+
|
|
3
|
+
`get_tools()` returns OpenAI-format tool definitions you can pass straight to
|
|
4
|
+
any chat-completions endpoint (Groq, Ollama, vLLM, ...). `dispatch()` executes
|
|
5
|
+
a tool call the model made and returns a JSON-safe result dict - errors are
|
|
6
|
+
returned rather than raised, so the model can read them and self-correct.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ValidationError
|
|
16
|
+
|
|
17
|
+
from .renderers import render_document, render_presentation, render_spreadsheet
|
|
18
|
+
from .schemas import DocumentSpec, PresentationSpec, SpreadsheetSpec
|
|
19
|
+
|
|
20
|
+
# name -> (spec class, renderer, template key or None, description)
|
|
21
|
+
_REGISTRY: dict[str, tuple[type[BaseModel], Any, str | None, str]] = {
|
|
22
|
+
"create_document": (
|
|
23
|
+
DocumentSpec,
|
|
24
|
+
render_document,
|
|
25
|
+
"docx",
|
|
26
|
+
"Create a Word document (.docx) from ordered content blocks: headings, "
|
|
27
|
+
"paragraphs, bullet/numbered lists, tables, images, markdown, and page "
|
|
28
|
+
"breaks.",
|
|
29
|
+
),
|
|
30
|
+
"create_spreadsheet": (
|
|
31
|
+
SpreadsheetSpec,
|
|
32
|
+
render_spreadsheet,
|
|
33
|
+
None,
|
|
34
|
+
"Create an Excel workbook (.xlsx) with one or more worksheets. Supports "
|
|
35
|
+
"bold header rows, column widths, frozen headers, formulas (string cells "
|
|
36
|
+
"starting with '='), and bar/line/pie charts of the sheet's data.",
|
|
37
|
+
),
|
|
38
|
+
"create_presentation": (
|
|
39
|
+
PresentationSpec,
|
|
40
|
+
render_presentation,
|
|
41
|
+
"pptx",
|
|
42
|
+
"Create a PowerPoint deck (.pptx) from slides: a title slide, section "
|
|
43
|
+
"dividers, bulleted content slides, chart slides, and image slides, "
|
|
44
|
+
"with optional speaker notes.",
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_tools() -> list[dict[str, Any]]:
|
|
50
|
+
"""OpenAI-format tool definitions for the three document skills."""
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
"type": "function",
|
|
54
|
+
"function": {
|
|
55
|
+
"name": name,
|
|
56
|
+
"description": description,
|
|
57
|
+
"parameters": spec.model_json_schema(),
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
for name, (spec, _, _, description) in _REGISTRY.items()
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def dispatch(
|
|
65
|
+
name: str,
|
|
66
|
+
arguments: str | dict[str, Any],
|
|
67
|
+
out_dir: str | Path,
|
|
68
|
+
templates: dict[str, Path] | None = None,
|
|
69
|
+
) -> dict[str, Any]:
|
|
70
|
+
"""Execute one tool call and return {"ok": True, "file": path} or an error.
|
|
71
|
+
|
|
72
|
+
`templates` optionally maps 'docx'/'pptx' to a template file the renderer
|
|
73
|
+
starts from, for brand styling.
|
|
74
|
+
"""
|
|
75
|
+
entry = _REGISTRY.get(name)
|
|
76
|
+
if entry is None:
|
|
77
|
+
return {"ok": False, "error": f"unknown tool {name!r}"}
|
|
78
|
+
spec_cls, render, template_key, _ = entry
|
|
79
|
+
|
|
80
|
+
if isinstance(arguments, str):
|
|
81
|
+
try:
|
|
82
|
+
arguments = json.loads(arguments)
|
|
83
|
+
except json.JSONDecodeError as e:
|
|
84
|
+
return {"ok": False, "error": f"arguments are not valid JSON: {e}"}
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
spec = spec_cls.model_validate(arguments)
|
|
88
|
+
except ValidationError as e:
|
|
89
|
+
return {"ok": False, "error": f"invalid arguments: {e}"}
|
|
90
|
+
|
|
91
|
+
template = (templates or {}).get(template_key) if template_key else None
|
|
92
|
+
try:
|
|
93
|
+
if template_key:
|
|
94
|
+
path = render(spec, Path(out_dir), template=template)
|
|
95
|
+
else:
|
|
96
|
+
path = render(spec, Path(out_dir))
|
|
97
|
+
except Exception as e: # surface renderer failures to the model
|
|
98
|
+
return {"ok": False, "error": f"failed to render: {e}"}
|
|
99
|
+
return {"ok": True, "file": str(path)}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: altr-oss
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Open source doc / excel / ppt skills for LLMs - Groq, Ollama, vLLM, any OpenAI-compatible endpoint.
|
|
5
|
+
Project-URL: Homepage, https://github.com/PasinduSuraweera/altr-oss
|
|
6
|
+
Author: Pasindu Suraweera
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 Pasindu Suraweera
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Keywords: docx,gpt-oss,groq,llm,pptx,skills,tool-calling,xlsx
|
|
30
|
+
Classifier: Development Status :: 4 - Beta
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Topic :: Office/Business :: Office Suites
|
|
38
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
39
|
+
Requires-Python: >=3.10
|
|
40
|
+
Requires-Dist: openai>=1.40
|
|
41
|
+
Requires-Dist: openpyxl>=3.1
|
|
42
|
+
Requires-Dist: pydantic>=2.7
|
|
43
|
+
Requires-Dist: python-docx>=1.1
|
|
44
|
+
Requires-Dist: python-pptx>=1.0
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# altr
|
|
50
|
+
|
|
51
|
+
> Open source doc / excel / ppt skills for LLMs.
|
|
52
|
+
|
|
53
|
+
[](https://github.com/PasinduSuraweera/altr-oss/actions/workflows/ci.yml)
|
|
54
|
+
[](LICENSE)
|
|
55
|
+
[](pyproject.toml)
|
|
56
|
+
|
|
57
|
+
Claude has document skills. ChatGPT has them. **Open-weight models don't.**
|
|
58
|
+
Point `gpt-oss-120b` on Groq (or any model behind an OpenAI-compatible API) at
|
|
59
|
+
altr and it gains three tools it can call to produce real files:
|
|
60
|
+
|
|
61
|
+
| Tool | Output | Good for |
|
|
62
|
+
| --------------------- | ------- | ------------------------------------------ |
|
|
63
|
+
| `create_document` | `.docx` | reports, guides, letters, meeting notes |
|
|
64
|
+
| `create_spreadsheet` | `.xlsx` | budgets, trackers, datasets - with formulas |
|
|
65
|
+
| `create_presentation` | `.pptx` | pitch decks, talks - with speaker notes |
|
|
66
|
+
|
|
67
|
+
The model sends structured JSON through standard tool calling; altr
|
|
68
|
+
validates it (Pydantic) and renders it (`python-docx`, `openpyxl`,
|
|
69
|
+
`python-pptx`). Renderer errors are fed back to the model so it can correct
|
|
70
|
+
itself. No code execution, no sandboxes - the model can only emit document
|
|
71
|
+
content.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
pip install altr-oss
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The PyPI distribution is `altr-oss`; the import and the CLI command are both
|
|
80
|
+
plain `altr`.
|
|
81
|
+
|
|
82
|
+
Or straight from the repo:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
pip install git+https://github.com/PasinduSuraweera/altr-oss
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Quickstart (CLI)
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
export GROQ_API_KEY=gsk_...
|
|
92
|
+
|
|
93
|
+
altr make "Create a 6-slide pitch deck for a solar-powered drone startup"
|
|
94
|
+
altr make "Make a 12-month SaaS budget spreadsheet with formula totals"
|
|
95
|
+
altr make "Write a 2-page onboarding doc for new backend engineers"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Files land in `./output`. Works with any OpenAI-compatible server:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
# Groq (default)
|
|
102
|
+
altr make "..." --model openai/gpt-oss-120b
|
|
103
|
+
|
|
104
|
+
# Ollama, fully local
|
|
105
|
+
altr make "..." --base-url http://localhost:11434/v1 --model llama3.3 --api-key ollama
|
|
106
|
+
|
|
107
|
+
# vLLM / LM Studio / anything else that speaks chat completions
|
|
108
|
+
altr make "..." --base-url http://localhost:8000/v1 --model my-model
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Render a JSON spec directly, no model involved (great for testing):
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
altr render presentation examples/pitch-deck.json
|
|
115
|
+
altr render spreadsheet examples/budget.json
|
|
116
|
+
altr render document examples/onboarding-doc.json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Use it as a library
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from altr import OfficeAgent
|
|
123
|
+
|
|
124
|
+
agent = OfficeAgent(model="openai/gpt-oss-120b", out_dir="out")
|
|
125
|
+
result = agent.run("Create a quarterly report with a KPI table")
|
|
126
|
+
print(result.files) # [PosixPath('out/quarterly-report.docx')]
|
|
127
|
+
print(result.reply) # the model's final message
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Use it as a skill in your own agent
|
|
131
|
+
|
|
132
|
+
Already have an agent loop? Take just the tools:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from altr import SYSTEM_PROMPT, get_tools, dispatch
|
|
136
|
+
|
|
137
|
+
response = client.chat.completions.create(
|
|
138
|
+
model="openai/gpt-oss-120b",
|
|
139
|
+
messages=[{"role": "system", "content": SYSTEM_PROMPT}, ...],
|
|
140
|
+
tools=get_tools(), # OpenAI-format tool definitions
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
for call in response.choices[0].message.tool_calls:
|
|
144
|
+
result = dispatch(call.function.name, call.function.arguments, out_dir="out")
|
|
145
|
+
# {"ok": True, "file": "out/report.docx"} - or {"ok": False, "error": ...}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`dispatch` never raises on bad model output - validation and render errors come
|
|
149
|
+
back as data you can hand to the model for self-correction.
|
|
150
|
+
|
|
151
|
+
## What the model can express
|
|
152
|
+
|
|
153
|
+
- **Documents**: headings (9 levels), paragraphs with inline
|
|
154
|
+
**bold**/*italic*/`code`, bullet & numbered lists, tables with bold headers,
|
|
155
|
+
images with captions, whole markdown blocks, page breaks.
|
|
156
|
+
- **Spreadsheets**: multiple worksheets, bold header rows, column widths,
|
|
157
|
+
frozen header rows, live Excel formulas (`=SUM(B2:B10)`), and bar/line/pie
|
|
158
|
+
charts built from the sheet's data.
|
|
159
|
+
- **Presentations**: title slides, section dividers, bulleted slides with
|
|
160
|
+
indent levels, chart slides, full-width image slides, speaker notes.
|
|
161
|
+
|
|
162
|
+
Filenames from the model are sanitized to their base name, so output can never
|
|
163
|
+
escape the output directory. Image paths must point at existing local files -
|
|
164
|
+
the system prompt tells the model to only use files you mention.
|
|
165
|
+
|
|
166
|
+
## Brand templates
|
|
167
|
+
|
|
168
|
+
Start every generated file from your own template so fonts, colors, and slide
|
|
169
|
+
masters match your brand:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
altr make "..." --docx-template brand.docx --pptx-template brand.pptx
|
|
173
|
+
altr render presentation deck.json --template brand.pptx
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Custom `.pptx` templates must keep the stock layout order (0 title,
|
|
177
|
+
1 title+content, 2 section header, 5 title only).
|
|
178
|
+
|
|
179
|
+
## PDF export
|
|
180
|
+
|
|
181
|
+
Pass `--pdf` to `make` or `render` to also export each created file as PDF.
|
|
182
|
+
Requires LibreOffice (`soffice`) on your PATH. From Python:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from altr import to_pdf
|
|
186
|
+
to_pdf("output/report.docx")
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Roadmap
|
|
190
|
+
|
|
191
|
+
- [ ] Recipe/preset library of reusable prompts
|
|
192
|
+
- [ ] Chart styling options (colors, axis titles, legends)
|
|
193
|
+
- [ ] Nested markdown lists and blockquotes
|
|
194
|
+
- [ ] Watermarks and headers/footers
|
|
195
|
+
|
|
196
|
+
## Contributing
|
|
197
|
+
|
|
198
|
+
PRs welcome - see [CONTRIBUTING.md](CONTRIBUTING.md). Good first issues:
|
|
199
|
+
roadmap items above, or new block types for the document schema.
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
[MIT](LICENSE) © Pasindu Suraweera
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
altr/__init__.py,sha256=557k_EfSQsia3iZ7qkTJFn_8FQgHx-DZYnFXY3tD63o,635
|
|
2
|
+
altr/agent.py,sha256=bqHAswOupvhF1Xa-ksOO07a-TtSs98ql0uC5qk2b7YA,3717
|
|
3
|
+
altr/cli.py,sha256=3v2wejkFAmzTZOJ_WEQcNpTnwgnAfJsIjc-fChamIiE,3901
|
|
4
|
+
altr/markdown.py,sha256=2Buob3DDU_m9FmOgV1Y9C1uArLHIxPsPaG4DobvBSCs,3309
|
|
5
|
+
altr/pdf.py,sha256=SVIsFNedhFr8GnlfvEuUBYkIOyzEm7RbtxWw6EgudVI,1251
|
|
6
|
+
altr/prompts.py,sha256=2C0Pc77GYUVFcOjRzSI11huRgqcGS1FzMLm6QFTUvws,1440
|
|
7
|
+
altr/schemas.py,sha256=ojgIG9tYsKKaH3bsh4TtyIbRLHUg8Uug_HqWHAwjBSE,6307
|
|
8
|
+
altr/tools.py,sha256=ceN37TYe0UG7ujuF79bs99C1MOluPPAgpcxTDhSskBA,3452
|
|
9
|
+
altr/renderers/__init__.py,sha256=LXfYjOoG7g-iH_sMLhH6QUyYUWAMtNWj0ymrBXndvAg,800
|
|
10
|
+
altr/renderers/docx.py,sha256=1skG8Oafn2zzEfn1-B4N7cUfy7skH8Gvd0NYEHuagfs,2773
|
|
11
|
+
altr/renderers/pptx.py,sha256=s3KO2iljQRVAoMDNxIhQBNBaOw9FbdJnTRBtfK1Fkrg,3051
|
|
12
|
+
altr/renderers/xlsx.py,sha256=Tm6fQouaCrEfY0xgro5DLrQikaVmJVFwFiBKmH-lyJA,2465
|
|
13
|
+
altr_oss-0.2.0.dist-info/METADATA,sha256=bnZTZqT031k3ZoyTxanwLLr0vNRQ_E2yqQB1nLsZmsY,7579
|
|
14
|
+
altr_oss-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
15
|
+
altr_oss-0.2.0.dist-info/entry_points.txt,sha256=VH5Lm7ca5UbF7e1DhnjMOQcE7rBarodnt3vCRPhWsIA,39
|
|
16
|
+
altr_oss-0.2.0.dist-info/licenses/LICENSE,sha256=89BZWbKYY4VKs3SoPPyQnzpGncMgkX8KCImF2gp87_E,1074
|
|
17
|
+
altr_oss-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pasindu Suraweera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|