Skip to content

Commit

Permalink
Update annotations/suggestions model
Browse files Browse the repository at this point in the history
  • Loading branch information
asparuhft committed Mar 2, 2023
1 parent b7d307b commit b8e300b
Show file tree
Hide file tree
Showing 9 changed files with 234 additions and 482 deletions.
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,6 @@ Example:
curl -XPUT -H "X-Request-Id: 123" -H "Content-Type: application/json" localhost:8080/content/3fa70485-3a57-3b9b-9449-774b001cd965/annotations/annotations-pac --data
"@annotations/examplePutBody.json"

NB: Although provenances are supplied is a list, we don't expect to get more than one provenance: we will take the scores from that one
and apply them to the relationship that we are creating for that annotation.

If there is no provenance, or the provenance is incomplete (e.g. no agent role) we'll still
create the relationship, it just won't have score, agent and time properties.

### GET
/content/{annotatedContentId}/annotations/{annotations-lifecycle}
This internal read should return what got written (i.e., this isn't the public annotations read API) - for the specified annotations-lifecycle.
Expand Down
132 changes: 34 additions & 98 deletions annotations/cypher.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"net/url"
"regexp"
"strings"
"time"

cmneo4j "github.com/Financial-Times/cm-neo4j-driver"
)
Expand Down Expand Up @@ -61,18 +60,19 @@ func (s service) DecodeJSON(dec *json.Decoder) (interface{}, error) {
func (s service) Read(contentUUID string, bookmark string, annotationLifecycle string) (thing interface{}, found bool, err error) {
results := []Annotation{}
//TODO shouldn't return Provenances if none of the scores, agentRole or atTime are set
statementTemplate := `
statement := `
MATCH (c:Thing{uuid:$contentUUID})-[rel{lifecycle:$annotationLifecycle}]->(cc:Thing)
WITH c, cc, rel, {id:cc.uuid,prefLabel:cc.prefLabel,types:labels(cc),predicate:type(rel)} as thing,
collect(
{scores:[
{scoringSystem:'%s', value:rel.relevanceScore},
{scoringSystem:'%s', value:rel.confidenceScore}],
agentRole:rel.annotatedBy,
atTime:rel.annotatedDate}) as provenances
RETURN thing, provenances ORDER BY thing.id`

statement := fmt.Sprintf(statementTemplate, relevanceScoringSystem, confidenceScoringSystem)
RETURN
cc.uuid as id,
cc.preflabel as prefLabel,
labels(cc) as types,
type(rel) as predicate,
rel.relevanceScore as relevanceScore,
rel.confidenceScore as confidenceScore,
rel.annotatedBy as annotatedBy,
rel.annotatedDate as annotatedDate
ORDER BY id`

query := []*cmneo4j.Query{{
Cypher: statement,
Params: map[string]interface{}{
Expand Down Expand Up @@ -207,43 +207,29 @@ func getRelationshipFromPredicate(predicate string) (string, error) {
}

func createAnnotationQuery(contentUUID string, ann Annotation, platformVersion string, annotationLifecycle string) (*cmneo4j.Query, error) {
thingID, err := extractUUIDFromURI(ann.Thing.ID)
thingID, err := extractUUIDFromURI(ann.ID)
if err != nil {
return nil, err
}

//todo temporary change to deal with multiple provenances
/*if len(ann.Provenances) > 1 {
return nil, errors.New("Cannot insert a MENTIONS annotation with multiple provenances")
}*/

var prov Provenance
params := map[string]interface{}{}
params["platformVersion"] = platformVersion
params["lifecycle"] = annotationLifecycle

if len(ann.Provenances) >= 1 {
prov = ann.Provenances[0]
annotatedBy, annotatedDateEpoch, relevanceScore, confidenceScore, supplied, err := extractDataFromProvenance(&prov)

if ann.AnnotatedBy != "" {
params["annotatedBy"], err = extractUUIDFromURI(ann.AnnotatedBy)
if err != nil {
return nil, err
}

if supplied == true {
if annotatedBy != "" {
params["annotatedBy"] = annotatedBy
}
if prov.AtTime != "" {
params["annotatedDateEpoch"] = annotatedDateEpoch
params["annotatedDate"] = prov.AtTime
}
params["relevanceScore"] = relevanceScore
params["confidenceScore"] = confidenceScore
}
}
if ann.AnnotatedDate != "" {
params["annotatedDateEpoch"] = ann.AnnotatedDateEpoch
params["annotatedDate"] = ann.AnnotatedDate
}
params["relevanceScore"] = ann.RelevanceScore
params["confidenceScore"] = ann.ConfidenceScore

relation, err := getRelationshipFromPredicate(ann.Thing.Predicate)
relation, err := getRelationshipFromPredicate(ann.Predicate)
if err != nil {
return nil, err
}
Expand All @@ -261,61 +247,6 @@ func createAnnotationQuery(contentUUID string, ann Annotation, platformVersion s
return query, nil
}

func extractDataFromProvenance(prov *Provenance) (string, int64, float64, float64, bool, error) {
if len(prov.Scores) == 0 {
return "", -1, -1, -1, false, nil
}
var annotatedBy string
var annotatedDateEpoch int64
var confidenceScore, relevanceScore float64
var err error
if prov.AgentRole != "" {
annotatedBy, err = extractUUIDFromURI(prov.AgentRole)
}
if prov.AtTime != "" {
annotatedDateEpoch, err = convertAnnotatedDateToEpoch(prov.AtTime)
}
relevanceScore, confidenceScore, err = extractScores(prov.Scores)

if err != nil {
return "", -1, -1, -1, true, err
}
return annotatedBy, annotatedDateEpoch, relevanceScore, confidenceScore, true, nil
}

func extractUUIDFromURI(uri string) (string, error) {
result := uuidExtractRegex.FindStringSubmatch(uri)
if len(result) == 2 {
return result[1], nil
}
return "", fmt.Errorf("couldn't extract uuid from uri %s", uri)
}

func convertAnnotatedDateToEpoch(annotatedDateString string) (int64, error) {
datetimeEpoch, err := time.Parse(time.RFC3339, annotatedDateString)

if err != nil {
return 0, err
}

return datetimeEpoch.Unix(), nil
}

func extractScores(scores []Score) (float64, float64, error) {
var relevanceScore, confidenceScore float64
for _, score := range scores {
scoringSystem := score.ScoringSystem
value := score.Value
switch scoringSystem {
case relevanceScoringSystem:
relevanceScore = value
case confidenceScoringSystem:
confidenceScore = value
}
}
return relevanceScore, confidenceScore, nil
}

func buildDeleteQuery(contentUUID string, annotationLifecycle string, includeStats bool) *cmneo4j.Query {
statement := `OPTIONAL MATCH (:Thing{uuid:$contentID})-[r{lifecycle:$annotationLifecycle}]->(t:Thing)
DELETE r`
Expand All @@ -333,7 +264,7 @@ func buildDeleteQuery(contentUUID string, annotationLifecycle string, includeSta
func validateAnnotations(annotations *Annotations) error {
//TODO - for consistency, we should probably just not create the annotation?
for _, annotation := range *annotations {
if annotation.Thing.ID == "" {
if annotation.ID == "" {
return ValidationError{fmt.Sprintf("Concept uuid missing for annotation %+v", annotation)}
}
}
Expand All @@ -350,15 +281,20 @@ func (v ValidationError) Error() string {
}

func mapToResponseFormat(ann *Annotation, publicAPIURL string) {
ann.Thing.ID = thingURL(ann.Thing.ID, publicAPIURL)
// We expect only ONE provenance - provenance value is considered valid even if the AgentRole is not specified. See: v1 - isClassifiedBy
for idx := range ann.Provenances {
if ann.Provenances[idx].AgentRole != "" {
ann.Provenances[idx].AgentRole = thingURL(ann.Provenances[idx].AgentRole, publicAPIURL)
}
ann.ID = thingURL(ann.ID, publicAPIURL)
if ann.AnnotatedBy != "" {
ann.AnnotatedBy = thingURL(ann.AnnotatedBy, publicAPIURL)
}
}

func thingURL(uuid, baseURL string) string {
return strings.TrimRight(baseURL, "/") + "/things/" + uuid
}

func extractUUIDFromURI(uri string) (string, error) {
result := uuidExtractRegex.FindStringSubmatch(uri)
if len(result) == 2 {
return result[1], nil
}
return "", fmt.Errorf("couldn't extract uuid from uri %s", uri)
}
139 changes: 50 additions & 89 deletions annotations/cypher_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,23 +61,16 @@ func TestWriteFailsWhenNoConceptIDSupplied(t *testing.T) {
assert.NoError(err, "creating cypher annotations service failed")

conceptWithoutID := Annotations{Annotation{
Thing: Thing{
PrefLabel: "prefLabel",
Types: []string{
"http://www.ft.com/ontology/organisation/Organisation",
"http://www.ft.com/ontology/core/Thing",
"http://www.ft.com/ontology/concept/Concept",
}},
Provenances: []Provenance{
{
Scores: []Score{
{ScoringSystem: "http://api.ft.com/scoringsystem/FT-RELEVANCE-SYSTEM", Value: 0.9},
{ScoringSystem: "http://api.ft.com/scoringsystem/FT-CONFIDENCE-SYSTEM", Value: 0.8},
},
AgentRole: "http://api.ft.com/things/0edd3c31-1fd0-4ef6-9230-8d545be3880a",
AtTime: "2016-01-01T19:43:47.314Z",
},
PrefLabel: "prefLabel",
Types: []string{
"http://www.ft.com/ontology/organisation/Organisation",
"http://www.ft.com/ontology/core/Thing",
"http://www.ft.com/ontology/concept/Concept",
},
RelevanceScore: 0.9,
ConfidenceScore: 0.8,
AnnotatedBy: "http://api.ft.com/things/0edd3c31-1fd0-4ef6-9230-8d545be3880a",
AnnotatedDate: "2016-01-01T19:43:47.314Z",
}}

_, err = annotationsService.Write(contentUUID, v2AnnotationLifecycle, v2PlatformVersion, conceptWithoutID)
Expand All @@ -93,25 +86,18 @@ func TestWriteFailsForInvalidPredicate(t *testing.T) {
assert.NoError(err, "creating cypher annotations service failed")

conceptWithInvalidPredicate := Annotation{
Thing: Thing{ID: fmt.Sprintf("http://api.ft.com/things/%s", oldConceptUUID),
PrefLabel: "prefLabel",
Types: []string{
"http://www.ft.com/ontology/person/Person",
"http://www.ft.com/ontology/core/Thing",
"http://www.ft.com/ontology/concept/Concept",
},
Predicate: "hasAFakePredicate",
},
Provenances: []Provenance{
{
Scores: []Score{
{ScoringSystem: "http://api.ft.com/scoringsystem/FT-RELEVANCE-SYSTEM", Value: 0.9},
{ScoringSystem: "http://api.ft.com/scoringsystem/FT-CONFIDENCE-SYSTEM", Value: 0.8},
},
AgentRole: "http://api.ft.com/things/0edd3c31-1fd0-4ef6-9230-8d545be3880a",
AtTime: "2016-01-01T19:43:47.314Z",
},
ID: fmt.Sprintf("http://api.ft.com/things/%s", oldConceptUUID),
PrefLabel: "prefLabel",
Types: []string{
"http://www.ft.com/ontology/person/Person",
"http://www.ft.com/ontology/core/Thing",
"http://www.ft.com/ontology/concept/Concept",
},
Predicate: "hasAFakePredicate",
RelevanceScore: 0.9,
ConfidenceScore: 0.8,
AnnotatedBy: "http://api.ft.com/things/0edd3c31-1fd0-4ef6-9230-8d545be3880a",
AnnotatedDate: "2016-01-01T19:43:47.314Z",
}

_, err = annotationsService.Write(contentUUID, v2AnnotationLifecycle, v2PlatformVersion, Annotations{conceptWithInvalidPredicate})
Expand Down Expand Up @@ -355,46 +341,32 @@ func TestWriteAndReadMultipleAnnotations(t *testing.T) {

multiConceptAnnotations := Annotations{
Annotation{
Thing: Thing{ID: getURI(conceptUUID),
PrefLabel: "prefLabel",
Types: []string{
"http://www.ft.com/ontology/product/Brand",
"http://www.ft.com/ontology/core/Thing",
"http://www.ft.com/ontology/concept/Concept",
},
Predicate: "hasBrand",
},
Provenances: []Provenance{
{
Scores: []Score{
{ScoringSystem: relevanceScoringSystem, Value: 0.9},
{ScoringSystem: confidenceScoringSystem, Value: 0.8},
},
AgentRole: "http://api.ft.com/things/0edd3c31-1fd0-4ef6-9230-8d545be3880a",
AtTime: "2016-01-01T19:43:47.314Z",
},
ID: getURI(conceptUUID),
PrefLabel: "prefLabel",
Types: []string{
"http://www.ft.com/ontology/product/Brand",
"http://www.ft.com/ontology/core/Thing",
"http://www.ft.com/ontology/concept/Concept",
},
Predicate: "hasBrand",
RelevanceScore: 0.9,
ConfidenceScore: 0.8,
AnnotatedBy: "http://api.ft.com/things/0edd3c31-1fd0-4ef6-9230-8d545be3880a",
AnnotatedDate: "2016-01-01T19:43:47.314Z",
},
Annotation{
Thing: Thing{ID: getURI(secondConceptUUID),
PrefLabel: "prefLabel",
Types: []string{
"http://www.ft.com/ontology/organisation/Organisation",
"http://www.ft.com/ontology/core/Thing",
"http://www.ft.com/ontology/concept/Concept",
},
Predicate: "mentions",
},
Provenances: []Provenance{
{
Scores: []Score{
{ScoringSystem: relevanceScoringSystem, Value: 0.4},
{ScoringSystem: confidenceScoringSystem, Value: 0.5},
},
AgentRole: "http://api.ft.com/things/0edd3c31-1fd0-4ef6-9230-8d545be3880a",
AtTime: "2016-01-01T19:43:47.314Z",
},
ID: getURI(secondConceptUUID),
PrefLabel: "prefLabel",
Types: []string{
"http://www.ft.com/ontology/organisation/Organisation",
"http://www.ft.com/ontology/core/Thing",
"http://www.ft.com/ontology/concept/Concept",
},
Predicate: "mentions",
RelevanceScore: 0.4,
ConfidenceScore: 0.5,
AnnotatedBy: "http://api.ft.com/things/0edd3c31-1fd0-4ef6-9230-8d545be3880a",
AnnotatedDate: "2016-01-01T19:43:47.314Z",
},
}

Expand All @@ -405,18 +377,6 @@ func TestWriteAndReadMultipleAnnotations(t *testing.T) {
cleanUp(t, contentUUID, v2AnnotationLifecycle, []string{conceptUUID, secondConceptUUID})
}

func TestIfProvenanceGetsWrittenWithEmptyAgentRoleAndTimeValues(t *testing.T) {
assert := assert.New(t)
driver := getNeo4jDriver(t)
annotationsService, err := NewCypherAnnotationsService(driver, apiHost)
assert.NoError(err, "creating cypher annotations service failed")

bookmark, err := annotationsService.Write(contentUUID, v2AnnotationLifecycle, v2PlatformVersion, conceptWithoutAgent)
assert.NoError(err, "Failed to write annotation")
readAnnotationsForContentUUIDAndCheckKeyFieldsMatch(t, annotationsService, contentUUID, v2AnnotationLifecycle, bookmark, conceptWithoutAgent)
cleanUp(t, contentUUID, v2AnnotationLifecycle, []string{conceptUUID})
}

func TestNextVideoAnnotationsUpdatesAnnotations(t *testing.T) {
assert := assert.New(t)
defer cleanDB(t, assert)
Expand Down Expand Up @@ -513,15 +473,16 @@ func readAnnotationsForContentUUIDAndCheckKeyFieldsMatch(t *testing.T, svc Servi
assert.Equal(len(expectedAnnotations), len(storedAnnotations), "Didn't get the same number of annotations")
for idx, expectedAnnotation := range expectedAnnotations {
storedAnnotation := storedAnnotations[idx]
assert.EqualValues(expectedAnnotation.Provenances, storedAnnotation.Provenances, "Provenances not the same")

// In annotations write, we don't store anything other than ID for the concept (so type will only be 'Thing' and pref label will not
// be present UNLESS the concept has been written by some other system)
assert.Equal(expectedAnnotation.Thing.ID, storedAnnotation.Thing.ID, "Thing ID not the same")

expectedPredicate, err := getRelationshipFromPredicate(expectedAnnotation.Thing.Predicate)
assert.NoError(err, "error getting relationship from predicate %s", expectedAnnotation.Thing.Predicate)
assert.Equal(expectedPredicate, storedAnnotation.Thing.Predicate, "Thing Predicates not the same")
assert.Equal(expectedAnnotation.ID, storedAnnotation.ID, "ID is not the same")
expectedPredicate, err := getRelationshipFromPredicate(expectedAnnotation.Predicate)
assert.NoError(err, "error getting relationship from predicate %s", expectedAnnotation.Predicate)
assert.Equal(expectedPredicate, storedAnnotation.Predicate, "Predicates are not the same")
assert.Equal(expectedAnnotation.RelevanceScore, storedAnnotation.RelevanceScore, "Relevance score is not the same")
assert.Equal(expectedAnnotation.ConfidenceScore, storedAnnotation.ConfidenceScore, "Confidence score is not the same")
assert.Equal(expectedAnnotation.AnnotatedBy, storedAnnotation.AnnotatedBy, "AnnotatedBy is not the same")
assert.Equal(expectedAnnotation.AnnotatedDate, storedAnnotation.AnnotatedDate, "AnnotatedDate is not the same")
}
}

Expand Down
2 changes: 1 addition & 1 deletion annotations/cypher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func TestCreateAnnotationQueryWithPredicate(t *testing.T) {
func TestCreateAnnotationQueryWithoutPredicate(t *testing.T) {
assert := assert.New(t)
annotation := exampleConcept(oldConceptUUID)
annotation.Thing.Predicate = ""
annotation.Predicate = ""

_, err := createAnnotationQuery(contentUUID, annotation, v2PlatformVersion, v2AnnotationLifecycle)
assert.True(errors.Is(err, UnsupportedPredicateErr), "Creating annotation without predicate is not allowed.")
Expand Down
Loading

0 comments on commit b8e300b

Please sign in to comment.