Class: FactDb::Services::FactService

Inherits:
Object
  • Object
show all
Defined in:
lib/fact_db/services/fact_service.rb

Overview

Service class for managing facts in the database

Provides methods for creating, querying, and manipulating facts including temporal queries, semantic search, and conflict resolution.

Examples:

Basic usage

service = FactService.new
fact = service.create("John works at Acme", valid_at: Date.today)

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config = FactDb.config) ⇒ FactService

Initializes a new FactService instance

Parameters:

  • config (FactDb::Config) (defaults to: FactDb.config)

    configuration object (defaults to FactDb.config)



27
28
29
30
31
# File 'lib/fact_db/services/fact_service.rb', line 27

def initialize(config = FactDb.config)
  @config = config
  @resolver = Resolution::FactResolver.new(config)
  @entity_service = EntityService.new(config)
end

Instance Attribute Details

#configFactDb::Config (readonly)

Returns the configuration object.

Returns:



16
17
18
# File 'lib/fact_db/services/fact_service.rb', line 16

def config
  @config
end

#entity_serviceFactDb::Services::EntityService (readonly)

Returns the entity service instance.

Returns:



22
23
24
# File 'lib/fact_db/services/fact_service.rb', line 22

def entity_service
  @entity_service
end

#resolverFactDb::Resolution::FactResolver (readonly)

Returns the fact resolver instance.

Returns:



19
20
21
# File 'lib/fact_db/services/fact_service.rb', line 19

def resolver
  @resolver
end

Instance Method Details

#build_timeline_fact(entity_id:, topic: nil) ⇒ Hash

Builds a timeline fact summarizing an entity’s history

Parameters:

  • entity_id (Integer)

    the entity ID

  • topic (String, nil) (defaults to: nil)

    optional topic filter

Returns:

  • (Hash)

    timeline summary data



324
325
326
# File 'lib/fact_db/services/fact_service.rb', line 324

def build_timeline_fact(entity_id:, topic: nil)
  @resolver.build_timeline_fact(entity_id: entity_id, topic: topic)
end

#by_extraction_method(method, limit: nil) ⇒ ActiveRecord::Relation

Returns facts by extraction method

Parameters:

  • method (Symbol, String)

    extraction method (:manual, :llm, :rule_based)

  • limit (Integer, nil) (defaults to: nil)

    maximum number of results

Returns:

  • (ActiveRecord::Relation)

    facts extracted by the given method



343
344
345
346
347
# File 'lib/fact_db/services/fact_service.rb', line 343

def by_extraction_method(method, limit: nil)
  scope = Models::Fact.extracted_by(method.to_s).order(created_at: :desc)
  scope = scope.limit(limit) if limit
  scope
end

#corroborate(fact_id, corroborating_fact_id) ⇒ FactDb::Models::Fact

Links a corroborating fact to support another fact

Parameters:

  • fact_id (Integer)

    ID of the fact being corroborated

  • corroborating_fact_id (Integer)

    ID of the supporting fact

Returns:



260
261
262
# File 'lib/fact_db/services/fact_service.rb', line 260

def corroborate(fact_id, corroborating_fact_id)
  @resolver.corroborate(fact_id, corroborating_fact_id)
end

#create(text, valid_at:, invalid_at: nil, status: :canonical, source_id: nil, mentions: [], extraction_method: :manual, confidence: 1.0, metadata: {}) ⇒ FactDb::Models::Fact

Creates a new fact in the database

Examples:

Create a fact with mentions

service.create(
  "John works at Acme Corp",
  valid_at: Date.parse("2024-01-15"),
  mentions: [
    { name: "John", kind: :person, role: :subject },
    { name: "Acme Corp", kind: :organization, role: :object }
  ]
)

