d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
114 lines
4.1 KiB
TypeScript
114 lines
4.1 KiB
TypeScript
import { executeInE2B } from '@/lib/execution/e2b'
|
|
import { CodeLanguage } from '@/lib/execution/languages'
|
|
|
|
const EXTRACT_TIMEOUT_MS = 120_000
|
|
// Bound the text handed back to the agent so a huge document can't blow the
|
|
// context window; the agent gets a clear truncation marker if it hits the cap.
|
|
const MAX_EXTRACT_CHARS = 200_000
|
|
|
|
/** Binary document formats whose text/tables we can extract in the doc sandbox. */
|
|
const EXTRACTABLE_EXTS = new Set(['pdf', 'pptx', 'docx', 'xlsx'])
|
|
|
|
export function isExtractableDocExt(ext: string): boolean {
|
|
return EXTRACTABLE_EXTS.has(ext.toLowerCase())
|
|
}
|
|
|
|
export interface DocExtract {
|
|
text: string
|
|
truncated: boolean
|
|
}
|
|
|
|
/**
|
|
* Extracts readable text (and tables) from an uploaded binary document inside the
|
|
* E2B doc sandbox so the agent can read/reason over files it cannot otherwise see
|
|
* as source: pdf via pdfplumber, pptx via python-pptx, docx via python-docx, xlsx
|
|
* via openpyxl. Read-only — never mutates the file. Throws on sandbox/infra
|
|
* failure or an unparseable document.
|
|
*/
|
|
export async function extractDocText(args: { binary: Buffer; ext: string }): Promise<DocExtract> {
|
|
const ext = args.ext.toLowerCase()
|
|
if (!isExtractableDocExt(ext)) {
|
|
throw new Error(`Cannot extract text from .${ext} (supported: pdf, pptx, docx, xlsx)`)
|
|
}
|
|
|
|
const script = `
|
|
import json
|
|
ext = ${JSON.stringify(ext)}
|
|
inp = f"/home/user/input.{ext}"
|
|
out = []
|
|
|
|
if ext == "pdf":
|
|
import pdfplumber
|
|
with pdfplumber.open(inp) as pdf:
|
|
for i, page in enumerate(pdf.pages, 1):
|
|
out.append(f"--- Page {i} ---")
|
|
out.append(page.extract_text() or "")
|
|
for t in (page.extract_tables() or []):
|
|
out.append("[table] " + json.dumps(t, ensure_ascii=False))
|
|
elif ext == "pptx":
|
|
from pptx import Presentation
|
|
prs = Presentation(inp)
|
|
for i, slide in enumerate(prs.slides, 1):
|
|
out.append(f"--- Slide {i} ---")
|
|
for shape in slide.shapes:
|
|
if shape.has_text_frame and shape.text_frame.text.strip():
|
|
out.append(shape.text_frame.text)
|
|
if shape.has_table:
|
|
for row in shape.table.rows:
|
|
out.append(" | ".join(c.text for c in row.cells))
|
|
nf = slide.notes_slide.notes_text_frame if slide.has_notes_slide else None
|
|
notes = nf.text if nf is not None else ""
|
|
if notes.strip():
|
|
out.append("[notes] " + notes)
|
|
elif ext == "docx":
|
|
import docx
|
|
d = docx.Document(inp)
|
|
for p in d.paragraphs:
|
|
if p.text.strip():
|
|
out.append(p.text)
|
|
for tbl in d.tables:
|
|
for row in tbl.rows:
|
|
out.append(" | ".join(c.text for c in row.cells))
|
|
elif ext == "xlsx":
|
|
import openpyxl
|
|
wb = openpyxl.load_workbook(inp, data_only=True)
|
|
for ws in wb.worksheets:
|
|
out.append(f"--- Sheet {ws.title} ---")
|
|
# Cap rows so an inflated used-range can't blow up memory/output.
|
|
for ri, row in enumerate(ws.iter_rows(values_only=True)):
|
|
if ri >= 5000:
|
|
out.append("[... more rows truncated]")
|
|
break
|
|
out.append(",".join("" if v is None else str(v) for v in row))
|
|
|
|
# Bound the transferred text so a decompression bomb can't return gigabytes.
|
|
# Headroom over MAX_EXTRACT_CHARS so the TS-side truncation flag can still fire.
|
|
text = "\\n".join(out)[:${MAX_EXTRACT_CHARS + 20000}]
|
|
print("__SIM_RESULT__=" + json.dumps({"text": text}))
|
|
`.trim()
|
|
|
|
const result = await executeInE2B({
|
|
code: script,
|
|
language: CodeLanguage.Python,
|
|
timeoutMs: EXTRACT_TIMEOUT_MS,
|
|
sandboxKind: 'doc',
|
|
sandboxFiles: [
|
|
{
|
|
path: `/home/user/input.${ext}`,
|
|
content: args.binary.toString('base64'),
|
|
encoding: 'base64',
|
|
},
|
|
],
|
|
})
|
|
|
|
if (result.error) {
|
|
throw new Error(`Document extraction failed: ${result.error}`)
|
|
}
|
|
const payload = result.result as { text?: string } | null
|
|
const full = payload?.text ?? ''
|
|
const truncated = full.length > MAX_EXTRACT_CHARS
|
|
// The caller (VFS read) owns the user-facing truncation note; just return the
|
|
// bounded text + the flag here.
|
|
return { text: full.slice(0, MAX_EXTRACT_CHARS), truncated }
|
|
}
|