Extraction — structured records from unstructured data

Extraction — structured records from unstructured data #

extract(content, Shape, settings) turns text and documents into records you can walk and write from: one declared tree, one model call. For a single value — a summary, a category — use ai() instead.

basics #

type Thesis = <"Consumer" | "Infra" | "Health">

node Company: "each company named in this message" {
  name:    <text> "the company's name"
  website: <text | null> "its website, if given"
  stage:   <crm-[:Onboarding]->.Stage> "how far along this company is"
  thesis:  <Thesis> "which of our theses it fits"
  node person: "each person at the company named in the message" {
    name:  <text> "the person's full name"
    email: <text | null> "their email address, if given"
  }
}

function `Intake`(m: <inbox-[:Email]->>) {
  content   = [m.Body, ...m-[a:Attachments]->.File]
  companies = extract(content, Company, { tier: "careful" })

  map(companies, (c) => {
    record = write crm-[:Companies]-> { unique by (FUZZY Name), Name: c.name }
    write record-[:Lists]-> { listName: "Onboarding", Stage ?: c.stage }
    c-[p:person]-> {
      write record-[:Team]-> { unique by (FUZZY Name), Name: p.name, Email ?: p.email }
    }
  })
}

extractOne(content, Shape, settings) gives the single record the content describes, as TypeScript's find sits beside filter:

sender = extractOne(content, node Sender: "the company that sent this message" { name: <text> "its name" })

A reply that does not fit the shape is tried once more, then the call fails. Inside map, onError: "warn" leaves that record out and carries on.

a-yes-or-no-field #

node Lead: "the company this email is about" {
  name:      <text> "the company's name"
  `Is Warm`: <boolean> "true when the sender already knows us — a referral, a reply to outreach, or a repeat contact; false otherwise"
}

lead = extractOne([m.Body], Lead)
if (lead == null) { fail("no company in this email") }
if (lead.`Is Warm`) {
  write crm-[:Companies]-> { unique by (FUZZY Name), Name: lead.name, Description: "Warm Lead" }
}

A <boolean> field is the condition itself. A missing one reads as false; guard it with == null when "couldn't tell" should stop the run. A description that argues both ways gives the model a real decision rather than a value to lean toward.

how-hard-it-works #

tier says how much thinking the extraction is worth — the same three words ai() takes:

Leave it off and the extraction sizes itself from the tree you declared. model (a model this installation can reach, by name) and effort ("low", "medium", "high", "xhigh") override the tier's. An unknown setting, or an unreachable model, is refused when you save.

repeated-calls #

The model reads content it has already seen in this run for a fraction of the price, but only the part that comes first and is identical:

The shape always goes after the content, so a second extraction with a different shape over the same content is cheap too. The engine marks the cache itself; the order you write is the only thing to get right.

enrichment #

Enrich by composition: extract, map over the results, call plugins, extract again over what they returned, then merge.

import { fetchUrl, research } from plugins

node CompanyDetail: "the company" {
  summary: <text> "one line on what the company does"
}

node Detailed {
  name:    <text>
  website: <text | null>
  summary: <text | null>
  node person { name: <text> }
}

content   = [m.Body]
companies = extract(content, Company, { tier: "careful" })

detailed = map(companies, (c) => {
  page    = fetchUrl(c.website)
  more    = research(c.name, { context: m.Subject, questions: "what the company does" })
  details = extractOne([...content, Json.stringify(c), coalesce(page, ""), coalesce(more.dossier, "")], CompanyDetail, { tier: "careful" })
  return graph<Detailed> { ...c, ...details }
}, { initialConcurrency: 1, concurrency: 4, onError: "warn" })

map(detailed, (d) => {
  record = write crm-[:Companies]-> { unique by (FUZZY Name), Name: d.name, Description ?: d.summary }
  d-[p:person]-> { write record-[:Team]-> { unique by (FUZZY Name), Name: p.name } }
})

long-documents #

Cut a long document into pieces, read each on its own, and gather what they found before any of it reaches a system:

node Found {
  node companies {
    name:    <text>
    website: <text | null>
  }
}

pieces  = Text.chunks(coalesce(File.read(first(docs)), ""), { size: 40000, overlap: 2000 })
deduped = graph<Found> {}

map(pieces, (p) => {
  map(extract([p], Company, { tier: "quick" }), (c) => {
    write deduped-[:companies]-> { unique by (FUZZY name), name: c.name, website ?: c.website }
  })
})

deduped-[c:companies ORDER BY c.name]-> {
  write crm-[:Companies]-> { unique by (FUZZY Name), Name: c.name }
}

attaching-the-source-files #

The files an extraction read are the ones you put in its content, so attach them from where they came:

record = write crm-[:Companies]-> { unique by (FUZZY Name), Name: c.name }
m-[a:Attachments]-> {
  write record-[:Files]-> { File: a.File }
}

A document a plugin downloaded comes back on its result (vcUrlRetrieval's file), and is written the same way.