Parameters:

  • text (String)

    the fact text content

  • valid_at (Date, Time)

    when the fact became valid

  • invalid_at (Date, Time, nil) (defaults to: nil)

    when the fact became invalid (nil if still valid)

  • status (Symbol) (defaults to: :canonical)

    fact status (:canonical, :superseded, :synthesized)

  • source_id (Integer, nil) (defaults to: nil)

    ID of the source document

  • mentions (Array<Hash>) (defaults to: [])

    entity mentions with :name, :kind, :role, :confidence keys

  • extraction_method (Symbol) (defaults to: :manual)

    how the fact was extracted (:manual, :llm, :rule_based)

  • confidence (Float) (defaults to: 1.0)

    confidence score from 0.0 to 1.0

  • metadata (Hash) (defaults to: {})

    additional metadata for the fact

Returns:



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/fact_db/services/fact_service.rb', line 55

def create(text, valid_at:, invalid_at: nil, status: :canonical, source_id: nil, mentions: [], extraction_method: :manual, confidence: 1.0, metadata: {})
  embedding = generate_embedding(text)

  fact = Models::Fact.create!(
    text: text,
    valid_at: valid_at,
    invalid_at: invalid_at,
    status: status.to_s,
    extraction_method: extraction_method.to_s,
    confidence: confidence,
    metadata: ,
    embedding: embedding
  )

  # Link to source
  if source_id
    source = Models::Source.find(source_id)
    fact.add_source(source: source, kind: "primary")
  end

  # Add entity mentions
  mentions.each do |mention|
    entity = resolve_or_create_entity(mention)
    fact.add_mention(
      entity: entity,
      text: mention[:text] || mention[:name],
      role: mention[:role],
      confidence: mention[:confidence] || 1.0
    )
  end

  fact
end

#current_facts(entity: nil, topic: nil, limit: nil) ⇒ ActiveRecord::Relation

Returns currently valid facts

Parameters:

  • entity (Integer, nil) (defaults to: nil)

    entity ID to filter by

  • topic (String, nil) (defaults to: nil)

    topic to search for

  • limit (Integer, nil) (defaults to: nil)

    maximum number of results

Returns:

  • (ActiveRecord::Relation)

    currently valid canonical facts



194
195
196
# File 'lib/fact_db/services/fact_service.rb', line 194

def current_facts(entity: nil, topic: nil, limit: nil)
  query(topic: topic, entity: entity, at: nil, status: :canonical, limit: limit)
end

#extract_from_source(source_id, extractor: config.default_extractor) ⇒ Array<FactDb::Models::Fact> Also known as: extract_from_content

Extracts facts from a source document

Uses the configured extractor to parse the source content and create facts.

Examples:

Extract facts using LLM

facts = service.extract_from_source(source.id, extractor: :llm)

Parameters:

  • source_id (Integer)

    ID of the source to extract from

  • extractor (Symbol) (defaults to: config.default_extractor)

    extractor type (:manual, :llm, :rule_based)

Returns:



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/fact_db/services/fact_service.rb', line 141

def extract_from_source(source_id, extractor: config.default_extractor)
  source = Models::Source.find(source_id)
  extractor_instance = Extractors::Base.for(extractor, config)

  extracted = extractor_instance.extract(
    source.content,
    { captured_at: source.captured_at }
  )

  extracted.map do |fact_data|
    create(
      fact_data[:text],
      valid_at: fact_data[:valid_at],
      invalid_at: fact_data[:invalid_at],
      source_id: source_id,
      mentions: fact_data[:mentions],
      extraction_method: fact_data[:extraction_method] || extractor,
      confidence: fact_data[:confidence] || 1.0,
      metadata: fact_data[:metadata] || {}
    )
  end
end

#fact_stats(entity_id = nil) ⇒ Hash

Returns fact statistics for an entity (or all facts)

Parameters:

  • entity_id (Integer, nil) (defaults to: nil)

    Entity ID (nil for all facts)

Returns:

  • (Hash)

    Statistics by fact status



