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 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
+ [![CI](https://github.com/PasinduSuraweera/altr-oss/actions/workflows/ci.yml/badge.svg)](https://github.com/PasinduSuraweera/altr-oss/actions/workflows/ci.yml)
54
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
55
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ altr = altr.cli:main
@@ -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.