autoforge-ai 0.1.16 → 0.1.18
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.
- package/autonomous_agent_demo.py +2 -2
- package/package.json +1 -1
- package/parallel_orchestrator.py +4 -4
- package/requirements-prod.txt +4 -0
- package/server/routers/agent.py +11 -4
- package/server/routers/expand_project.py +3 -3
- package/server/routers/settings.py +5 -0
- package/server/routers/spec_creation.py +3 -3
- package/server/schemas.py +55 -21
- package/server/services/chat_constants.py +36 -0
- package/server/services/expand_chat_session.py +14 -13
- package/server/services/process_manager.py +4 -0
- package/server/services/spec_chat_session.py +12 -14
- package/server/utils/document_extraction.py +221 -0
- package/ui/dist/assets/index-DXm5cuJA.js +96 -0
- package/ui/dist/assets/index-DlYws_VI.css +1 -0
- package/ui/dist/assets/vendor-flow-CSXy01ye.js +7 -0
- package/ui/dist/assets/{vendor-markdown-lmnOnLXp.js → vendor-markdown-BxiGvyag.js} +3 -3
- package/ui/dist/assets/vendor-query-CcgjkJlA.js +1 -0
- package/ui/dist/assets/vendor-radix-DIVIznMB.js +45 -0
- package/ui/dist/assets/vendor-react-l0sNRNKZ.js +1 -0
- package/ui/dist/assets/{vendor-utils-CdMnkzGY.js → vendor-utils-CJmVD20L.js} +1 -1
- package/ui/dist/index.html +7 -8
- package/ui/dist/assets/index-CX9TqxHJ.css +0 -1
- package/ui/dist/assets/index-DtBG9zqQ.js +0 -96
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +0 -7
- package/ui/dist/assets/vendor-query-BUABzP5o.js +0 -1
- package/ui/dist/assets/vendor-radix-DjWauVBs.js +0 -45
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +0 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Document Extraction Utility
|
|
3
|
+
============================
|
|
4
|
+
|
|
5
|
+
Extracts text content from various document formats in memory (no disk I/O).
|
|
6
|
+
Supports: TXT, MD, CSV, DOCX, XLSX, PDF, PPTX.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import csv
|
|
11
|
+
import io
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Maximum characters of extracted text to send to Claude
|
|
17
|
+
MAX_EXTRACTED_CHARS = 200_000
|
|
18
|
+
|
|
19
|
+
# Maximum rows per sheet for Excel files
|
|
20
|
+
MAX_EXCEL_ROWS_PER_SHEET = 10_000
|
|
21
|
+
MAX_EXCEL_SHEETS = 50
|
|
22
|
+
|
|
23
|
+
# MIME type classification
|
|
24
|
+
DOCUMENT_MIME_TYPES: dict[str, str] = {
|
|
25
|
+
"text/plain": ".txt",
|
|
26
|
+
"text/markdown": ".md",
|
|
27
|
+
"text/csv": ".csv",
|
|
28
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
29
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
30
|
+
"application/pdf": ".pdf",
|
|
31
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
IMAGE_MIME_TYPES = {"image/jpeg", "image/png"}
|
|
35
|
+
|
|
36
|
+
ALL_ALLOWED_MIME_TYPES = IMAGE_MIME_TYPES | set(DOCUMENT_MIME_TYPES.keys())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_image(mime_type: str) -> bool:
|
|
40
|
+
"""Check if the MIME type is a supported image format."""
|
|
41
|
+
return mime_type in IMAGE_MIME_TYPES
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_document(mime_type: str) -> bool:
|
|
45
|
+
"""Check if the MIME type is a supported document format."""
|
|
46
|
+
return mime_type in DOCUMENT_MIME_TYPES
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DocumentExtractionError(Exception):
|
|
50
|
+
"""Raised when text extraction from a document fails."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, filename: str, reason: str):
|
|
53
|
+
self.filename = filename
|
|
54
|
+
self.reason = reason
|
|
55
|
+
super().__init__(f"Failed to read {filename}: {reason}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _truncate(text: str) -> str:
|
|
59
|
+
"""Truncate text if it exceeds the maximum character limit."""
|
|
60
|
+
if len(text) > MAX_EXTRACTED_CHARS:
|
|
61
|
+
omitted = len(text) - MAX_EXTRACTED_CHARS
|
|
62
|
+
return text[:MAX_EXTRACTED_CHARS] + f"\n\n[... truncated, {omitted:,} characters omitted]"
|
|
63
|
+
return text
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _extract_plain_text(data: bytes) -> str:
|
|
67
|
+
"""Extract text from plain text or markdown files."""
|
|
68
|
+
try:
|
|
69
|
+
return data.decode("utf-8")
|
|
70
|
+
except UnicodeDecodeError:
|
|
71
|
+
return data.decode("latin-1")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _extract_csv(data: bytes) -> str:
|
|
75
|
+
"""Extract text from CSV files, formatted as a readable table."""
|
|
76
|
+
try:
|
|
77
|
+
text = data.decode("utf-8")
|
|
78
|
+
except UnicodeDecodeError:
|
|
79
|
+
text = data.decode("latin-1")
|
|
80
|
+
|
|
81
|
+
reader = csv.reader(io.StringIO(text))
|
|
82
|
+
lines = []
|
|
83
|
+
for i, row in enumerate(reader):
|
|
84
|
+
lines.append(f"Row {i + 1}: {', '.join(row)}")
|
|
85
|
+
return "\n".join(lines)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _extract_docx(data: bytes) -> str:
|
|
89
|
+
"""Extract text from Word documents."""
|
|
90
|
+
from docx import Document
|
|
91
|
+
|
|
92
|
+
doc = Document(io.BytesIO(data))
|
|
93
|
+
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
|
94
|
+
return "\n\n".join(paragraphs)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _extract_xlsx(data: bytes) -> str:
|
|
98
|
+
"""Extract text from Excel spreadsheets."""
|
|
99
|
+
from openpyxl import load_workbook
|
|
100
|
+
|
|
101
|
+
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
|
102
|
+
sections = []
|
|
103
|
+
|
|
104
|
+
for sheet_idx, sheet_name in enumerate(wb.sheetnames):
|
|
105
|
+
if sheet_idx >= MAX_EXCEL_SHEETS:
|
|
106
|
+
sections.append(f"\n[... {len(wb.sheetnames) - MAX_EXCEL_SHEETS} more sheets omitted]")
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
ws = wb[sheet_name]
|
|
110
|
+
rows_text = [f"=== Sheet: {sheet_name} ==="]
|
|
111
|
+
row_count = 0
|
|
112
|
+
|
|
113
|
+
for row in ws.iter_rows(values_only=True):
|
|
114
|
+
if row_count >= MAX_EXCEL_ROWS_PER_SHEET:
|
|
115
|
+
rows_text.append(f"[... more rows omitted, limit {MAX_EXCEL_ROWS_PER_SHEET:,} rows/sheet]")
|
|
116
|
+
break
|
|
117
|
+
cells = [str(cell) if cell is not None else "" for cell in row]
|
|
118
|
+
rows_text.append("\t".join(cells))
|
|
119
|
+
row_count += 1
|
|
120
|
+
|
|
121
|
+
sections.append("\n".join(rows_text))
|
|
122
|
+
|
|
123
|
+
wb.close()
|
|
124
|
+
return "\n\n".join(sections)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_pdf(data: bytes, filename: str) -> str:
|
|
128
|
+
"""Extract text from PDF files."""
|
|
129
|
+
from PyPDF2 import PdfReader
|
|
130
|
+
from PyPDF2.errors import PdfReadError
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
reader = PdfReader(io.BytesIO(data))
|
|
134
|
+
except PdfReadError as e:
|
|
135
|
+
if "encrypt" in str(e).lower() or "password" in str(e).lower():
|
|
136
|
+
raise DocumentExtractionError(filename, "PDF is password-protected")
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
if reader.is_encrypted:
|
|
140
|
+
raise DocumentExtractionError(filename, "PDF is password-protected")
|
|
141
|
+
|
|
142
|
+
pages = []
|
|
143
|
+
for i, page in enumerate(reader.pages):
|
|
144
|
+
text = page.extract_text()
|
|
145
|
+
if text and text.strip():
|
|
146
|
+
pages.append(f"--- Page {i + 1} ---\n{text}")
|
|
147
|
+
|
|
148
|
+
return "\n\n".join(pages)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _extract_pptx(data: bytes) -> str:
|
|
152
|
+
"""Extract text from PowerPoint presentations."""
|
|
153
|
+
from pptx import Presentation
|
|
154
|
+
|
|
155
|
+
prs = Presentation(io.BytesIO(data))
|
|
156
|
+
slides_text = []
|
|
157
|
+
|
|
158
|
+
for i, slide in enumerate(prs.slides):
|
|
159
|
+
texts = []
|
|
160
|
+
for shape in slide.shapes:
|
|
161
|
+
if shape.has_text_frame:
|
|
162
|
+
for paragraph in shape.text_frame.paragraphs:
|
|
163
|
+
text = paragraph.text.strip()
|
|
164
|
+
if text:
|
|
165
|
+
texts.append(text)
|
|
166
|
+
if texts:
|
|
167
|
+
slides_text.append(f"--- Slide {i + 1} ---\n" + "\n".join(texts))
|
|
168
|
+
|
|
169
|
+
return "\n\n".join(slides_text)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def extract_text_from_document(base64_data: str, mime_type: str, filename: str) -> str:
|
|
173
|
+
"""
|
|
174
|
+
Extract text content from a document file.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
base64_data: Base64-encoded file content
|
|
178
|
+
mime_type: MIME type of the document
|
|
179
|
+
filename: Original filename (for error messages)
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Extracted text content, truncated if necessary
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
DocumentExtractionError: If extraction fails
|
|
186
|
+
"""
|
|
187
|
+
if mime_type not in DOCUMENT_MIME_TYPES:
|
|
188
|
+
raise DocumentExtractionError(filename, f"unsupported document type: {mime_type}")
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
data = base64.b64decode(base64_data)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
raise DocumentExtractionError(filename, f"invalid base64 data: {e}")
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
if mime_type in ("text/plain", "text/markdown"):
|
|
197
|
+
text = _extract_plain_text(data)
|
|
198
|
+
elif mime_type == "text/csv":
|
|
199
|
+
text = _extract_csv(data)
|
|
200
|
+
elif mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
|
201
|
+
text = _extract_docx(data)
|
|
202
|
+
elif mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
|
|
203
|
+
text = _extract_xlsx(data)
|
|
204
|
+
elif mime_type == "application/pdf":
|
|
205
|
+
text = _extract_pdf(data, filename)
|
|
206
|
+
elif mime_type == "application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
|
207
|
+
text = _extract_pptx(data)
|
|
208
|
+
else:
|
|
209
|
+
raise DocumentExtractionError(filename, f"unsupported document type: {mime_type}")
|
|
210
|
+
except DocumentExtractionError:
|
|
211
|
+
raise
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.warning(f"Document extraction failed for {filename}: {e}")
|
|
214
|
+
raise DocumentExtractionError(
|
|
215
|
+
filename, "file appears to be corrupt or in an unexpected format"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if not text or not text.strip():
|
|
219
|
+
return f"[File {filename} is empty or contains no extractable text]"
|
|
220
|
+
|
|
221
|
+
return _truncate(text)
|