368
369
370
371
372
373
374
375
376
377
# File 'lib/fact_db/services/fact_service.rb', line 368

def fact_stats(entity_id = nil)
  scope = entity_id ? Models::Fact.mentioning_entity(entity_id) : Models::Fact.all

  {
    canonical: scope.where(status: "canonical").count,
    superseded: scope.where(status: "superseded").count,
    corroborated: scope.where.not(corroborated_by_ids: nil).where.not(corroborated_by_ids: []).count,
    synthesized: scope.where(status: "synthesized").count
  }
end

#facts_at(date, entity: nil, topic: nil) ⇒ ActiveRecord::Relation

Returns facts valid at a specific date

Parameters:

  • date (Date, Time)

    the point in time

  • entity (Integer, nil) (defaults to: nil)

    entity ID to filter by

  • topic (String, nil) (defaults to: nil)

    topic to search for

Returns:

  • (ActiveRecord::Relation)

    facts valid at the given date



204
205
206
# File 'lib/fact_db/services/fact_service.rb', line 204

def facts_at(date, entity: nil, topic: nil)
  query(topic: topic, entity: entity, at: date, status: :canonical)
end

#find(id) ⇒ FactDb::Models::Fact

Finds a fact by ID

Parameters:

  • id (Integer)

    the fact ID

Returns:

Raises:

  • (ActiveRecord::RecordNotFound)

    if fact not found



127
128
129
# File 'lib/fact_db/services/fact_service.rb', line 127

def find(id)
  Models::Fact.find(id)
end

#find_conflicts(entity_id: nil, topic: nil) ⇒ Array<Hash>

Finds conflicting facts for an entity or topic

Parameters:

  • entity_id (Integer, nil) (defaults to: nil)

    entity ID to check

  • topic (String, nil) (defaults to: nil)

    topic to check

Returns:

  • (Array<Hash>)

    array of conflict descriptions



305
306
307
# File 'lib/fact_db/services/fact_service.rb', line 305

def find_conflicts(entity_id: nil, topic: nil)
  @resolver.find_conflicts(entity_id: entity_id, topic: topic)
end

#find_or_create(text, valid_at:, invalid_at: nil, status: :canonical, source_id: nil, mentions: [], extraction_method: :manual, confidence: 1.0, metadata: {}) ⇒ FactDb::Models::Fact

Finds an existing fact or creates a new one

Uses a SHA256 digest of the text and valid_at date to find duplicates.

Parameters:

  • text (String)

    the fact text content

  • valid_at (Date, Time)

    when the fact became valid

  • invalid_at (Date, Time, nil) (defaults to: nil)

    when the fact became invalid

  • status (Symbol) (defaults to: :canonical)

    fact status

  • source_id (Integer, nil) (defaults to: nil)

    ID of the source document

  • mentions (Array<Hash>) (defaults to: [])

    entity mentions

  • extraction_method (Symbol) (defaults to: :manual)

    extraction method used

  • confidence (Float) (defaults to: 1.0)

    confidence score

  • metadata (Hash) (defaults to: {})

    additional metadata

Returns:



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/fact_db/services/fact_service.rb', line 103

def find_or_create(text, valid_at:, invalid_at: nil, status: :canonical, source_id: nil, mentions: [], extraction_method: :manual, confidence: 1.0, metadata: {})
  digest = Digest::SHA256.hexdigest(text)
  existing = Models::Fact.find_by(digest: digest, valid_at: valid_at)

  return existing if existing

  create(
    text,
    valid_at: valid_at,
    invalid_at: invalid_at,
    status: status,
    source_id: source_id,
    mentions: mentions,
    extraction_method: extraction_method,
    confidence: confidence,
    metadata: 
  )
end

#invalidate(fact_id, at: Time.current) ⇒ FactDb::Models::Fact

Invalidates a fact at a specific time

Parameters:

  • fact_id (Integer)

    ID of the fact to invalidate

  • at (Time) (defaults to: Time.current)

    when the fact became invalid (defaults to now)

