mirror of
https://github.com/hanzoai/semantic.git
synced 2026-10-30 15:04:19 +00:00
Near matches a predicate a model stated against one a caller asked for and keeps the subject and object where they are, so a synonym has to hold with the same two. Some did not: employs was listed under works_for and managed_by under ceo_of, which turned "Acme employs Alice" into "Acme works_for Alice". Bare nouns such as founder, parent_company and chief_executive read either way round, and merged_with is symmetric, so each could land an edge backwards under a predicate with a direction. All of them are gone; owned_by stays under subsidiary_of, which reads the same way. The comment on the table states the rule. Co-authored-by: Hanzo Dev <[email protected]>
239 lines
7.4 KiB
Go
239 lines
7.4 KiB
Go
package extract
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Near finds the candidate most like text and how alike it is, in [0,1].
|
|
// It tries the cheap tests first and stops as soon as one is decisive: an
|
|
// exact match, then a known synonym, then containment scaled by how much of
|
|
// the longer string the shorter one covers, then the longest-matching-blocks
|
|
// ratio. It returns -1 when there are no candidates.
|
|
//
|
|
// The Python original inserts sentence-embedding and word-vector stages
|
|
// between containment and the ratio. Those need a model; the stages that do
|
|
// not are here, and an embedding-backed matcher belongs behind Model rather
|
|
// than in this function.
|
|
func Near(text string, candidates []string) (int, float64) {
|
|
if text == "" || len(candidates) == 0 {
|
|
return -1, 0
|
|
}
|
|
want := strings.ToLower(strings.TrimSpace(text))
|
|
if want == "" {
|
|
return -1, 0
|
|
}
|
|
|
|
low := make([]string, len(candidates))
|
|
for i, c := range candidates {
|
|
low[i] = strings.ToLower(strings.TrimSpace(c))
|
|
}
|
|
|
|
for i, c := range low {
|
|
if c == want {
|
|
return i, 1
|
|
}
|
|
}
|
|
|
|
best, score := -1, 0.0
|
|
if syns, ok := synonyms[want]; ok {
|
|
for _, s := range syns {
|
|
for i, c := range low {
|
|
if c == s {
|
|
return i, 0.95
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for i, c := range low {
|
|
for _, s := range synonyms[c] {
|
|
if s == want && score < 0.95 {
|
|
best, score = i, 0.95
|
|
}
|
|
}
|
|
}
|
|
|
|
word, err := regexp.Compile(`\b` + regexp.QuoteMeta(want) + `\b`)
|
|
if err != nil {
|
|
word = nil
|
|
}
|
|
for i, c := range low {
|
|
if c == "" {
|
|
continue
|
|
}
|
|
var s float64
|
|
if strings.Contains(c, want) || strings.Contains(want, c) {
|
|
short, long := len(want), len(c)
|
|
if short > long {
|
|
short, long = long, short
|
|
}
|
|
s = 0.9*(float64(short)/float64(long)) + 0.1
|
|
if word != nil && word.MatchString(c) && s < 0.88 {
|
|
s = 0.88
|
|
}
|
|
}
|
|
if s > score {
|
|
best, score = i, s
|
|
}
|
|
}
|
|
if score >= 0.85 {
|
|
return best, score
|
|
}
|
|
|
|
for i, c := range low {
|
|
if c == "" {
|
|
continue
|
|
}
|
|
if s := Ratio(want, c); s > score {
|
|
best, score = i, s
|
|
}
|
|
}
|
|
return best, score
|
|
}
|
|
|
|
// Similar is how alike text and the closest candidate are, in [0,1], for a
|
|
// caller that wants the score without the candidate.
|
|
func Similar(text string, candidates []string) float64 {
|
|
_, score := Near(text, candidates)
|
|
return score
|
|
}
|
|
|
|
// Bind returns the entity that best matches text, or false when none reaches
|
|
// min. An exact, case-insensitive hit always wins.
|
|
func Bind(text string, known []Entity, min float64) (Entity, bool) {
|
|
if text == "" || len(known) == 0 {
|
|
return Entity{}, false
|
|
}
|
|
want := strings.ToLower(strings.TrimSpace(text))
|
|
for _, e := range known {
|
|
if strings.ToLower(strings.TrimSpace(e.Text)) == want {
|
|
return e, true
|
|
}
|
|
}
|
|
names := make([]string, len(known))
|
|
for i, e := range known {
|
|
names[i] = e.Text
|
|
}
|
|
if i, s := Near(text, names); i >= 0 && s >= min {
|
|
return known[i], true
|
|
}
|
|
return Entity{}, false
|
|
}
|
|
|
|
// Weigh blends an extractor's own confidence with how close the item is to the
|
|
// types a caller asked for, half from each. It returns score unchanged when
|
|
// the caller named no types, since there is then nothing to compare against.
|
|
func Weigh(label string, score float64, want []string, text string) float64 {
|
|
if len(want) == 0 {
|
|
return score
|
|
}
|
|
_, byLabel := Near(label, want)
|
|
byText := 0.0
|
|
if text != "" {
|
|
_, byText = Near(text, want)
|
|
}
|
|
if byText > byLabel {
|
|
byLabel = byText
|
|
}
|
|
return clamp(0.5*score + 0.5*byLabel)
|
|
}
|
|
|
|
// Ratio is the share of two strings that lies in matching blocks: twice the
|
|
// matched length over the summed length, the similarity Python's difflib
|
|
// reports. Identical strings give 1, strings with nothing in common give 0.
|
|
func Ratio(a, b string) float64 {
|
|
ra, rb := []rune(a), []rune(b)
|
|
total := len(ra) + len(rb)
|
|
if total == 0 {
|
|
return 1
|
|
}
|
|
return 2 * float64(blocks(ra, rb)) / float64(total)
|
|
}
|
|
|
|
// blocks counts the characters covered by the longest common run and, either
|
|
// side of it, by the longest common run of what remains.
|
|
func blocks(a, b []rune) int {
|
|
i, j, n := longest(a, b)
|
|
if n == 0 {
|
|
return 0
|
|
}
|
|
return n + blocks(a[:i], b[:j]) + blocks(a[i+n:], b[j+n:])
|
|
}
|
|
|
|
// longest returns the start in a, the start in b, and the length of the
|
|
// longest run the two share, preferring the earliest such run in a and then in
|
|
// b so the result does not depend on scan order.
|
|
func longest(a, b []rune) (int, int, int) {
|
|
if len(a) == 0 || len(b) == 0 {
|
|
return 0, 0, 0
|
|
}
|
|
prev := make([]int, len(b)+1)
|
|
cur := make([]int, len(b)+1)
|
|
bi, bj, best := 0, 0, 0
|
|
for i := 1; i <= len(a); i++ {
|
|
for j := 1; j <= len(b); j++ {
|
|
if a[i-1] == b[j-1] {
|
|
cur[j] = prev[j-1] + 1
|
|
if cur[j] > best {
|
|
best, bi, bj = cur[j], i-cur[j], j-cur[j]
|
|
}
|
|
} else {
|
|
cur[j] = 0
|
|
}
|
|
}
|
|
prev, cur = cur, prev
|
|
for j := range cur {
|
|
cur[j] = 0
|
|
}
|
|
}
|
|
return bi, bj, best
|
|
}
|
|
|
|
func clamp(f float64) float64 {
|
|
if f < 0 {
|
|
return 0
|
|
}
|
|
if f > 1 {
|
|
return 1
|
|
}
|
|
return f
|
|
}
|
|
|
|
// synonyms are the type and predicate names that mean the same thing, so a
|
|
// label an extractor produced can be recognised as one a caller asked for.
|
|
//
|
|
// A predicate's synonym holds with the same subject and object, since a match
|
|
// keeps them where they are. So a predicate with a direction lists no inverse
|
|
// — employs is not works_for, managed_by is not ceo_of; reason.Inverse is the
|
|
// rule that relates those — and no name that reads either way round, such as
|
|
// a bare noun ("founder", "parent_company") or a symmetric relation
|
|
// ("merged_with"). A symmetric predicate may list anything symmetric.
|
|
var synonyms = map[string][]string{
|
|
"person": {"people", "human", "name", "individual", "artist", "actor", "author", "politician"},
|
|
"org": {"company", "organization", "business", "institution", "agency", "brand", "corporation"},
|
|
"organization": {"company", "business", "institution", "agency", "brand", "corporation"},
|
|
"gpe": {"location", "place", "city", "country", "state", "nation", "region"},
|
|
"loc": {"location", "place", "region", "area"},
|
|
"date": {"time", "year", "day", "month", "period", "duration"},
|
|
"money": {"cost", "price", "value", "currency", "amount"},
|
|
"product": {"item", "object", "commodity", "goods", "device", "tool", "vehicle", "software", "app"},
|
|
"event": {"incident", "occasion", "activity", "happening", "ceremony"},
|
|
"drug": {"medication", "medicine", "pharmaceutical", "chemical", "treatment", "therapy"},
|
|
"chemical": {"drug", "substance", "compound", "element"},
|
|
"disease": {"condition", "illness", "sickness", "disorder", "syndrome", "ailment"},
|
|
|
|
"founded_by": {"established_by", "started_by"},
|
|
"acquired": {"bought", "purchased"},
|
|
"subsidiary_of": {"owned_by", "part_of", "division_of", "unit_of"},
|
|
"works_for": {"employee_of", "employed_by", "staff_of", "hired_by"},
|
|
"located_in": {"based_in", "headquartered_in", "situated_in", "found_in", "operates_in"},
|
|
"ceo_of": {"leader_of", "head_of", "director_of", "president_of"},
|
|
"invested_in": {"funded", "financed", "backed", "shareholder_of"},
|
|
"partner_with": {"collaborate_with", "joint_venture", "alliance", "deal_with", "partnership"},
|
|
"competitor_of": {"rival", "competes_with", "opponent", "nemesis"},
|
|
"manufacturer_of": {"producer_of", "maker_of", "creator_of", "builder_of"},
|
|
"treats": {"cures", "heals", "remedy_for", "used_for", "prescribed_for"},
|
|
"causes": {"leads_to", "results_in", "triggers", "produces", "creates"},
|
|
"diagnosed_with": {"suffers_from", "has_condition", "patient_of", "victim_of"},
|
|
}
|