390 lines
16 KiB
Go
390 lines
16 KiB
Go
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)
|
|
}
|