Returns:



251
252
253
# File 'lib/fact_db/services/fact_service.rb', line 251

def invalidate(fact_id, at: Time.current)
  @resolver.invalidate(fact_id, at: at)
end

#query(topic: nil, at: nil, entity: nil, status: :canonical, limit: nil) ⇒ ActiveRecord::Relation

Queries facts with filtering options

Examples:

Query facts about a topic at a specific date

service.query(topic: "employment", at: Date.parse("2024-01-15"))

Parameters:

  • topic (String, nil) (defaults to: nil)

    topic to search for in fact text

  • at (Date, Time, nil) (defaults to: nil)

    point in time for temporal query

  • entity (Integer, nil) (defaults to: nil)

    entity ID to filter by

  • status (Symbol) (defaults to: :canonical)

    fact status filter (:canonical, :superseded, :all)

  • limit (Integer, nil) (defaults to: nil)

    maximum number of results

Returns:

  • (ActiveRecord::Relation)

    matching facts



178
179
180
181
182
183
184
185
186
# File 'lib/fact_db/services/fact_service.rb', line 178

def query(topic: nil, at: nil, entity: nil, status: :canonical, limit: nil)
  Temporal::Query.new.execute(
    topic: topic,
    at: at,
    entity_id: entity,
    status: status,
    limit: limit
  )
end

#recent(limit: 10, status: :canonical) ⇒ ActiveRecord::Relation

Returns recently created facts

Parameters:

  • limit (Integer) (defaults to: 10)

    maximum number of results

  • status (Symbol) (defaults to: :canonical)

    fact status filter

Returns:

  • (ActiveRecord::Relation)

    recent facts ordered by creation date



333
334
335
336
# File 'lib/fact_db/services/fact_service.rb', line 333

def recent(limit: 10, status: :canonical)
  scope = Models::Fact.where(status: status.to_s).order(created_at: :desc)
  scope.limit(limit)
end

#resolve_conflict(keep_fact_id, supersede_fact_ids, reason: nil) ⇒ FactDb::Models::Fact

Resolves a conflict by keeping one fact and superseding others

Parameters:

  • keep_fact_id (Integer)

    ID of the fact to keep

  • supersede_fact_ids (Array<Integer>)

    IDs of facts to supersede

  • reason (String, nil) (defaults to: nil)

    reason for the resolution

Returns:



315
316
317
# File 'lib/fact_db/services/fact_service.rb', line 315

def resolve_conflict(keep_fact_id, supersede_fact_ids, reason: nil)
  @resolver.resolve_conflict(keep_fact_id, supersede_fact_ids, reason: reason)
end

#search(query, entity: nil, status: :canonical, limit: 20) ⇒ ActiveRecord::Relation

Searches facts using full-text search

Parameters:

  • query (String)

    the search query

  • entity (Integer, nil) (defaults to: nil)

    entity ID to filter by

  • status (Symbol) (defaults to: :canonical)

    fact status filter

  • limit (Integer) (defaults to: 20)

    maximum number of results

Returns:

  • (ActiveRecord::Relation)

    matching facts



271
272
273
274
275
# File 'lib/fact_db/services/fact_service.rb', line 271

def search(query, entity: nil, status: :canonical, limit: 20)
  scope = Models::Fact.search_text(query)
  scope = apply_filters(scope, entity: entity, status: status)
  scope.order(valid_at: :desc).limit(limit)
end

#semantic_search(query, entity: nil, at: nil, limit: 20) ⇒ ActiveRecord::Relation

Searches facts using semantic similarity (vector search)

Requires an embedding generator to be configured.

Examples:

Find semantically similar facts

service.semantic_search("Who manages the sales team?", limit: 5)

