Files
simstudioai--sim/apps/sim/lib/copilot/tools/server/files/doc-extract.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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 }
}