222 lines
7.6 KiB
Go
222 lines
7.6 KiB
Go
package entities
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/andydixon/relata/internal/domain"
|
|
)
|
|
|
|
// Mention is one occurrence of an entity in a piece of text.
|
|
type Mention struct {
|
|
Name string
|
|
Type domain.EntityType
|
|
// Start and End are byte offsets into the text the mention was found in.
|
|
Start, End int
|
|
Quote string
|
|
Method domain.ExtractionMethod
|
|
// Normalised is the ISO-8601 form for date mentions.
|
|
Normalised string
|
|
}
|
|
|
|
// Deterministic patterns.
|
|
//
|
|
// These are kept narrow on purpose. A pattern that fires on ordinary prose
|
|
// would flood the entity index with noise and make shared-entity evidence
|
|
// meaningless, so only high-precision shapes are matched here. Everything
|
|
// broader is left to the model stage, whose output is marked as such.
|
|
var (
|
|
// A capitalised run terminated by a legal suffix: "Example Ltd",
|
|
// "Northern Rail Holdings PLC".
|
|
orgPattern = regexp.MustCompile(
|
|
`\b((?:[A-Z][\p{L}&.'-]*\s+){0,5}[A-Z][\p{L}&.'-]*)\s+(Ltd\.?|Limited|PLC|plc|LLP|LLC|Inc\.?|Incorporated|Corp\.?|Corporation|GmbH|AG|B\.?V\.?|N\.?V\.?|Pty|Co\.)(?:\s|$|[.,;:!?)])`)
|
|
|
|
// An honorific followed by capitalised names: "Dr Alice Chen".
|
|
personPattern = regexp.MustCompile(
|
|
`\b(Mr|Mrs|Ms|Miss|Dr|Prof|Professor|Sir|Dame|Lord|Lady)\.?\s+((?:[A-Z][\p{L}'-]+\s+){0,2}[A-Z][\p{L}'-]+)\b`)
|
|
|
|
// Email addresses identify a correspondent unambiguously.
|
|
emailPattern = regexp.MustCompile(`\b[\w.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[\p{L}\d](?:[\p{L}\d-]{0,61}[\p{L}\d])?(?:\.[\p{L}\d](?:[\p{L}\d-]{0,61}[\p{L}\d])?)+\b`)
|
|
|
|
// Dates in the formats Relata can normalise with confidence.
|
|
isoDatePattern = regexp.MustCompile(`\b(\d{4})-(\d{2})-(\d{2})\b`)
|
|
dmyPattern = regexp.MustCompile(`\b(\d{1,2})(?:st|nd|rd|th)?\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{4})\b`)
|
|
mdyPattern = regexp.MustCompile(`\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\b`)
|
|
slashDate = regexp.MustCompile(`\b(\d{1,2})/(\d{1,2})/(\d{4})\b`)
|
|
)
|
|
|
|
// maxDeterministicMentions bounds output from a pathological document.
|
|
const maxDeterministicMentions = 500
|
|
|
|
// ExtractDeterministic finds entities that can be identified by rule alone.
|
|
//
|
|
// Every mention carries exact source offsets, so the caller can attach a
|
|
// verifiable source reference. Mentions are returned in document order.
|
|
func ExtractDeterministic(text string) []Mention {
|
|
var out []Mention
|
|
|
|
add := func(m Mention) {
|
|
if len(out) >= maxDeterministicMentions {
|
|
return
|
|
}
|
|
m.Method = domain.MethodDeterministic
|
|
m.Quote = safeSlice(text, m.Start, m.End)
|
|
out = append(out, m)
|
|
}
|
|
|
|
for _, loc := range orgPattern.FindAllStringSubmatchIndex(text, maxDeterministicMentions) {
|
|
// Group 1 is the name body, group 2 the suffix; the entity is both.
|
|
start, end := loc[2], loc[5]
|
|
if start < 0 || end < 0 || end <= start {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(text[start:end])
|
|
if !plausibleName(name) {
|
|
continue
|
|
}
|
|
add(Mention{Name: name, Type: domain.EntityOrganisation, Start: start, End: end})
|
|
}
|
|
|
|
for _, loc := range personPattern.FindAllStringSubmatchIndex(text, maxDeterministicMentions) {
|
|
start, end := loc[4], loc[5] // the name without the honorific
|
|
if start < 0 || end < 0 {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(text[start:end])
|
|
if !plausibleName(name) {
|
|
continue
|
|
}
|
|
add(Mention{Name: name, Type: domain.EntityPerson, Start: start, End: end})
|
|
}
|
|
|
|
for _, loc := range emailPattern.FindAllStringIndex(text, maxDeterministicMentions) {
|
|
addr := text[loc[0]:loc[1]]
|
|
if len(addr) > 254 {
|
|
continue
|
|
}
|
|
add(Mention{Name: strings.ToLower(addr), Type: domain.EntityPerson, Start: loc[0], End: loc[1]})
|
|
}
|
|
|
|
out = append(out, extractDates(text)...)
|
|
sortMentions(out)
|
|
return dedupeOverlapping(out)
|
|
}
|
|
|
|
// extractDates finds and normalises dates to ISO-8601.
|
|
//
|
|
// Ambiguous numeric formats are resolved as day/month/year, matching the
|
|
// application's British English conventions. The normalised value is what
|
|
// temporal comparisons use, so getting this wrong would silently distort
|
|
// relationship evidence — hence only unambiguous or documented formats.
|
|
func extractDates(text string) []Mention {
|
|
var out []Mention
|
|
|
|
emit := func(start, end int, t time.Time) {
|
|
out = append(out, Mention{
|
|
Name: t.Format("2006-01-02"),
|
|
Type: domain.EntityDate,
|
|
Start: start,
|
|
End: end,
|
|
Quote: safeSlice(text, start, end),
|
|
Method: domain.MethodDeterministic,
|
|
Normalised: t.Format("2006-01-02"),
|
|
})
|
|
}
|
|
|
|
for _, loc := range isoDatePattern.FindAllStringSubmatchIndex(text, 200) {
|
|
if t, err := time.Parse("2006-01-02", text[loc[0]:loc[1]]); err == nil {
|
|
emit(loc[0], loc[1], t)
|
|
}
|
|
}
|
|
for _, loc := range dmyPattern.FindAllStringSubmatchIndex(text, 200) {
|
|
day := text[loc[2]:loc[3]]
|
|
month := text[loc[4]:loc[5]]
|
|
year := text[loc[6]:loc[7]]
|
|
if t, err := time.Parse("2 January 2006", day+" "+month+" "+year); err == nil {
|
|
emit(loc[0], loc[1], t)
|
|
}
|
|
}
|
|
for _, loc := range mdyPattern.FindAllStringSubmatchIndex(text, 200) {
|
|
month := text[loc[2]:loc[3]]
|
|
day := text[loc[4]:loc[5]]
|
|
year := text[loc[6]:loc[7]]
|
|
if t, err := time.Parse("2 January 2006", day+" "+month+" "+year); err == nil {
|
|
emit(loc[0], loc[1], t)
|
|
}
|
|
}
|
|
for _, loc := range slashDate.FindAllStringSubmatchIndex(text, 200) {
|
|
d, m, y := text[loc[2]:loc[3]], text[loc[4]:loc[5]], text[loc[6]:loc[7]]
|
|
// Day/month/year. A value above 12 in the first position confirms the
|
|
// reading; otherwise the convention is applied as documented.
|
|
if t, err := time.Parse("2/1/2006", d+"/"+m+"/"+y); err == nil {
|
|
emit(loc[0], loc[1], t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// plausibleName filters out matches that are almost certainly not names:
|
|
// sentence-initial words, all-caps shouting, and absurd lengths.
|
|
func plausibleName(name string) bool {
|
|
if len(name) < 2 || len(name) > 120 {
|
|
return false
|
|
}
|
|
words := strings.Fields(name)
|
|
if len(words) == 0 || len(words) > 8 {
|
|
return false
|
|
}
|
|
// A single very common word that happens to be capitalised is not a name.
|
|
if len(words) == 1 && commonWords[strings.ToLower(strings.Trim(words[0], ".,"))] {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// commonWords are capitalised sentence-openers that would otherwise be matched.
|
|
var commonWords = map[string]bool{
|
|
"the": true, "this": true, "that": true, "these": true, "those": true,
|
|
"a": true, "an": true, "it": true, "we": true, "they": true, "he": true,
|
|
"she": true, "i": true, "our": true, "their": true, "his": true, "her": true,
|
|
"in": true, "on": true, "at": true, "to": true, "for": true, "and": true,
|
|
"but": true, "if": true, "as": true, "by": true, "from": true, "with": true,
|
|
"all": true, "any": true, "each": true, "no": true, "not": true, "one": true,
|
|
"there": true, "here": true, "when": true, "where": true, "while": true,
|
|
"after": true, "before": true, "during": true, "since": true, "until": true,
|
|
}
|
|
|
|
func safeSlice(s string, start, end int) string {
|
|
if start < 0 || end > len(s) || start >= end {
|
|
return ""
|
|
}
|
|
return s[start:end]
|
|
}
|
|
|
|
func sortMentions(ms []Mention) {
|
|
for i := 1; i < len(ms); i++ {
|
|
for j := i; j > 0 && ms[j].Start < ms[j-1].Start; j-- {
|
|
ms[j], ms[j-1] = ms[j-1], ms[j]
|
|
}
|
|
}
|
|
}
|
|
|
|
// dedupeOverlapping removes mentions contained within an earlier, longer
|
|
// mention at the same position, so "Example Ltd" is not also recorded as the
|
|
// person "Example".
|
|
func dedupeOverlapping(ms []Mention) []Mention {
|
|
out := ms[:0]
|
|
var lastEnd int
|
|
var lastType domain.EntityType
|
|
for _, m := range ms {
|
|
if m.Start < lastEnd && m.Type != lastType {
|
|
continue
|
|
}
|
|
out = append(out, m)
|
|
if m.End > lastEnd {
|
|
lastEnd = m.End
|
|
lastType = m.Type
|
|
}
|
|
}
|
|
return out
|
|
}
|