Parameters:

  • query (String)

    the search query

  • entity (Integer, nil) (defaults to: nil)

    entity ID to filter by

  • at (Date, Time, nil) (defaults to: nil)

    point in time for temporal filtering

  • limit (Integer) (defaults to: 20)

    maximum number of results

Returns:

  • (ActiveRecord::Relation)

    semantically similar facts



289
290
291
292
293
294
295
296
297
298
# File 'lib/fact_db/services/fact_service.rb', line 289

def semantic_search(query, entity: nil, at: nil, limit: 20)
  embedding = generate_embedding(query)
  return Models::Fact.none unless embedding

  scope = Models::Fact.canonical.nearest_neighbors(embedding, limit: limit * 2)
  scope = scope.currently_valid if at.nil?
  scope = scope.valid_at(at) if at
  scope = scope.mentioning_entity(entity) if entity
  scope.limit(limit)
end

#statsHash

Returns aggregate statistics about all facts

Returns:

  • (Hash)

    statistics including counts by status and extraction method



352
353
354
355
356
357
358
359
360
361
362
# File 'lib/fact_db/services/fact_service.rb', line 352

def stats
  {
    total: Models::Fact.count,
    total_count: Models::Fact.count,
    canonical_count: Models::Fact.canonical.count,
    currently_valid_count: Models::Fact.canonical.currently_valid.count,
    by_status: Models::Fact.group(:status).count,
    by_extraction_method: Models::Fact.group(:extraction_method).count,
    average_confidence: Models::Fact.average(:confidence)&.to_f&.round(3)
  }
end

#supersede(old_fact_id, new_text, valid_at:, mentions: []) ⇒ FactDb::Models::Fact

Supersedes an old fact with new information

Marks the old fact as superseded and creates a new canonical fact.

Parameters:

  • old_fact_id (Integer)

    ID of the fact to supersede

  • new_text (String)

    the updated fact text

  • valid_at (Date, Time)

    when the new fact became valid

  • mentions (Array<Hash>) (defaults to: [])

    entity mentions for the new fact

Returns:



230
231
232
# File 'lib/fact_db/services/fact_service.rb', line 230

def supersede(old_fact_id, new_text, valid_at:, mentions: [])
  @resolver.supersede(old_fact_id, new_text, valid_at: valid_at, mentions: mentions)
end

#synthesize(source_fact_ids, synthesized_text, valid_at:, invalid_at: nil, mentions: []) ⇒ FactDb::Models::Fact

Synthesizes multiple facts into a single summary fact

Parameters:

  • source_fact_ids (Array<Integer>)

    IDs of facts to synthesize

  • synthesized_text (String)

    the synthesized summary text

  • valid_at (Date, Time)

    when the synthesis is valid from

  • invalid_at (Date, Time, nil) (defaults to: nil)

    when the synthesis becomes invalid

  • mentions (Array<Hash>) (defaults to: [])

    entity mentions for the synthesized fact

Returns:



242
243
244
# File 'lib/fact_db/services/fact_service.rb', line 242

def synthesize(source_fact_ids, synthesized_text, valid_at:, invalid_at: nil, mentions: [])
  @resolver.synthesize(source_fact_ids, synthesized_text, valid_at: valid_at, invalid_at: invalid_at, mentions: mentions)
end

#timeline(entity_id:, from: nil, to: nil) ⇒ FactDb::Temporal::Timeline

Builds a timeline of facts for an entity

Examples:

Get timeline for past year

service.timeline(entity_id: 1, from: 1.year.ago, to: Date.today)

Parameters:

  • entity_id (Integer)

    the entity ID

  • from (Date, Time, nil) (defaults to: nil)

    start of timeline range

  • to (Date, Time, nil) (defaults to: nil)

    end of timeline range

Returns:



217
218
219
# File 'lib/fact_db/services/fact_service.rb', line 217

def timeline(entity_id:, from: nil, to: nil)
  Temporal::Timeline.new.build(entity_id: entity_id, from: from, to: to)
end