Code sync, security fixes

This commit is contained in:
Andy Dixon
2026-08-11 10:40:10 +01:00
parent 4e00deedf8
commit 53dbc28383
141 changed files with 36194 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
package domain
import "time"
// Role determines what a user may do.
type Role string
const (
RoleAdmin Role = "admin"
RoleEditor Role = "editor"
RoleViewer Role = "viewer"
)
// Valid reports whether r is a recognised role.
func (r Role) Valid() bool {
switch r {
case RoleAdmin, RoleEditor, RoleViewer:
return true
}
return false
}
// CanWrite reports whether the role may create or modify content.
func (r Role) CanWrite() bool { return r == RoleAdmin || r == RoleEditor }
// Workspace is the data boundary. Every user-content record carries a
// workspace ID and every repository query filters on it.
type Workspace struct {
ID string `bson:"_id" json:"id"`
Name string `bson:"name" json:"name"`
Slug string `bson:"slug" json:"slug"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// User is an account, either local or federated. Only the Argon2id encoded
// hash is ever stored for a local account; a federated one has none.
type User struct {
ID string `bson:"_id" json:"id"`
Username string `bson:"username" json:"username"`
// PasswordHash is an encoded Argon2id string including salt and parameters.
// It is empty for a federated account, which can never sign in locally.
PasswordHash string `bson:"password_hash" json:"-"`
// OAuthProvider and OAuthSubject identify a federated account.
//
// The subject is the key, never the email address: `sub` is the only
// identifier a provider promises not to reassign, whereas email addresses
// are recycled — and keying on one would hand a new holder the previous
// holder's documents.
OAuthProvider string `bson:"oauth_provider,omitempty" json:"oauth_provider,omitempty"`
OAuthSubject string `bson:"oauth_subject,omitempty" json:"oauth_subject,omitempty"`
// Email is stored for display and for the optional domain allowlist. It is
// not an identifier and nothing is looked up by it.
Email string `bson:"email,omitempty" json:"email,omitempty"`
DisplayName string `bson:"display_name,omitempty" json:"display_name,omitempty"`
Role Role `bson:"role" json:"role"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
Disabled bool `bson:"disabled" json:"disabled"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
LastLoginAt *time.Time `bson:"last_login_at,omitempty" json:"last_login_at,omitempty"`
}
// Session is a server-side browser session. The cookie carries only an opaque
// token; the stored record holds a hash of that token so a database read cannot
// be replayed as a login.
type Session struct {
ID string `bson:"_id" json:"id"`
TokenHash string `bson:"token_hash" json:"-"`
// CSRFToken is a per-session secret compared in constant time on
// state-changing browser requests.
CSRFToken string `bson:"csrf_token" json:"-"`
UserID string `bson:"user_id" json:"user_id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
LastSeenAt time.Time `bson:"last_seen_at" json:"last_seen_at"`
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
AbsoluteExpiry time.Time `bson:"absolute_expiry" json:"absolute_expiry"`
UserAgentHash string `bson:"user_agent_hash,omitempty" json:"-"`
Revoked bool `bson:"revoked" json:"revoked"`
}
// Active reports whether the session may still be used at time now.
func (s *Session) Active(now time.Time) bool {
return !s.Revoked && now.Before(s.ExpiresAt) && now.Before(s.AbsoluteExpiry)
}
// Principal is the authenticated caller carried through request contexts.
// Every service call that touches user content takes one.
type Principal struct {
UserID string
Username string
DisplayName string
Role Role
WorkspaceID string
SessionID string
// TokenID and TokenScopes are set when the request authenticated with an
// API token rather than a session. A principal carries one or the other,
// never both, so downstream code can tell an interactive actor from a
// program without inspecting the request.
TokenID string
TokenScopes []TokenScope
}
// ViaToken reports whether this principal came from an API token.
func (p *Principal) ViaToken() bool { return p != nil && p.TokenID != "" }
// Permits reports whether the principal may perform an action needing scope s.
//
// A session-authenticated person is limited only by their role. A token is
// limited by the intersection of its scopes and that role, so a token can never
// do more than the person who created it.
func (p *Principal) Permits(s TokenScope) bool {
if p == nil || !RoleGrants(p.Role, s) {
return false
}
if !p.ViaToken() {
return true
}
for _, got := range p.TokenScopes {
if got == s {
return true
}
}
return false
}
// AuditEvent is an append-only record of a consequential action.
type AuditEvent struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
At time.Time `bson:"at" json:"at"`
Action string `bson:"action" json:"action"`
ActorID string `bson:"actor_id,omitempty" json:"actor_id,omitempty"`
ActorName string `bson:"actor_name,omitempty" json:"actor_name,omitempty"`
TargetKind string `bson:"target_kind,omitempty" json:"target_kind,omitempty"`
TargetID string `bson:"target_id,omitempty" json:"target_id,omitempty"`
RequestID string `bson:"request_id,omitempty" json:"request_id,omitempty"`
Outcome string `bson:"outcome" json:"outcome"`
// Detail holds short, non-sensitive descriptive fields. Never credentials,
// never full document bodies.
Detail map[string]string `bson:"detail,omitempty" json:"detail,omitempty"`
}
// Well-known audit actions.
const (
AuditLogin = "auth.login"
AuditLoginFailed = "auth.login_failed"
AuditLogout = "auth.logout"
AuditUserCreated = "user.created"
AuditItemCreated = "item.created"
AuditItemDeleted = "item.deleted"
AuditItemReprocessed = "item.reprocessed"
AuditRelConfirmed = "relationship.confirmed"
AuditRelRejected = "relationship.rejected"
AuditRelEdited = "relationship.edited"
AuditRelCreated = "relationship.created"
AuditTokenCreated = "api_token.created"
AuditTokenRevoked = "api_token.revoked"
AuditOAuthLogin = "auth.oauth_login"
AuditOAuthProvisioned = "auth.oauth_provisioned"
AuditOAuthFailed = "auth.oauth_failed"
AuditJobRetried = "job.retried"
AuditModelBadOutput = "model.invalid_output"
AuditAnswerGenerated = "answer.generated"
AuditWorkspaceReembed = "workspace.reembed"
)
// ProcessingRun records one pass of the pipeline over an item, retained for
// audit so that a reprocess does not erase what previous versions produced.
type ProcessingRun struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
ItemID string `bson:"item_id" json:"item_id"`
Reason string `bson:"reason" json:"reason"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
Outcome string `bson:"outcome" json:"outcome"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
ParserVersion string `bson:"parser_version,omitempty" json:"parser_version,omitempty"`
EmbeddingModel string `bson:"embedding_model,omitempty" json:"embedding_model,omitempty"`
AnalysisModel string `bson:"analysis_model,omitempty" json:"analysis_model,omitempty"`
ScoreVersion string `bson:"score_version,omitempty" json:"score_version,omitempty"`
Stats map[string]int `bson:"stats,omitempty" json:"stats,omitempty"`
}
// ModelVersion records a model/algorithm version that has been used, so that
// stale derived data can be identified when configuration changes.
type ModelVersion struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
Kind string `bson:"kind" json:"kind"`
Name string `bson:"name" json:"name"`
Dimensions int `bson:"dimensions,omitempty" json:"dimensions,omitempty"`
FirstSeenAt time.Time `bson:"first_seen_at" json:"first_seen_at"`
LastSeenAt time.Time `bson:"last_seen_at" json:"last_seen_at"`
}
// Federated reports whether the account signs in through an identity provider.
func (u *User) Federated() bool { return u != nil && u.OAuthSubject != "" }
// CanLoginLocally reports whether a username and password could ever
// authenticate this account. A federated account has no password hash, so no
// submitted password can match one.
func (u *User) CanLoginLocally() bool {
return u != nil && u.PasswordHash != "" && !u.Federated()
}
// ---------------------------------------------------------------------------
// API tokens
// ---------------------------------------------------------------------------
// TokenScope is a coarse permission carried by an API token.
type TokenScope string
const (
// ScopeIngest permits creating, reprocessing and deleting items.
ScopeIngest TokenScope = "ingest"
// ScopeQuery permits search, answer generation, and reading stored records.
ScopeQuery TokenScope = "query"
// ScopeReview permits acting on relationship and alias review decisions.
ScopeReview TokenScope = "review"
)
// Valid reports whether s is a recognised scope.
func (s TokenScope) Valid() bool {
switch s {
case ScopeIngest, ScopeQuery, ScopeReview:
return true
}
return false
}
// AllTokenScopes lists every scope, for validation and for the interface.
func AllTokenScopes() []TokenScope {
return []TokenScope{ScopeIngest, ScopeQuery, ScopeReview}
}
// RoleGrants reports whether a role is capable of a scope at all. A token can
// never exceed the role of the user who created it, so the effective permission
// is the intersection of the two — which means demoting a person immediately
// narrows every token they hold, without touching the tokens.
func RoleGrants(r Role, s TokenScope) bool {
switch s {
case ScopeQuery:
return r.Valid()
case ScopeIngest, ScopeReview:
return r.CanWrite()
}
return false
}
// APIToken is a non-interactive credential for /api/v1.
//
// Only the hash of the secret is stored, so neither a database read nor a
// backup yields a usable credential.
type APIToken struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
UserID string `bson:"user_id" json:"user_id"`
// Name is the person's own label for the token.
Name string `bson:"name" json:"name"`
// Prefix is the leading, non-secret portion of the token, kept in clear so
// the interface can identify a token without holding it.
Prefix string `bson:"prefix" json:"prefix"`
// TokenHash is the SHA-256 of the whole token. It is never returned.
TokenHash string `bson:"token_hash" json:"-"`
Scopes []TokenScope `bson:"scopes" json:"scopes"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
LastUsedAt *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"`
RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"`
// RevokedBy records who revoked it, for the audit trail.
RevokedBy string `bson:"revoked_by,omitempty" json:"revoked_by,omitempty"`
}
// Active reports whether the token may authenticate a request at time now.
func (t *APIToken) Active(now time.Time) bool {
if t == nil || t.RevokedAt != nil {
return false
}
return t.ExpiresAt.IsZero() || now.Before(t.ExpiresAt)
}
// HasScope reports whether the token was granted s.
func (t *APIToken) HasScope(s TokenScope) bool {
if t == nil {
return false
}
for _, got := range t.Scopes {
if got == s {
return true
}
}
return false
}
+169
View File
@@ -0,0 +1,169 @@
package domain
import "time"
// EntityType classifies an extracted subject.
type EntityType string
const (
EntityPerson EntityType = "person"
EntityOrganisation EntityType = "organisation"
EntityLocation EntityType = "location"
EntityProduct EntityType = "product"
EntityEvent EntityType = "event"
EntityDate EntityType = "date"
EntitySubject EntityType = "subject"
EntityConcept EntityType = "concept"
)
// ValidEntityTypes is the closed set of accepted entity types.
var ValidEntityTypes = []EntityType{
EntityPerson, EntityOrganisation, EntityLocation, EntityProduct,
EntityEvent, EntityDate, EntitySubject, EntityConcept,
}
// Valid reports whether t is a recognised entity type.
func (t EntityType) Valid() bool {
for _, v := range ValidEntityTypes {
if v == t {
return true
}
}
return false
}
// ExtractionMethod records how a derived record was produced.
type ExtractionMethod string
const (
MethodDeterministic ExtractionMethod = "deterministic"
MethodModel ExtractionMethod = "model"
MethodUser ExtractionMethod = "user"
)
// SourceRef points at the exact place a derived record came from.
// A claim or entity must never lose this link back to original text.
type SourceRef struct {
ItemID string `bson:"item_id" json:"item_id"`
ChunkID string `bson:"chunk_id,omitempty" json:"chunk_id,omitempty"`
// Start and End are byte offsets into the item's extracted text.
Start int `bson:"start" json:"start"`
End int `bson:"end" json:"end"`
// Quote is the verbatim text at those offsets.
Quote string `bson:"quote,omitempty" json:"quote,omitempty"`
Page int `bson:"page,omitempty" json:"page,omitempty"`
}
// Entity is an identifiable subject extracted from source material.
//
// Entities are keyed on (workspace, type, normalised name). Two entities whose
// names merely look alike are never merged automatically; a candidate alias is
// proposed instead and awaits confirmation.
type Entity struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
CanonicalName string `bson:"canonical_name" json:"canonical_name"`
NormalisedKey string `bson:"normalised_key" json:"normalised_key"`
Type EntityType `bson:"type" json:"type"`
Aliases []string `bson:"aliases,omitempty" json:"aliases,omitempty"`
SourceRefs []SourceRef `bson:"source_refs,omitempty" json:"source_refs,omitempty"`
ItemIDs []string `bson:"item_ids" json:"item_ids"`
Method ExtractionMethod `bson:"method" json:"method"`
// EvidenceScore reflects how well-attested the entity is (mention count,
// extraction agreement). It is not a probability.
EvidenceScore float64 `bson:"evidence_score" json:"evidence_score"`
Mentions int `bson:"mentions" json:"mentions"`
ModelVersion string `bson:"model_version,omitempty" json:"model_version,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
Deleted bool `bson:"deleted" json:"deleted"`
}
// AliasStatus tracks whether a proposed alias link has been adjudicated.
type AliasStatus string
const (
AliasCandidate AliasStatus = "candidate"
AliasConfirmed AliasStatus = "confirmed"
AliasRejected AliasStatus = "rejected"
)
// EntityAlias is a proposed "these two names may be the same thing" link.
// It exists precisely so that name similarity does not silently merge entities.
type EntityAlias struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
EntityID string `bson:"entity_id" json:"entity_id"`
Alias string `bson:"alias" json:"alias"`
Normalised string `bson:"normalised" json:"normalised"`
// CandidateEntityID is set when the alias points at another existing entity.
CandidateEntityID string `bson:"candidate_entity_id,omitempty" json:"candidate_entity_id,omitempty"`
Status AliasStatus `bson:"status" json:"status"`
Method ExtractionMethod `bson:"method" json:"method"`
Similarity float64 `bson:"similarity" json:"similarity"`
Reason string `bson:"reason,omitempty" json:"reason,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// ClaimNature distinguishes how faithfully a claim reproduces its source.
type ClaimNature string
const (
ClaimQuoted ClaimNature = "quoted"
ClaimParaphrased ClaimNature = "paraphrased"
ClaimInferred ClaimNature = "inferred"
)
// Valid reports whether n is a recognised claim nature.
func (n ClaimNature) Valid() bool {
switch n {
case ClaimQuoted, ClaimParaphrased, ClaimInferred:
return true
}
return false
}
// Claim is a statement made by a source item. A claim is not a fact: it is a
// record that some source said something, and it always retains the exact text.
type Claim struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
ItemID string `bson:"item_id" json:"item_id"`
ChunkID string `bson:"chunk_id,omitempty" json:"chunk_id,omitempty"`
// Statement is a short normalised rendering; SourceRef.Quote is the original.
Statement string `bson:"statement" json:"statement"`
SourceRef SourceRef `bson:"source_ref" json:"source_ref"`
// Attribution is who the source says is making the claim, where known.
Attribution string `bson:"attribution,omitempty" json:"attribution,omitempty"`
Nature ClaimNature `bson:"nature" json:"nature"`
// Subject and Predicate are coarse normalised keys used to find claims that
// talk about the same thing, which is how support/contradiction candidates
// are located without comparing every claim to every other claim.
Subject string `bson:"subject,omitempty" json:"subject,omitempty"`
Predicate string `bson:"predicate,omitempty" json:"predicate,omitempty"`
// Numbers and Dates hold normalised values mentioned in the claim, used to
// detect disagreement about the same quantity or date.
Numbers []float64 `bson:"numbers,omitempty" json:"numbers,omitempty"`
Dates []string `bson:"dates,omitempty" json:"dates,omitempty"`
// Polarity is +1 for affirmative, -1 for negated statements.
Polarity int `bson:"polarity" json:"polarity"`
Method ExtractionMethod `bson:"method" json:"method"`
ModelVersion string `bson:"model_version,omitempty" json:"model_version,omitempty"`
Confidence float64 `bson:"confidence" json:"confidence"`
DedupeKey string `bson:"dedupe_key" json:"-"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
Deleted bool `bson:"deleted" json:"deleted"`
}
+176
View File
@@ -0,0 +1,176 @@
// Package domain holds Relata's core types and the port interfaces the service
// layer depends on. It imports only the standard library: no MongoDB, Qdrant,
// Ollama or HTTP types may appear here.
package domain
import (
"errors"
"time"
)
// SourceType classifies where an item came from.
type SourceType string
const (
SourceTypeNote SourceType = "note"
SourceTypeDocument SourceType = "document"
SourceTypeJSON SourceType = "json"
SourceTypeEmail SourceType = "email"
SourceTypeTranscript SourceType = "transcript"
SourceTypeMarkdown SourceType = "markdown"
)
// ValidSourceTypes enumerates every accepted source type.
var ValidSourceTypes = []SourceType{
SourceTypeNote, SourceTypeDocument, SourceTypeJSON,
SourceTypeEmail, SourceTypeTranscript, SourceTypeMarkdown,
}
// Valid reports whether s is a recognised source type.
func (s SourceType) Valid() bool {
for _, v := range ValidSourceTypes {
if v == s {
return true
}
}
return false
}
// ProcessingStatus tracks an item's progress through the pipeline.
type ProcessingStatus string
const (
StatusPending ProcessingStatus = "pending"
StatusParsing ProcessingStatus = "parsing"
StatusChunking ProcessingStatus = "chunking"
StatusEmbedding ProcessingStatus = "embedding"
StatusAnalysing ProcessingStatus = "analysing"
StatusRelating ProcessingStatus = "relating"
StatusReady ProcessingStatus = "ready"
StatusFailed ProcessingStatus = "failed"
StatusPartial ProcessingStatus = "partial"
StatusDeleting ProcessingStatus = "deleting"
StatusTombstoned ProcessingStatus = "tombstoned"
)
// ParseWarning records a non-fatal problem encountered while extracting text.
// Warnings are retained on the item so that a degraded extraction is visible
// rather than silently accepted.
type ParseWarning struct {
Code string `bson:"code" json:"code"`
Message string `bson:"message" json:"message"`
Detail string `bson:"detail,omitempty" json:"detail,omitempty"`
}
// Well-known parse warning codes.
const (
WarnLowExtractableText = "low_extractable_text"
WarnParseFailed = "parse_failed"
WarnTruncatedText = "truncated_text"
WarnTruncatedChunks = "truncated_chunks"
WarnJSONDepthExceeded = "json_depth_exceeded"
WarnEncodingRepaired = "encoding_repaired"
WarnNoPageInfo = "no_page_info"
)
// Item is a complete piece of original information supplied by a user.
// The original content is never rewritten; everything else is derived.
type Item struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
Title string `bson:"title" json:"title"`
SourceType SourceType `bson:"source_type" json:"source_type"`
MIMEType string `bson:"mime_type" json:"mime_type"`
// OriginalFilename is the sanitised upload name; OriginalStoredPath points at
// the preserved bytes on disk for binary uploads (PDF, DOCX).
OriginalFilename string `bson:"original_filename,omitempty" json:"original_filename,omitempty"`
OriginalStoredPath string `bson:"original_stored_path,omitempty" json:"-"`
// OriginalText holds the verbatim user-supplied text for text-shaped inputs.
OriginalText string `bson:"original_text,omitempty" json:"original_text,omitempty"`
SizeBytes int64 `bson:"size_bytes" json:"size_bytes"`
// ExtractedText is the parser's output. For plain text it equals OriginalText.
ExtractedText string `bson:"extracted_text,omitempty" json:"extracted_text,omitempty"`
ContentHash string `bson:"content_hash" json:"content_hash"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
// CreatedAt is the user-asserted creation time of the information itself
// (may predate ingestion); IngestedAt is when Relata received it.
CreatedAt time.Time `bson:"created_at" json:"created_at"`
IngestedAt time.Time `bson:"ingested_at" json:"ingested_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
Status ProcessingStatus `bson:"status" json:"status"`
Warnings []ParseWarning `bson:"warnings,omitempty" json:"warnings,omitempty"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
ParserVersion string `bson:"parser_version,omitempty" json:"parser_version,omitempty"`
EmbeddingModel string `bson:"embedding_model,omitempty" json:"embedding_model,omitempty"`
AnalysisModel string `bson:"analysis_model,omitempty" json:"analysis_model,omitempty"`
ScoreVersion string `bson:"score_version,omitempty" json:"score_version,omitempty"`
ChunkCount int `bson:"chunk_count" json:"chunk_count"`
EntityCount int `bson:"entity_count" json:"entity_count"`
ClaimCount int `bson:"claim_count" json:"claim_count"`
RelationshipSeen int `bson:"relationship_count" json:"relationship_count"`
// DuplicateOf is set when the item was knowingly kept alongside an identical
// existing item. It is informational; the duplicate is still fully processed.
// The bson tag deliberately omits `omitempty`: the uniqueness index that
// enforces duplicate detection is a partial index keyed on this field being
// the empty string, and an absent field would not match it.
DuplicateOf string `bson:"duplicate_of" json:"duplicate_of,omitempty"`
CreatedBy string `bson:"created_by,omitempty" json:"created_by,omitempty"`
Deleted bool `bson:"deleted" json:"deleted"`
}
// HasWarning reports whether the item carries a warning with the given code.
func (i *Item) HasWarning(code string) bool {
for _, w := range i.Warnings {
if w.Code == code {
return true
}
}
return false
}
// Chunk is a retrievable section of an item.
type Chunk struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
ItemID string `bson:"item_id" json:"item_id"`
Sequence int `bson:"sequence" json:"sequence"`
Text string `bson:"text" json:"text"`
ContentHash string `bson:"content_hash" json:"content_hash"`
StartOffset int `bson:"start_offset" json:"start_offset"`
EndOffset int `bson:"end_offset" json:"end_offset"`
// Page is 1-based and only set for PDF-derived chunks.
Page int `bson:"page,omitempty" json:"page,omitempty"`
Section string `bson:"section,omitempty" json:"section,omitempty"`
Heading string `bson:"heading,omitempty" json:"heading,omitempty"`
EmbeddingModel string `bson:"embedding_model,omitempty" json:"embedding_model,omitempty"`
Embedded bool `bson:"embedded" json:"embedded"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// Errors returned by repositories and services.
var (
ErrNotFound = errors.New("not found")
ErrDuplicate = errors.New("duplicate content")
ErrForbidden = errors.New("forbidden")
ErrInvalidInput = errors.New("invalid input")
ErrTooLarge = errors.New("payload too large")
ErrUnsupported = errors.New("unsupported content type")
ErrConflict = errors.New("conflict")
ErrRateLimited = errors.New("rate limited")
ErrModelInvalid = errors.New("model returned invalid output")
ErrUnavailable = errors.New("dependency unavailable")
ErrWorkspaceMiss = errors.New("workspace mismatch")
)
+109
View File
@@ -0,0 +1,109 @@
package domain
import "time"
// JobType names a unit of background work.
type JobType string
const (
JobParseItem JobType = "parse_item"
JobChunkItem JobType = "chunk_item"
JobEmbedItem JobType = "generate_embeddings"
JobExtractEntity JobType = "extract_entities"
JobExtractClaims JobType = "extract_claims"
JobDiscoverRels JobType = "discover_relationships"
JobReprocessItem JobType = "reprocess_item"
JobDeleteItemData JobType = "delete_item_data"
JobReembedWS JobType = "reembed_workspace"
)
// ValidJobTypes is the closed set of job types the workers understand.
var ValidJobTypes = []JobType{
JobParseItem, JobChunkItem, JobEmbedItem, JobExtractEntity, JobExtractClaims,
JobDiscoverRels, JobReprocessItem, JobDeleteItemData, JobReembedWS,
}
// Valid reports whether t is a recognised job type.
func (t JobType) Valid() bool {
for _, v := range ValidJobTypes {
if v == t {
return true
}
}
return false
}
// JobStatus is a job's lifecycle state.
type JobStatus string
const (
JobPending JobStatus = "pending"
JobRunning JobStatus = "running"
JobSucceeded JobStatus = "succeeded"
JobFailed JobStatus = "failed"
JobCancelled JobStatus = "cancelled"
)
// JobAttempt records one execution attempt and its outcome.
type JobAttempt struct {
Number int `bson:"number" json:"number"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
EndedAt time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
Worker string `bson:"worker,omitempty" json:"worker,omitempty"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
// ErrorClass groups failures for metrics: transient, permanent, timeout, cancelled.
ErrorClass string `bson:"error_class,omitempty" json:"error_class,omitempty"`
DurationMS int64 `bson:"duration_ms" json:"duration_ms"`
}
// Job is a persistent, leased unit of background work.
//
// Leases (not locks) are used so that a crashed worker's job becomes runnable
// again once its lease expires, without any external coordination.
type Job struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
Type JobType `bson:"type" json:"type"`
Status JobStatus `bson:"status" json:"status"`
// ItemID is the primary subject for item-scoped jobs.
ItemID string `bson:"item_id,omitempty" json:"item_id,omitempty"`
// Payload carries small typed job parameters. It never holds document bodies.
Payload map[string]string `bson:"payload,omitempty" json:"payload,omitempty"`
Priority int `bson:"priority" json:"priority"`
RunAfter time.Time `bson:"run_after" json:"run_after"`
Attempts int `bson:"attempts" json:"attempts"`
MaxAttempts int `bson:"max_attempts" json:"max_attempts"`
LeaseOwner string `bson:"lease_owner,omitempty" json:"lease_owner,omitempty"`
LeaseExpires *time.Time `bson:"lease_expires,omitempty" json:"lease_expires,omitempty"`
History []JobAttempt `bson:"history,omitempty" json:"history,omitempty"`
LastError string `bson:"last_error,omitempty" json:"last_error,omitempty"`
// IdempotencyKey prevents duplicate enqueues of the same pending work.
IdempotencyKey string `bson:"idempotency_key,omitempty" json:"-"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
CompletedAt *time.Time `bson:"completed_at,omitempty" json:"completed_at,omitempty"`
RunID string `bson:"run_id,omitempty" json:"run_id,omitempty"`
}
// Terminal reports whether the job will not run again without manual action.
func (j *Job) Terminal() bool {
return j.Status == JobSucceeded || j.Status == JobFailed || j.Status == JobCancelled
}
// ErrorClass categorises a failure for retry decisions and metrics.
type ErrorClass string
const (
ErrClassTransient ErrorClass = "transient"
ErrClassPermanent ErrorClass = "permanent"
ErrClassTimeout ErrorClass = "timeout"
ErrClassCancelled ErrorClass = "cancelled"
ErrClassInvalid ErrorClass = "invalid_model_output"
)
+389
View File
@@ -0,0 +1,389 @@
package domain
import (
"context"
"time"
)
// ---------------------------------------------------------------------------
// AI provider ports
//
// These exist so that Ollama can be replaced by an OpenAI-compatible API or any
// other provider without touching the service layer. Adapters live in
// internal/embeddings and internal/analysis.
// ---------------------------------------------------------------------------
// Embedder turns text into vectors.
type Embedder interface {
// Embed returns one vector per input text, in the same order. Implementations
// must honour ctx cancellation and must not retry indefinitely.
Embed(ctx context.Context, texts []string) ([][]float32, error)
// ModelID identifies the exact model, for stamping onto derived records.
ModelID() string
// Dimensions is the vector width this model produces.
Dimensions() int
}
// ChatMessage is one turn of a model conversation.
type ChatMessage struct {
Role string // "system" | "user" | "assistant"
Content string
}
// CompletionRequest asks the model for a bounded response.
type CompletionRequest struct {
Messages []ChatMessage
// JSONSchema, when non-empty, requests structured output conforming to it.
JSONSchema []byte
// MaxTokens bounds the response so a runaway generation cannot exhaust
// memory or time.
MaxTokens int
Temperature float64
}
// CompletionResponse is a model reply plus what it cost.
type CompletionResponse struct {
Content string
Model string
PromptTokens int
OutputTokens int
Duration time.Duration
}
// Analyser performs model-assisted structured tasks. Its output is always
// treated as untrusted and validated against a schema before use.
type Analyser interface {
Complete(ctx context.Context, req CompletionRequest) (*CompletionResponse, error)
ModelID() string
}
// ProviderHealth describes whether an AI provider is reachable and configured.
type ProviderHealth struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Model string `json:"model"`
Available bool `json:"available"`
Detail string `json:"detail,omitempty"`
Models []string `json:"models,omitempty"`
}
// HealthChecker is implemented by providers that can report reachability.
type HealthChecker interface {
Health(ctx context.Context) ProviderHealth
}
// ---------------------------------------------------------------------------
// Vector store port
// ---------------------------------------------------------------------------
// VectorPoint is a stored embedding plus the minimal retrieval metadata needed
// to resolve it back to MongoDB. Payloads deliberately exclude document text.
type VectorPoint struct {
ChunkID string
WorkspaceID string
ItemID string
SourceType SourceType
CreatedAt time.Time
EmbeddingModel string
Vector []float32
}
// VectorMatch is a similarity search hit.
type VectorMatch struct {
ChunkID string
ItemID string
WorkspaceID string
// Score is cosine similarity in roughly -1..1; higher is more similar.
Score float64
SourceType SourceType
CreatedAt time.Time
EmbeddingModel string
}
// VectorFilter narrows a similarity search. All fields are optional and are
// combined with AND. WorkspaceID is always applied by the caller.
type VectorFilter struct {
WorkspaceID string
ItemIDs []string
ExcludeItemIDs []string
SourceTypes []SourceType
EmbeddingModel string
CreatedFrom *time.Time
CreatedTo *time.Time
}
// VectorStore persists and searches embeddings.
type VectorStore interface {
// EnsureCollection creates the collection if absent, sized for dim.
EnsureCollection(ctx context.Context, dim int) error
Upsert(ctx context.Context, points []VectorPoint) error
Search(ctx context.Context, vector []float32, limit int, f VectorFilter) ([]VectorMatch, error)
DeleteByItem(ctx context.Context, workspaceID, itemID string) error
DeleteByChunks(ctx context.Context, workspaceID string, chunkIDs []string) error
// CountByWorkspace reports how many vectors exist, for reconciliation.
CountByWorkspace(ctx context.Context, workspaceID string) (int64, error)
Health(ctx context.Context) error
}
// ---------------------------------------------------------------------------
// Repository ports
//
// Every method that touches user content takes a workspace ID and must include
// it in the underlying query. There is intentionally no "get by ID" that skips
// the workspace filter.
// ---------------------------------------------------------------------------
// Page describes a bounded slice of results.
type Page struct {
Limit int
Offset int
}
// Normalise clamps a page request to safe bounds.
func (p Page) Normalise(defLimit, maxLimit int) Page {
if p.Limit <= 0 {
p.Limit = defLimit
}
if p.Limit > maxLimit {
p.Limit = maxLimit
}
if p.Offset < 0 {
p.Offset = 0
}
return p
}
// ItemFilter selects items using typed values only. Client-supplied MongoDB
// operators are never accepted anywhere in this struct.
type ItemFilter struct {
SourceTypes []SourceType
MIMETypes []string
Statuses []ProcessingStatus
CreatedFrom *time.Time
CreatedTo *time.Time
ContentHash string
TitleContains string
IncludeDeleted bool
}
// ItemRepository stores source items.
type ItemRepository interface {
Create(ctx context.Context, it *Item) error
Get(ctx context.Context, workspaceID, id string) (*Item, error)
GetMany(ctx context.Context, workspaceID string, ids []string) ([]*Item, error)
Update(ctx context.Context, it *Item) error
SetStatus(ctx context.Context, workspaceID, id string, st ProcessingStatus, errMsg string) error
AddWarning(ctx context.Context, workspaceID, id string, w ParseWarning) error
List(ctx context.Context, workspaceID string, f ItemFilter, p Page) ([]*Item, int64, error)
FindByContentHash(ctx context.Context, workspaceID, hash string) (*Item, error)
// TextSearch is the lexical half of hybrid retrieval.
TextSearch(ctx context.Context, workspaceID, query string, f ItemFilter, limit int) ([]LexicalHit, error)
Tombstone(ctx context.Context, workspaceID, id string) error
HardDelete(ctx context.Context, workspaceID, id string) error
Count(ctx context.Context, workspaceID string) (int64, error)
CountByStatus(ctx context.Context, workspaceID string) (map[ProcessingStatus]int64, error)
}
// LexicalHit is a keyword-search result with its raw text score.
type LexicalHit struct {
ItemID string
Score float64
}
// ChunkRepository stores item chunks.
type ChunkRepository interface {
ReplaceForItem(ctx context.Context, workspaceID, itemID string, chunks []*Chunk) error
ListByItem(ctx context.Context, workspaceID, itemID string) ([]*Chunk, error)
GetMany(ctx context.Context, workspaceID string, ids []string) ([]*Chunk, error)
MarkEmbedded(ctx context.Context, workspaceID string, ids []string, model string) error
ListUnembedded(ctx context.Context, workspaceID, model string, limit int) ([]*Chunk, error)
DeleteByItem(ctx context.Context, workspaceID, itemID string) error
Count(ctx context.Context, workspaceID string) (int64, error)
// TextSearch finds chunks lexically, used to produce excerpts.
TextSearch(ctx context.Context, workspaceID, query string, limit int) ([]ChunkHit, error)
}
// ChunkHit is a lexical chunk match.
type ChunkHit struct {
ChunkID string
ItemID string
Score float64
Text string
}
// EntityFilter selects entities by typed values.
type EntityFilter struct {
Types []EntityType
NameContains string
NormalisedKey string
ItemID string
MinMentions int
}
// EntityRepository stores extracted entities.
type EntityRepository interface {
// UpsertByKey merges a newly observed entity into an existing one with the
// same (workspace, type, normalised key), accumulating references. It never
// merges entities whose keys merely look similar.
UpsertByKey(ctx context.Context, e *Entity) (*Entity, error)
Get(ctx context.Context, workspaceID, id string) (*Entity, error)
GetMany(ctx context.Context, workspaceID string, ids []string) ([]*Entity, error)
FindByNormalised(ctx context.Context, workspaceID string, keys []string) ([]*Entity, error)
List(ctx context.Context, workspaceID string, f EntityFilter, p Page) ([]*Entity, int64, error)
ListByItem(ctx context.Context, workspaceID, itemID string) ([]*Entity, error)
// ItemsSharingEntities returns item IDs that reference any of the given
// entities, excluding excludeItem. This is the indexed path that keeps
// candidate discovery away from all-pairs comparison.
ItemsSharingEntities(ctx context.Context, workspaceID string, entityIDs []string, excludeItem string, limit int) ([]string, error)
RemoveItemRefs(ctx context.Context, workspaceID, itemID string) (orphaned []string, err error)
DeleteMany(ctx context.Context, workspaceID string, ids []string) error
Count(ctx context.Context, workspaceID string) (int64, error)
}
// AliasRepository stores candidate and confirmed entity aliases.
type AliasRepository interface {
Upsert(ctx context.Context, a *EntityAlias) error
ListByEntity(ctx context.Context, workspaceID, entityID string) ([]*EntityAlias, error)
List(ctx context.Context, workspaceID string, status AliasStatus, p Page) ([]*EntityAlias, int64, error)
SetStatus(ctx context.Context, workspaceID, id string, st AliasStatus) error
DeleteByEntity(ctx context.Context, workspaceID, entityID string) error
}
// ClaimRepository stores extracted claims.
type ClaimRepository interface {
ReplaceForItem(ctx context.Context, workspaceID, itemID string, claims []*Claim) error
ListByItem(ctx context.Context, workspaceID, itemID string) ([]*Claim, error)
// FindBySubjects locates claims about the same coarse subject, which is how
// support/contradiction candidates are found cheaply.
FindBySubjects(ctx context.Context, workspaceID string, subjects []string, excludeItem string, limit int) ([]*Claim, error)
GetMany(ctx context.Context, workspaceID string, ids []string) ([]*Claim, error)
DeleteByItem(ctx context.Context, workspaceID, itemID string) error
Count(ctx context.Context, workspaceID string) (int64, error)
}
// RelationshipFilter selects relationships by typed values.
type RelationshipFilter struct {
Types []RelationshipType
Provenances []Provenance
ReviewStatus []ReviewStatus
MinScore float64
NodeID string
NodeKind NodeKind
Direction string // "out" | "in" | "both"
ConfirmedOnly bool
}
// RelationshipRepository stores relationships and their review history.
type RelationshipRepository interface {
// UpsertByDedupeKey inserts or refreshes a relationship without creating a
// duplicate on reprocessing. It never overwrites a human review decision.
UpsertByDedupeKey(ctx context.Context, r *Relationship) (*Relationship, error)
// FindByDedupeKey returns the relationship with this dedupe key, or
// ErrNotFound. It exists so callers can locate an existing link without
// paging through a node's relationships — a well-connected item can have
// more than one page of them, and a paged scan would miss the match and
// then collide with the unique index.
FindByDedupeKey(ctx context.Context, workspaceID, dedupeKey string) (*Relationship, error)
Create(ctx context.Context, r *Relationship) error
Get(ctx context.Context, workspaceID, id string) (*Relationship, error)
List(ctx context.Context, workspaceID string, f RelationshipFilter, p Page) ([]*Relationship, int64, error)
ListForNode(ctx context.Context, workspaceID string, kind NodeKind, id string, f RelationshipFilter) (out, in []*Relationship, err error)
// ListForItems fetches relationships touching any of the given items, used
// for relationship expansion during search.
ListForItems(ctx context.Context, workspaceID string, itemIDs []string, minScore float64) ([]*Relationship, error)
Update(ctx context.Context, r *Relationship) error
AppendReview(ctx context.Context, workspaceID, id string, ev ReviewEvent, newStatus ReviewStatus, newType RelationshipType, newProv Provenance) (*Relationship, error)
TombstoneForItem(ctx context.Context, workspaceID, itemID string) (int64, error)
DeleteForItem(ctx context.Context, workspaceID, itemID string) (int64, error)
Count(ctx context.Context, workspaceID string) (int64, error)
CountPendingReview(ctx context.Context, workspaceID string) (int64, error)
}
// JobRepository is the persistent queue.
type JobRepository interface {
Enqueue(ctx context.Context, j *Job) error
// Claim atomically leases the next runnable job for owner.
Claim(ctx context.Context, owner string, types []JobType, lease time.Duration) (*Job, error)
// Heartbeat extends a lease held by owner; it fails if the lease was lost.
Heartbeat(ctx context.Context, id, owner string, lease time.Duration) error
Complete(ctx context.Context, id, owner string, att JobAttempt) error
Fail(ctx context.Context, id, owner string, att JobAttempt, retryAfter *time.Time) error
Get(ctx context.Context, id string) (*Job, error)
List(ctx context.Context, workspaceID string, statuses []JobStatus, types []JobType, p Page) ([]*Job, int64, error)
Retry(ctx context.Context, workspaceID, id string) (*Job, error)
CountByStatus(ctx context.Context, workspaceID string) (map[JobStatus]int64, error)
// ReleaseExpired returns jobs whose lease elapsed to the pending pool.
ReleaseExpired(ctx context.Context, now time.Time) (int64, error)
DeleteForItem(ctx context.Context, workspaceID, itemID string) (int64, error)
}
// AuditRepository is append-only.
type AuditRepository interface {
Record(ctx context.Context, ev *AuditEvent) error
List(ctx context.Context, workspaceID string, p Page) ([]*AuditEvent, int64, error)
}
// RunRepository stores processing run history.
type RunRepository interface {
Create(ctx context.Context, r *ProcessingRun) error
Finish(ctx context.Context, id, outcome, errMsg string, stats map[string]int) error
ListByItem(ctx context.Context, workspaceID, itemID string, limit int) ([]*ProcessingRun, error)
}
// ModelVersionRepository tracks which models have produced stored data.
type ModelVersionRepository interface {
Observe(ctx context.Context, workspaceID, kind, name string, dims int) error
List(ctx context.Context, workspaceID string) ([]*ModelVersion, error)
}
// UserRepository stores local accounts.
type UserRepository interface {
Create(ctx context.Context, u *User) error
GetByUsername(ctx context.Context, username string) (*User, error)
GetByID(ctx context.Context, id string) (*User, error)
Update(ctx context.Context, u *User) error
Count(ctx context.Context) (int64, error)
// GetByOAuthSubject finds a federated account by provider and subject, the
// only pair a provider promises not to reassign. Returns ErrNotFound when
// the person has not signed in before.
GetByOAuthSubject(ctx context.Context, provider, subject string) (*User, error)
}
// APITokenRepository stores non-interactive API credentials. Only hashes are
// held; there is deliberately no method that returns a token's secret.
type APITokenRepository interface {
Create(ctx context.Context, t *APIToken) error
// GetByHash finds a token by the SHA-256 of its secret. The lookup is by
// the full hash, so a partial match cannot narrow the search space.
GetByHash(ctx context.Context, hash string) (*APIToken, error)
// ListForUser returns a person's tokens, newest first, including revoked
// and expired ones so the history stays visible.
ListForUser(ctx context.Context, workspaceID, userID string) ([]*APIToken, error)
Get(ctx context.Context, workspaceID, id string) (*APIToken, error)
Revoke(ctx context.Context, workspaceID, id, revokedBy string, at time.Time) error
// TouchLastUsed records use. Callers throttle this so a busy token does not
// turn every read into a write.
TouchLastUsed(ctx context.Context, id string, at time.Time) error
// DeleteForUser removes a user's tokens, used when an account is removed.
DeleteForUser(ctx context.Context, workspaceID, userID string) (int64, error)
}
// SessionRepository stores browser sessions.
type SessionRepository interface {
Create(ctx context.Context, s *Session) error
GetByTokenHash(ctx context.Context, hash string) (*Session, error)
Touch(ctx context.Context, id string, lastSeen, expires time.Time) error
Revoke(ctx context.Context, id string) error
RevokeAllForUser(ctx context.Context, userID string) error
DeleteExpired(ctx context.Context, before time.Time) (int64, error)
}
// WorkspaceRepository stores workspaces.
type WorkspaceRepository interface {
Create(ctx context.Context, w *Workspace) error
Get(ctx context.Context, id string) (*Workspace, error)
GetBySlug(ctx context.Context, slug string) (*Workspace, error)
List(ctx context.Context) ([]*Workspace, error)
}
+239
View File
@@ -0,0 +1,239 @@
package domain
import "time"
// RelationshipType is the semantic kind of connection between two nodes.
type RelationshipType string
const (
RelSimilarTo RelationshipType = "similar_to"
RelMentions RelationshipType = "mentions"
RelInvolves RelationshipType = "involves"
RelSupports RelationshipType = "supports"
RelContradicts RelationshipType = "contradicts"
RelUpdates RelationshipType = "updates"
RelSupersedes RelationshipType = "supersedes"
RelProvidesContextFor RelationshipType = "provides_context_for"
RelPossiblyExplains RelationshipType = "possibly_explains"
RelOccurredBefore RelationshipType = "occurred_before"
RelOccurredAfter RelationshipType = "occurred_after"
RelDerivedFrom RelationshipType = "derived_from"
RelSameEntityAs RelationshipType = "same_entity_as"
)
// ValidRelationshipTypes is the closed set of accepted relationship types.
// Model output proposing anything outside this set is rejected.
var ValidRelationshipTypes = []RelationshipType{
RelSimilarTo, RelMentions, RelInvolves, RelSupports, RelContradicts,
RelUpdates, RelSupersedes, RelProvidesContextFor, RelPossiblyExplains,
RelOccurredBefore, RelOccurredAfter, RelDerivedFrom, RelSameEntityAs,
}
// Valid reports whether t is a recognised relationship type.
func (t RelationshipType) Valid() bool {
for _, v := range ValidRelationshipTypes {
if v == t {
return true
}
}
return false
}
// Directed reports whether the type implies an ordered source -> target reading.
// Non-directed types are symmetric and are stored with a canonical ordering.
func (t RelationshipType) Directed() bool {
switch t {
case RelSimilarTo, RelSameEntityAs:
return false
default:
return true
}
}
// Inverse returns the type that describes the same connection read from the
// target's point of view, and whether such an inverse exists.
func (t RelationshipType) Inverse() (RelationshipType, bool) {
switch t {
case RelOccurredBefore:
return RelOccurredAfter, true
case RelOccurredAfter:
return RelOccurredBefore, true
case RelSimilarTo:
return RelSimilarTo, true
case RelSameEntityAs:
return RelSameEntityAs, true
}
return "", false
}
// Provenance records how a relationship came to exist. Model-inferred
// relationships are proposals, not facts, and must be labelled as such.
type Provenance string
const (
ProvExplicit Provenance = "explicit"
ProvDeterministic Provenance = "deterministic"
ProvModelInferred Provenance = "model_inferred"
ProvUserConfirmed Provenance = "user_confirmed"
ProvUserRejected Provenance = "user_rejected"
)
// ValidProvenances is the closed set of provenance classifications.
var ValidProvenances = []Provenance{
ProvExplicit, ProvDeterministic, ProvModelInferred, ProvUserConfirmed, ProvUserRejected,
}
// Valid reports whether p is a recognised provenance.
func (p Provenance) Valid() bool {
for _, v := range ValidProvenances {
if v == p {
return true
}
}
return false
}
// Asserted reports whether the provenance represents something a human or a
// deterministic rule stated, as opposed to a model's guess.
func (p Provenance) Asserted() bool {
return p == ProvExplicit || p == ProvDeterministic || p == ProvUserConfirmed
}
// NodeKind is what a relationship endpoint refers to.
type NodeKind string
const (
NodeItem NodeKind = "item"
NodeChunk NodeKind = "chunk"
NodeEntity NodeKind = "entity"
NodeClaim NodeKind = "claim"
)
// Valid reports whether k is a recognised node kind.
func (k NodeKind) Valid() bool {
switch k {
case NodeItem, NodeChunk, NodeEntity, NodeClaim:
return true
}
return false
}
// ReviewStatus tracks human adjudication of a proposed relationship.
type ReviewStatus string
const (
ReviewPending ReviewStatus = "pending"
ReviewConfirmed ReviewStatus = "confirmed"
ReviewRejected ReviewStatus = "rejected"
ReviewEdited ReviewStatus = "edited"
)
// SignalType names a concrete, explainable contribution to a relationship score.
type SignalType string
const (
SigSemanticSimilarity SignalType = "semantic_similarity"
SigSharedEntity SignalType = "shared_entity"
SigAliasEntity SignalType = "alias_entity"
SigSharedConcept SignalType = "shared_concept"
SigTemporalProximity SignalType = "temporal_proximity"
SigSharedMetadata SignalType = "shared_metadata"
SigClaimSupport SignalType = "claim_support"
SigClaimContradiction SignalType = "claim_contradiction"
SigModelClassify SignalType = "model_classification"
SigUserConfirmed SignalType = "user_confirmation"
SigUserRejected SignalType = "user_rejection"
SigContentHashMatch SignalType = "content_hash_match"
)
// Signal is one explicit piece of evidence behind a relationship score.
//
// Value carries the human-readable evidence ("Example Ltd", "2 days"); Strength
// is the normalised 0..1 contribution the signal makes before weighting. Keeping
// them separate is what lets the UI show the arithmetic instead of a bare number.
type Signal struct {
Type SignalType `bson:"type" json:"type"`
Value string `bson:"value" json:"value"`
Strength float64 `bson:"strength" json:"strength"`
Weight float64 `bson:"weight" json:"weight"`
Detail string `bson:"detail,omitempty" json:"detail,omitempty"`
}
// Contribution is the signal's weighted addition to the total score.
func (s Signal) Contribution() float64 { return s.Strength * s.Weight }
// Evidence is the concrete supporting material for a proposed relationship:
// the signals plus pointers at the exact chunks and entities involved.
type Evidence struct {
Signals []Signal `bson:"signals" json:"signals"`
SourceChunkIDs []string `bson:"source_chunk_ids,omitempty" json:"source_chunk_ids,omitempty"`
TargetChunkIDs []string `bson:"target_chunk_ids,omitempty" json:"target_chunk_ids,omitempty"`
SharedEntities []string `bson:"shared_entities,omitempty" json:"shared_entities,omitempty"`
// Excerpts are short verbatim quotations from each side, for display.
SourceExcerpt string `bson:"source_excerpt,omitempty" json:"source_excerpt,omitempty"`
TargetExcerpt string `bson:"target_excerpt,omitempty" json:"target_excerpt,omitempty"`
// ModelRationale is the model's own words about the connection. It is
// displayed as a model opinion, never as an established fact.
ModelRationale string `bson:"model_rationale,omitempty" json:"model_rationale,omitempty"`
ModelVersion string `bson:"model_version,omitempty" json:"model_version,omitempty"`
}
// ReviewEvent is one immutable entry in a relationship's decision history.
// History is append-only: rejecting a relationship never erases the proposal.
type ReviewEvent struct {
At time.Time `bson:"at" json:"at"`
Action string `bson:"action" json:"action"`
ActorID string `bson:"actor_id" json:"actor_id"`
ActorName string `bson:"actor_name,omitempty" json:"actor_name,omitempty"`
Note string `bson:"note,omitempty" json:"note,omitempty"`
FromValue string `bson:"from_value,omitempty" json:"from_value,omitempty"`
ToValue string `bson:"to_value,omitempty" json:"to_value,omitempty"`
}
// Relationship connects two nodes with a scored, evidenced, provenance-tagged link.
type Relationship struct {
ID string `bson:"_id" json:"id"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
SourceKind NodeKind `bson:"source_kind" json:"source_kind"`
SourceID string `bson:"source_id" json:"source_id"`
TargetKind NodeKind `bson:"target_kind" json:"target_kind"`
TargetID string `bson:"target_id" json:"target_id"`
Type RelationshipType `bson:"type" json:"type"`
Provenance Provenance `bson:"provenance" json:"provenance"`
// Score is a heuristic relationship strength in 0..1. It is NOT a calibrated
// probability and must never be presented as one.
Score float64 `bson:"score" json:"score"`
ScoreVersion string `bson:"score_version" json:"score_version"`
Evidence Evidence `bson:"evidence" json:"evidence"`
// OriginalType and OriginalProvenance preserve what was first proposed, even
// after a user edits the relationship.
OriginalType RelationshipType `bson:"original_type" json:"original_type"`
OriginalProvenance Provenance `bson:"original_provenance" json:"original_provenance"`
ReviewStatus ReviewStatus `bson:"review_status" json:"review_status"`
ReviewHistory []ReviewEvent `bson:"review_history,omitempty" json:"review_history,omitempty"`
// DedupeKey makes relationship creation idempotent across reprocessing runs.
DedupeKey string `bson:"dedupe_key" json:"-"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
Deleted bool `bson:"deleted" json:"deleted"`
}
// Confirmed reports whether a human affirmed this relationship.
func (r *Relationship) Confirmed() bool { return r.ReviewStatus == ReviewConfirmed }
// Rejected reports whether a human rejected this relationship. Rejected
// relationships are retained for audit and to inform future ranking.
func (r *Relationship) Rejected() bool { return r.ReviewStatus == ReviewRejected }
// Inferred reports whether the link is a model proposal rather than an assertion.
func (r *Relationship) Inferred() bool { return r.Provenance == ProvModelInferred }