pal-code 0.1.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.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- pal_code-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Per-session document index on top of the SDK: full text goes to the storage manager's files,
|
|
2
|
+
chunks go to its vector store. Later turns retrieve only the chunks that match the question."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from pal.config import DocumentsConfig
|
|
11
|
+
from pal.documents.components.chunker import Chunk, chunk_markdown
|
|
12
|
+
from pal.errors import NotFoundError
|
|
13
|
+
from pal.paths import safe_id
|
|
14
|
+
from pal.tokens import estimate_tokens
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DocInfo:
|
|
21
|
+
name: str
|
|
22
|
+
slug: str
|
|
23
|
+
tokens: int
|
|
24
|
+
parts: int
|
|
25
|
+
outline: list[str] = field(default_factory=list)
|
|
26
|
+
inline: bool = True # small enough to send in full on the upload turn
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Hit:
|
|
31
|
+
doc: str
|
|
32
|
+
part: int # 1-based
|
|
33
|
+
parts: int
|
|
34
|
+
heading: str
|
|
35
|
+
text: str
|
|
36
|
+
score: float | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _slug(name: str) -> str:
|
|
40
|
+
return f"{safe_id(name.rsplit('.', 1)[0], default='doc')[:32]}-{hashlib.sha1(name.encode()).hexdigest()[:6]}" # noqa: S324
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DocumentIndex:
|
|
44
|
+
def __init__(self, sdk, cfg: DocumentsConfig | dict | None = None, count: Callable[[str], int] = estimate_tokens):
|
|
45
|
+
self.sdk = sdk
|
|
46
|
+
self.cfg = DocumentsConfig.coerce(cfg)
|
|
47
|
+
self.count = count
|
|
48
|
+
self.docs: dict[str, DocInfo] = {}
|
|
49
|
+
|
|
50
|
+
# -- indexing --------------------------------------------------------------------------
|
|
51
|
+
def add(self, name: str, text: str) -> DocInfo:
|
|
52
|
+
chunks = chunk_markdown(text, self.cfg.chunk_tokens, self.count)
|
|
53
|
+
slug = _slug(name)
|
|
54
|
+
total = sum(c.tokens for c in chunks)
|
|
55
|
+
self.sdk.write(f"docs/{slug}.json", text)
|
|
56
|
+
for c in chunks:
|
|
57
|
+
self.sdk.add_knowledge(f"{slug}:{c.index:04d}", c.text,
|
|
58
|
+
{"doc": name, "part": c.index + 1, "parts": len(chunks), "heading": c.heading})
|
|
59
|
+
outline: list[str] = []
|
|
60
|
+
for c in chunks:
|
|
61
|
+
if c.heading and (not outline or not outline[-1].endswith(f" {c.heading}")):
|
|
62
|
+
outline.append(f"{c.index + 1} {c.heading}")
|
|
63
|
+
info = DocInfo(name=name, slug=slug, tokens=total, parts=len(chunks), outline=outline[:25],
|
|
64
|
+
inline=total <= self.cfg.inline_max_tokens)
|
|
65
|
+
self.docs[name] = info
|
|
66
|
+
log.debug("indexed %s: %d tokens, %d parts", name, total, len(chunks))
|
|
67
|
+
return info
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def total_tokens(self) -> int:
|
|
71
|
+
return sum(d.tokens for d in self.docs.values())
|
|
72
|
+
|
|
73
|
+
def _chunks(self, info: DocInfo) -> list[Chunk]:
|
|
74
|
+
text = self.sdk.read(f"docs/{info.slug}.json")
|
|
75
|
+
return chunk_markdown(text, self.cfg.chunk_tokens, self.count) if isinstance(text, str) else []
|
|
76
|
+
|
|
77
|
+
# -- what the API model sees on the upload turn -----------------------------------------
|
|
78
|
+
def upload_view(self, name: str, text: str, tools: bool) -> str:
|
|
79
|
+
"""Small documents in full. Large ones as a preview plus an outline."""
|
|
80
|
+
info = self.docs[name]
|
|
81
|
+
if info.inline:
|
|
82
|
+
return text
|
|
83
|
+
chunks = self._chunks(info)
|
|
84
|
+
taken: list[str] = []
|
|
85
|
+
used = 0
|
|
86
|
+
for c in chunks:
|
|
87
|
+
if taken and used + c.tokens > self.cfg.preview_tokens:
|
|
88
|
+
break
|
|
89
|
+
taken.append(c.text)
|
|
90
|
+
used += c.tokens
|
|
91
|
+
rest = info.tokens - used
|
|
92
|
+
how = ("Use search_documents(query) or read_document(name, part) to read more."
|
|
93
|
+
if tools else "The parts relevant to each question are attached to it.")
|
|
94
|
+
outline = ("Outline: " + "; ".join(info.outline) + ". ") if info.outline else ""
|
|
95
|
+
return "\n\n".join(taken) + f"\n\n[Preview only. {rest:,} more tokens in {info.parts} parts. {outline}{how}]"
|
|
96
|
+
|
|
97
|
+
def stub(self, name: str) -> str:
|
|
98
|
+
"""What the history keeps instead of the file body."""
|
|
99
|
+
i = self.docs[name]
|
|
100
|
+
return f"[file: {name} | {i.tokens:,} tokens | indexed; relevant parts are attached to each question]"
|
|
101
|
+
|
|
102
|
+
# -- retrieval ------------------------------------------------------------------------------------
|
|
103
|
+
def search(self, query: str, top_k: int | None = None, exclude: set[str] | None = None) -> list[Hit]:
|
|
104
|
+
if not self.docs or not query.strip():
|
|
105
|
+
return []
|
|
106
|
+
k = top_k or self.cfg.retrieve_chunks
|
|
107
|
+
hits = []
|
|
108
|
+
for r in self.sdk.search_knowledge(query, top_k=k * 3):
|
|
109
|
+
score = r.get("score")
|
|
110
|
+
if score is not None and score < self.cfg.min_score:
|
|
111
|
+
continue
|
|
112
|
+
m = r.get("metadata") or {}
|
|
113
|
+
if m.get("doc") not in self.docs or m.get("doc") in (exclude or ()):
|
|
114
|
+
continue
|
|
115
|
+
hits.append(Hit(m["doc"], int(m.get("part", 1)), int(m.get("parts", 1)), m.get("heading", ""),
|
|
116
|
+
r["text"], score))
|
|
117
|
+
return hits[:k]
|
|
118
|
+
|
|
119
|
+
def excerpts(self, query: str, budget: int | None = None, exclude: set[str] | None = None) -> tuple[str, int, int]:
|
|
120
|
+
"""(text block, tokens used, number of chunks). Empty when nothing is relevant enough."""
|
|
121
|
+
budget = budget or self.cfg.budget_tokens
|
|
122
|
+
blocks, used = [], 0
|
|
123
|
+
for h in self.search(query, exclude=exclude):
|
|
124
|
+
body = f"[{h.doc} part {h.part}/{h.parts}{': ' + h.heading if h.heading else ''}]\n{h.text}"
|
|
125
|
+
cost = self.count(body)
|
|
126
|
+
if used + cost > budget:
|
|
127
|
+
continue
|
|
128
|
+
blocks.append(body)
|
|
129
|
+
used += cost
|
|
130
|
+
if not blocks:
|
|
131
|
+
return "", 0, 0
|
|
132
|
+
return "<documents>\n" + "\n\n".join(blocks) + "\n</documents>", used, len(blocks)
|
|
133
|
+
|
|
134
|
+
def outline_text(self, name: str) -> str:
|
|
135
|
+
"""Every part of a document with its heading and size, so a model can decide which part to read."""
|
|
136
|
+
match = next((n for n in self.docs if n.lower() == name.strip().lower()), None)
|
|
137
|
+
if match is None:
|
|
138
|
+
return f"no document named {name!r}; documents: {', '.join(self.docs) or 'none'}"
|
|
139
|
+
info = self.docs[match]
|
|
140
|
+
lines = [f"{info.name}: {info.tokens:,} tokens in {info.parts} parts"]
|
|
141
|
+
for c in self._chunks(info)[:60]:
|
|
142
|
+
lines.append(f"part {c.index + 1} ({c.tokens} tokens): {c.heading or '(no heading)'}")
|
|
143
|
+
if info.parts > 60:
|
|
144
|
+
lines.append(f"[{info.parts - 60} more parts not shown]")
|
|
145
|
+
return "\n".join(lines)
|
|
146
|
+
|
|
147
|
+
def read_part(self, name: str, part: int) -> str:
|
|
148
|
+
info = self.docs.get(name) or next((d for n, d in self.docs.items() if n.lower() == name.lower()), None)
|
|
149
|
+
if info is None:
|
|
150
|
+
raise NotFoundError(f"no document named {name!r}. Available: {', '.join(self.docs) or 'none'}")
|
|
151
|
+
chunks = self._chunks(info)
|
|
152
|
+
if not 1 <= part <= len(chunks):
|
|
153
|
+
raise NotFoundError(f"{info.name} has parts 1 to {len(chunks)}")
|
|
154
|
+
c = chunks[part - 1]
|
|
155
|
+
return f"[{info.name} part {part}/{len(chunks)}{': ' + c.heading if c.heading else ''}]\n{c.text}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
pal/errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Exception hierarchy. Everything Pal raises on purpose derives from PalError, so the
|
|
2
|
+
web layer can map it to an HTTP status without string matching and never leaks a traceback."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PalError(Exception):
|
|
7
|
+
"""Base class. `http_status` is what the web layer answers with; `public` says whether the
|
|
8
|
+
message is safe to show the user as-is."""
|
|
9
|
+
|
|
10
|
+
http_status = 500
|
|
11
|
+
public = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UserInputError(PalError, ValueError):
|
|
15
|
+
"""The request was wrong: bad file, empty message, unknown option. Message is user-safe."""
|
|
16
|
+
|
|
17
|
+
http_status = 400
|
|
18
|
+
public = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigError(PalError, ValueError):
|
|
22
|
+
"""Settings are invalid. Raised at startup, so a bad config fails fast and clearly."""
|
|
23
|
+
|
|
24
|
+
http_status = 500
|
|
25
|
+
public = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProviderError(PalError):
|
|
29
|
+
"""The API model or the local model failed (network, auth, rate limit, bad response)."""
|
|
30
|
+
|
|
31
|
+
http_status = 502
|
|
32
|
+
public = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DependencyMissingError(UserInputError):
|
|
36
|
+
"""An optional dependency or system tool (pandoc, graphify) is not installed."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NotFoundError(UserInputError):
|
|
40
|
+
http_status = 404
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pal.errors import UserInputError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConversionError(UserInputError):
|
|
9
|
+
"""A file could not be turned into markdown. A UserInputError: shown to the user as a per-file error."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ConvertedFile:
|
|
14
|
+
name: str
|
|
15
|
+
text: str # markdown (or the original text for plain-text files)
|
|
16
|
+
source_format: str # "html", "pdf", "docx", ... or "text"
|
|
17
|
+
converted: bool # False when the file was already text and passed through
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def table_to_markdown(rows: list[list[str]]) -> str:
|
|
21
|
+
"""Render rows (first row = header) as a GitHub-style pipe table."""
|
|
22
|
+
rows = [r for r in rows if any(c.strip() for c in r)]
|
|
23
|
+
if not rows:
|
|
24
|
+
return ""
|
|
25
|
+
width = max(len(r) for r in rows)
|
|
26
|
+
# drop columns that are empty in every row
|
|
27
|
+
keep = [i for i in range(width) if any(i < len(r) and r[i].strip() for r in rows)]
|
|
28
|
+
if not keep:
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
def cell(r: list[str], i: int) -> str:
|
|
32
|
+
v = r[i] if i < len(r) else ""
|
|
33
|
+
return v.replace("|", "\\|").replace("\r", " ").replace("\n", " ").strip()
|
|
34
|
+
|
|
35
|
+
lines = ["| " + " | ".join(cell(rows[0], i) for i in keep) + " |",
|
|
36
|
+
"| " + " | ".join("---" for _ in keep) + " |"]
|
|
37
|
+
lines += ["| " + " | ".join(cell(r, i) for i in keep) + " |" for r in rows[1:]]
|
|
38
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""HTML, DOCX, ODT, RTF, EPUB and LaTeX -> GitHub-flavoured markdown via pandoc."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from pal.input_converters.components.base import ConversionError
|
|
8
|
+
|
|
9
|
+
# extension -> pandoc reader
|
|
10
|
+
READERS = {
|
|
11
|
+
"html": "html", "htm": "html", "xhtml": "html",
|
|
12
|
+
"docx": "docx", "odt": "odt", "rtf": "rtf",
|
|
13
|
+
"epub": "epub", "tex": "latex", "latex": "latex",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
_TIMEOUT_S = 60
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def available() -> bool:
|
|
20
|
+
return shutil.which("pandoc") is not None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def docx_without_pandoc(raw: bytes) -> str:
|
|
24
|
+
"""A Word file as markdown without pandoc: headings, lists and tables from the document's XML (Python's standard library only)."""
|
|
25
|
+
import io
|
|
26
|
+
import xml.etree.ElementTree as ET # noqa: S405 (expat in Python 3.10+ limits entity expansion)
|
|
27
|
+
import zipfile
|
|
28
|
+
w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
|
29
|
+
try:
|
|
30
|
+
xml = zipfile.ZipFile(io.BytesIO(raw)).read("word/document.xml")
|
|
31
|
+
except (zipfile.BadZipFile, KeyError):
|
|
32
|
+
raise ConversionError("not a valid Word (.docx) file") from None
|
|
33
|
+
if len(xml) > 50_000_000:
|
|
34
|
+
raise ConversionError("the Word file is too large to read")
|
|
35
|
+
body = ET.fromstring(xml).find(f"{w}body") # noqa: S314
|
|
36
|
+
if body is None:
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
def text_of(el) -> str:
|
|
40
|
+
return "".join(t.text or "" for t in el.iter(f"{w}t"))
|
|
41
|
+
out: list[str] = []
|
|
42
|
+
for el in body:
|
|
43
|
+
if el.tag == f"{w}p":
|
|
44
|
+
text = text_of(el).strip()
|
|
45
|
+
if not text:
|
|
46
|
+
continue
|
|
47
|
+
style = el.find(f"{w}pPr/{w}pStyle")
|
|
48
|
+
name = (style.get(f"{w}val") or "") if style is not None else ""
|
|
49
|
+
if name.lower().startswith("heading") and name[-1:].isdigit():
|
|
50
|
+
out.append("#" * min(6, int(name[-1])) + " " + text)
|
|
51
|
+
elif name.lower() == "title":
|
|
52
|
+
out.append("# " + text)
|
|
53
|
+
elif el.find(f"{w}pPr/{w}numPr") is not None or name.lower().startswith("list"):
|
|
54
|
+
if out and out[-1].startswith("- "):
|
|
55
|
+
out[-1] += "\n- " + text # one list, not a paragraph per item
|
|
56
|
+
else:
|
|
57
|
+
out.append("- " + text)
|
|
58
|
+
else:
|
|
59
|
+
out.append(text)
|
|
60
|
+
elif el.tag == f"{w}tbl":
|
|
61
|
+
rows = [[text_of(c).strip().replace("|", "\\|") for c in r.iter(f"{w}tc")] for r in el.iter(f"{w}tr")]
|
|
62
|
+
rows = [r for r in rows if any(r)]
|
|
63
|
+
if rows:
|
|
64
|
+
width = max(len(r) for r in rows)
|
|
65
|
+
rows = [r + [""] * (width - len(r)) for r in rows]
|
|
66
|
+
out.append("\n".join(["| " + " | ".join(rows[0]) + " |", "|" + " --- |" * width] + ["| " + " | ".join(r) + " |" for r in rows[1:]]))
|
|
67
|
+
return "\n\n".join(out)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def convert(ext: str, raw: bytes) -> str:
|
|
71
|
+
reader = READERS[ext]
|
|
72
|
+
if not available() and ext == "docx":
|
|
73
|
+
return docx_without_pandoc(raw)
|
|
74
|
+
if not available():
|
|
75
|
+
raise ConversionError(
|
|
76
|
+
f".{ext} needs pandoc, which is not installed (apt install pandoc / brew install pandoc)")
|
|
77
|
+
cmd = ["pandoc", "-f", reader, "-t", "gfm-raw_html", "--wrap=none"]
|
|
78
|
+
try:
|
|
79
|
+
p = subprocess.run(cmd, input=raw, capture_output=True, timeout=_TIMEOUT_S)
|
|
80
|
+
except subprocess.TimeoutExpired:
|
|
81
|
+
raise ConversionError(f"pandoc timed out after {_TIMEOUT_S}s") from None
|
|
82
|
+
if p.returncode != 0 and ext == "docx":
|
|
83
|
+
return docx_without_pandoc(raw) # pandoc rejects some Word files that are still readable
|
|
84
|
+
if p.returncode != 0:
|
|
85
|
+
err = p.stderr.decode("utf-8", errors="replace").strip().splitlines()
|
|
86
|
+
raise ConversionError(f"pandoc could not read the file ({err[-1] if err else 'unknown error'})") from None
|
|
87
|
+
return p.stdout.decode("utf-8", errors="replace")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Text-layer PDFs -> markdown. Uses pymupdf4llm (headings, tables) when it is
|
|
2
|
+
installed, otherwise pypdf with one section per page. Scans have no text layer
|
|
3
|
+
and are rejected: there is no OCR here."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import io
|
|
7
|
+
|
|
8
|
+
from pal.input_converters.components.base import ConversionError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _with_pymupdf(raw: bytes) -> str | None:
|
|
12
|
+
try:
|
|
13
|
+
import pymupdf # noqa: F401
|
|
14
|
+
import pymupdf4llm
|
|
15
|
+
except ImportError:
|
|
16
|
+
return None
|
|
17
|
+
doc = pymupdf.open(stream=raw, filetype="pdf")
|
|
18
|
+
try:
|
|
19
|
+
return pymupdf4llm.to_markdown(doc)
|
|
20
|
+
finally:
|
|
21
|
+
doc.close()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _with_pypdf(raw: bytes) -> str:
|
|
25
|
+
try:
|
|
26
|
+
from pypdf import PdfReader
|
|
27
|
+
except ImportError:
|
|
28
|
+
raise ConversionError("PDF support needs pypdf (pip install pypdf)") from None
|
|
29
|
+
pages = [(p.extract_text() or "").strip() for p in PdfReader(io.BytesIO(raw)).pages]
|
|
30
|
+
if len(pages) == 1:
|
|
31
|
+
return pages[0]
|
|
32
|
+
return "\n\n".join(f"## Page {i}\n\n{t}" for i, t in enumerate(pages, 1) if t)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def convert(raw: bytes) -> str:
|
|
36
|
+
try:
|
|
37
|
+
text = _with_pymupdf(raw)
|
|
38
|
+
if text is None or not text.strip():
|
|
39
|
+
text = _with_pypdf(raw)
|
|
40
|
+
except ConversionError:
|
|
41
|
+
raise
|
|
42
|
+
except Exception as e:
|
|
43
|
+
raise ConversionError(f"could not read PDF ({e})") from e
|
|
44
|
+
if not text.strip():
|
|
45
|
+
raise ConversionError("PDF has no text layer (scanned?); OCR is not supported")
|
|
46
|
+
return text
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""PowerPoint -> markdown: one `## Slide N` section per slide, speaker notes kept."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
|
|
6
|
+
from pal.input_converters.components.base import ConversionError, table_to_markdown
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _shape_lines(shape, out: list[str]) -> None:
|
|
10
|
+
if getattr(shape, "shape_type", None) == 6 or hasattr(shape, "shapes"): # group
|
|
11
|
+
for s in getattr(shape, "shapes", []):
|
|
12
|
+
_shape_lines(s, out)
|
|
13
|
+
return
|
|
14
|
+
if getattr(shape, "has_table", False) and shape.has_table:
|
|
15
|
+
rows = [[c.text for c in r.cells] for r in shape.table.rows]
|
|
16
|
+
md = table_to_markdown(rows)
|
|
17
|
+
if md:
|
|
18
|
+
out.append(md)
|
|
19
|
+
return
|
|
20
|
+
if getattr(shape, "has_text_frame", False) and shape.has_text_frame:
|
|
21
|
+
for para in shape.text_frame.paragraphs:
|
|
22
|
+
t = "".join(r.text for r in para.runs).strip() or para.text.strip()
|
|
23
|
+
if t:
|
|
24
|
+
out.append((" " * min(para.level, 6)) + "- " + t if para.level else t)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def convert(raw: bytes) -> str:
|
|
28
|
+
try:
|
|
29
|
+
from pptx import Presentation
|
|
30
|
+
except ImportError:
|
|
31
|
+
raise ConversionError("PowerPoint support needs python-pptx (pip install python-pptx)") from None
|
|
32
|
+
try:
|
|
33
|
+
prs = Presentation(io.BytesIO(raw))
|
|
34
|
+
except Exception as e:
|
|
35
|
+
raise ConversionError(f"could not read PPTX ({e})") from e
|
|
36
|
+
sections = []
|
|
37
|
+
for i, slide in enumerate(prs.slides, 1):
|
|
38
|
+
title = ""
|
|
39
|
+
if slide.shapes.title is not None and slide.shapes.title.has_text_frame:
|
|
40
|
+
title = slide.shapes.title.text_frame.text.strip()
|
|
41
|
+
lines: list[str] = []
|
|
42
|
+
for shape in slide.shapes:
|
|
43
|
+
if slide.shapes.title is not None and shape.shape_id == slide.shapes.title.shape_id:
|
|
44
|
+
continue
|
|
45
|
+
_shape_lines(shape, lines)
|
|
46
|
+
if slide.has_notes_slide and slide.notes_slide.notes_text_frame is not None:
|
|
47
|
+
notes = slide.notes_slide.notes_text_frame.text.strip()
|
|
48
|
+
if notes:
|
|
49
|
+
lines.append(f"**Notes:** {notes}")
|
|
50
|
+
head = f"## Slide {i}" + (f": {title}" if title else "")
|
|
51
|
+
sections.append(head + ("\n\n" + "\n\n".join(lines) if lines else ""))
|
|
52
|
+
return "\n\n".join(sections)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Excel -> markdown: one `## Sheet: name` section with a pipe table per sheet.
|
|
2
|
+
Formula cells give their cached (last-saved) value."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
|
|
7
|
+
from pal.input_converters.components.base import ConversionError, table_to_markdown
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _fmt(v) -> str:
|
|
11
|
+
if v is None:
|
|
12
|
+
return ""
|
|
13
|
+
if isinstance(v, float) and v.is_integer():
|
|
14
|
+
return str(int(v))
|
|
15
|
+
if hasattr(v, "isoformat"):
|
|
16
|
+
return v.isoformat(sep=" ") if hasattr(v, "hour") and (v.hour or v.minute or v.second) else v.isoformat()[:10]
|
|
17
|
+
return str(v)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def convert(raw: bytes) -> str:
|
|
21
|
+
try:
|
|
22
|
+
from openpyxl import load_workbook
|
|
23
|
+
except ImportError:
|
|
24
|
+
raise ConversionError("Excel support needs openpyxl (pip install openpyxl)") from None
|
|
25
|
+
try:
|
|
26
|
+
wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
raise ConversionError(f"could not read XLSX ({e})") from e
|
|
29
|
+
sections = []
|
|
30
|
+
try:
|
|
31
|
+
for ws in wb.worksheets:
|
|
32
|
+
rows = [[_fmt(v) for v in row] for row in ws.iter_rows(values_only=True)]
|
|
33
|
+
table = table_to_markdown(rows)
|
|
34
|
+
if table:
|
|
35
|
+
sections.append(f"## Sheet: {ws.title}\n\n{table}")
|
|
36
|
+
finally:
|
|
37
|
+
wb.close()
|
|
38
|
+
return "\n\n".join(sections)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Attachment -> markdown, before anything reaches an API model.
|
|
2
|
+
|
|
3
|
+
Text-heavy binary/markup formats are converted to markdown because it costs
|
|
4
|
+
fewer tokens than the source markup and keeps structure (headings, lists,
|
|
5
|
+
tables) that flat extraction loses:
|
|
6
|
+
|
|
7
|
+
html/htm/xhtml pandoc
|
|
8
|
+
docx, odt, rtf pandoc
|
|
9
|
+
epub pandoc
|
|
10
|
+
tex/latex pandoc
|
|
11
|
+
pdf (text layer only) pymupdf4llm if installed, else pypdf
|
|
12
|
+
pptx python-pptx
|
|
13
|
+
xlsx openpyxl
|
|
14
|
+
|
|
15
|
+
Anything else is treated as plain text (code, CSV, JSON, .md, ...) and passed
|
|
16
|
+
through unchanged. Binary files that are not on the list are rejected.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import PurePath
|
|
21
|
+
|
|
22
|
+
from pal.input_converters.components import (
|
|
23
|
+
pandoc_converter,
|
|
24
|
+
pdf_converter,
|
|
25
|
+
pptx_converter,
|
|
26
|
+
xlsx_converter,
|
|
27
|
+
)
|
|
28
|
+
from pal.input_converters.components.base import ConversionError, ConvertedFile
|
|
29
|
+
|
|
30
|
+
CONVERTIBLE_EXTENSIONS = tuple(sorted(
|
|
31
|
+
set(pandoc_converter.READERS) | {"pdf", "pptx", "xlsx"}
|
|
32
|
+
))
|
|
33
|
+
|
|
34
|
+
# Legacy binary formats we cannot convert; say so instead of "binary not supported".
|
|
35
|
+
_LEGACY = {"doc": "docx", "ppt": "pptx", "xls": "xlsx"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _ext(name: str) -> str:
|
|
39
|
+
return PurePath(name).suffix.lower().lstrip(".")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def convert_to_markdown(name: str, raw: bytes) -> ConvertedFile:
|
|
43
|
+
"""Return the file's content as markdown (or unchanged text). Raises
|
|
44
|
+
ConversionError (a ValueError) with a user-readable message on failure."""
|
|
45
|
+
name = name or "file"
|
|
46
|
+
ext = _ext(name)
|
|
47
|
+
|
|
48
|
+
def done(text: str, fmt: str, converted: bool = True) -> ConvertedFile:
|
|
49
|
+
text = text.replace("\r\n", "\n").strip("\n")
|
|
50
|
+
if not text.strip():
|
|
51
|
+
raise ConversionError(f"{name}: no readable text")
|
|
52
|
+
return ConvertedFile(name=name, text=text, source_format=fmt, converted=converted)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
if ext in pandoc_converter.READERS:
|
|
56
|
+
return done(pandoc_converter.convert(ext, raw), ext)
|
|
57
|
+
if ext == "pdf":
|
|
58
|
+
return done(pdf_converter.convert(raw), "pdf")
|
|
59
|
+
if ext == "pptx":
|
|
60
|
+
return done(pptx_converter.convert(raw), "pptx")
|
|
61
|
+
if ext in ("xlsx", "xlsm"):
|
|
62
|
+
return done(xlsx_converter.convert(raw), "xlsx")
|
|
63
|
+
except ConversionError as e:
|
|
64
|
+
raise ConversionError(f"{name}: {e}") from e
|
|
65
|
+
|
|
66
|
+
if ext in _LEGACY:
|
|
67
|
+
raise ConversionError(f"{name}: legacy .{ext} is not supported; save it as .{_LEGACY[ext]} and re-attach")
|
|
68
|
+
if b"\x00" in raw[:4096]:
|
|
69
|
+
raise ConversionError(
|
|
70
|
+
f"{name}: binary files are not supported "
|
|
71
|
+
f"(text, code, CSV, JSON and {', '.join('.' + e for e in CONVERTIBLE_EXTENSIONS)} only)")
|
|
72
|
+
return done(raw.decode("utf-8", errors="replace"), "text", converted=False)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Convert a file that is already in a workspace to markdown: the converter as a tool.
|
|
2
|
+
|
|
3
|
+
Uploads are converted automatically when they arrive. This is the same converter for a file that is already in an agent's workspace
|
|
4
|
+
(an upload kept there, a file saved or generated earlier), asked for on demand. The full markdown is saved next to the original
|
|
5
|
+
under `converted/` so it can be read in parts, and an existing file is never overwritten.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
|
|
13
|
+
from pal.input_converters.components.base import ConversionError
|
|
14
|
+
from pal.input_converters.pipeline import CONVERTIBLE_EXTENSIONS, convert_to_markdown
|
|
15
|
+
|
|
16
|
+
OUT_DIR = "converted"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class WorkspaceConversion:
|
|
21
|
+
path: str # the file that was converted
|
|
22
|
+
text: str # the whole markdown
|
|
23
|
+
source_format: str
|
|
24
|
+
converted: bool # False when the file was already text and passed through
|
|
25
|
+
saved: str # where the markdown was saved ("" if it was not)
|
|
26
|
+
note: str # why it was not saved, if so
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def convertible(path: str) -> bool:
|
|
30
|
+
return PurePosixPath(path).suffix.lower().lstrip(".") in CONVERTIBLE_EXTENSIONS
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def convert_workspace_file(path: str, *, read_bytes: Callable[[str], bytes], write_text: Callable[[str, str], object],
|
|
34
|
+
exists: Callable[[str], bool], max_chars: int = 500_000) -> WorkspaceConversion:
|
|
35
|
+
name = PurePosixPath(path).name
|
|
36
|
+
if not convertible(path):
|
|
37
|
+
raise ConversionError(f"{name}: only {', '.join(CONVERTIBLE_EXTENSIONS)} files can be converted "
|
|
38
|
+
"(text and code files need no conversion)")
|
|
39
|
+
res = convert_to_markdown(name, read_bytes(path))
|
|
40
|
+
if len(res.text) > max_chars:
|
|
41
|
+
raise ConversionError(f"{name}: the markdown is {len(res.text):,} characters, over the {max_chars:,} limit")
|
|
42
|
+
out = f"{OUT_DIR}/{name}.md"
|
|
43
|
+
if exists(out):
|
|
44
|
+
return WorkspaceConversion(path, res.text, res.source_format, res.converted, "", f"{out} already exists and was not overwritten")
|
|
45
|
+
write_text(out, res.text)
|
|
46
|
+
return WorkspaceConversion(path, res.text, res.source_format, res.converted, out, "")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def describe(r: WorkspaceConversion, preview_chars: int = 2000, indexed: str = "") -> str:
|
|
50
|
+
"""The tool's answer: what happened, then the beginning of the markdown."""
|
|
51
|
+
head = f"converted {r.path} ({r.source_format} to markdown): {len(r.text):,} characters."
|
|
52
|
+
head += f" Saved the full text as {r.saved}." if r.saved else f" {r.note}."
|
|
53
|
+
if indexed:
|
|
54
|
+
head += " " + indexed
|
|
55
|
+
cut = r.text[:preview_chars]
|
|
56
|
+
return head + "\n\n" + cut + ("\n[preview ends; read the rest from the saved file]" if len(r.text) > preview_chars else "")
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
Edit input. Skip any change altering context. Critical: context must not change.
|
|
2
|
+
Remove: articles, filler, punctuation, pleasantries, hedging, extra whitespace. Paraphrase fewest words same meaning. Never drop not/never/no/only/except: meaning flip worse than tokens saved. Numbers, units exact. Imperative sentences. Instead of "Sure! I'd be happy to help. Issue likely caused by..." write "Bug in auth middleware". One word when one word enough. Never remove negation words. Never change user-pasted or attached file content. If original shorter than result, use original.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from pal.input_savers.components.base import SaverResult, StepResult
|
|
2
|
+
from pal.input_savers.components.methods import METHOD_ORDER, METHODS
|
|
3
|
+
from pal.input_savers.pipeline import InputSaverPipeline
|
|
4
|
+
|
|
5
|
+
__all__ = ["InputSaverPipeline", "SaverResult", "StepResult", "METHODS", "METHOD_ORDER"]
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Method:
|
|
8
|
+
id: str
|
|
9
|
+
name: str
|
|
10
|
+
rule: str # the one edit the local LLM is allowed to make
|
|
11
|
+
system: str = "" # optional full applier system prompt (used by the rules-file mode)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class StepResult:
|
|
16
|
+
method_id: str
|
|
17
|
+
name: str
|
|
18
|
+
status: str # "kept" | "reverted" | "skipped"
|
|
19
|
+
reason: str
|
|
20
|
+
tokens_before: int
|
|
21
|
+
tokens_after: int # equals tokens_before when reverted/skipped
|
|
22
|
+
chunks_total: int = 0
|
|
23
|
+
chunks_kept: int = 0
|
|
24
|
+
chunk_notes: list = field(default_factory=list) # per reverted chunk: why
|
|
25
|
+
before: str = ""
|
|
26
|
+
after: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SaverResult:
|
|
31
|
+
original: str
|
|
32
|
+
reduced: str
|
|
33
|
+
steps: list
|
|
34
|
+
tokens_original: int
|
|
35
|
+
tokens_reduced: int
|
|
36
|
+
counter: str = ""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def tokens_saved(self) -> int:
|
|
40
|
+
return max(0, self.tokens_original - self.tokens_reduced)
|