use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct SourceDigest(String);
impl SourceDigest {
#[must_use]
pub fn from_bytes(bytes: &[u8]) -> Self {
Self(blake3::hash(bytes).to_hex().to_string())
}
pub fn parse(value: impl Into<String>) -> Result<Self, DomainError> {
let value = value.into();
let valid = value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
if !valid {
return Err(DomainError::InvalidSourceDigest);
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SourceDigest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl TryFrom<String> for SourceDigest {
type Error = DomainError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl From<SourceDigest> for String {
fn from(value: SourceDigest) -> Self {
value.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct SourceFileName(String);
impl SourceFileName {
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
let value = value.into();
let path = Path::new(&value);
let one_normal_component = {
let mut components = path.components();
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
};
let safe = !value.trim().is_empty()
&& value != "."
&& value != ".."
&& !value.contains(['/', '\\'])
&& !value.chars().any(char::is_control)
&& one_normal_component;
if !safe {
return Err(DomainError::UnsafeSourceFileName(value));
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SourceFileName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl TryFrom<String> for SourceFileName {
type Error = DomainError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<SourceFileName> for String {
fn from(value: SourceFileName) -> Self {
value.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IngestionStage {
Discovered,
Extracted,
Analyzed,
NoteWritten,
Archived,
Indexed,
FailedRetryable,
NeedsReview,
}
impl IngestionStage {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Discovered => "discovered",
Self::Extracted => "extracted",
Self::Analyzed => "analyzed",
Self::NoteWritten => "note_written",
Self::Archived => "archived",
Self::Indexed => "indexed",
Self::FailedRetryable => "failed_retryable",
Self::NeedsReview => "needs_review",
}
}
}
impl FromStr for IngestionStage {
type Err = DomainError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"discovered" => Ok(Self::Discovered),
"extracted" => Ok(Self::Extracted),
"analyzed" => Ok(Self::Analyzed),
"note_written" => Ok(Self::NoteWritten),
"archived" => Ok(Self::Archived),
"indexed" => Ok(Self::Indexed),
"failed_retryable" => Ok(Self::FailedRetryable),
"needs_review" => Ok(Self::NeedsReview),
_ => Err(DomainError::InvalidIngestionStage(value.to_owned())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IngestionRun {
pub digest: SourceDigest,
pub source_name: SourceFileName,
pub stage: IngestionStage,
pub note_path: Option<String>,
pub archive_path: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DocumentKind {
Pdf,
Html,
Image,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExtractionMethod {
PdfText,
PdfOcr,
HtmlPandoc,
ImageOcr,
}
impl ExtractionMethod {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PdfText => "pdf_text",
Self::PdfOcr => "pdf_ocr",
Self::HtmlPandoc => "html_pandoc",
Self::ImageOcr => "image_ocr",
}
}
}
impl FromStr for ExtractionMethod {
type Err = DomainError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"pdf_text" => Ok(Self::PdfText),
"pdf_ocr" => Ok(Self::PdfOcr),
"html_pandoc" => Ok(Self::HtmlPandoc),
"image_ocr" => Ok(Self::ImageOcr),
_ => Err(DomainError::InvalidExtractionMethod(value.to_owned())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StagedSource {
path: PathBuf,
original_path: PathBuf,
digest: SourceDigest,
source_name: SourceFileName,
kind: DocumentKind,
}
impl StagedSource {
#[must_use]
pub fn new(
path: PathBuf,
digest: SourceDigest,
source_name: SourceFileName,
kind: DocumentKind,
) -> Self {
Self {
original_path: path.clone(),
path,
digest,
source_name,
kind,
}
}
#[must_use]
pub fn with_original(
path: PathBuf,
original_path: PathBuf,
digest: SourceDigest,
source_name: SourceFileName,
kind: DocumentKind,
) -> Self {
Self {
path,
original_path,
digest,
source_name,
kind,
}
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn original_path(&self) -> &Path {
&self.original_path
}
#[must_use]
pub fn digest(&self) -> &SourceDigest {
&self.digest
}
#[must_use]
pub fn source_name(&self) -> &SourceFileName {
&self.source_name
}
#[must_use]
pub const fn kind(&self) -> DocumentKind {
self.kind
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractedDocument {
source: StagedSource,
text: String,
method: ExtractionMethod,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocumentClassification {
pub title: String,
pub authors: Vec<String>,
pub source_type: String,
pub language: String,
pub topics: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClassificationRecord {
pub source_digest: SourceDigest,
pub source_name: SourceFileName,
pub extraction_method: ExtractionMethod,
pub classification: DocumentClassification,
pub analysis: AnalysisProvenance,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvidenceReference {
pub quote: String,
pub location: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResearchDraft {
pub title: String,
pub citation: String,
pub topics: Vec<String>,
pub summary: String,
pub key_ideas: Vec<String>,
pub implementation_notes: Vec<String>,
pub critique: String,
pub evidence: Vec<EvidenceReference>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnalysisProvenance {
pub function: String,
pub client: String,
pub model: String,
pub pipeline_version: String,
pub processed_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResearchNote {
pub draft: ResearchDraft,
pub source_digest: SourceDigest,
pub source_name: SourceFileName,
pub extraction_method: ExtractionMethod,
pub analysis: AnalysisProvenance,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredNote {
pub relative_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchivedSource {
pub relative_path: PathBuf,
}
impl ResearchDraft {
pub fn validate(self) -> Result<Self, DomainError> {
let required_text = [
self.title.as_str(),
self.citation.as_str(),
self.summary.as_str(),
self.critique.as_str(),
];
let evidence_valid = !self.evidence.is_empty()
&& self.evidence.iter().all(|reference| {
!reference.quote.trim().is_empty() && !reference.location.trim().is_empty()
});
if required_text.iter().any(|value| value.trim().is_empty()) || !evidence_valid {
return Err(DomainError::InvalidResearchDraft);
}
Ok(self)
}
pub fn validate_against(self, source_text: &str) -> Result<Self, DomainError> {
let draft = self.validate()?;
let normalized_source = normalize_whitespace(source_text);
if draft.evidence.iter().any(|reference| {
let quote = normalize_whitespace(&reference.quote);
quote.is_empty() || !normalized_source.contains("e)
}) {
return Err(DomainError::UngroundedEvidence);
}
Ok(draft)
}
}
impl DocumentClassification {
pub fn validate(self) -> Result<Self, DomainError> {
let scalar_fields_valid = valid_metadata(&self.title, 512)
&& valid_metadata(&self.source_type, 128)
&& valid_metadata(&self.language, 64);
let lists_valid = self.authors.len() <= 64
&& !self.topics.is_empty()
&& self.topics.len() <= 64
&& self
.authors
.iter()
.all(|author| valid_metadata(author, 256))
&& self.topics.iter().all(|topic| valid_metadata(topic, 128));
if !scalar_fields_valid || !lists_valid {
return Err(DomainError::InvalidClassification);
}
Ok(self)
}
}
impl ExtractedDocument {
pub fn new(
source: StagedSource,
text: impl Into<String>,
method: ExtractionMethod,
) -> Result<Self, DomainError> {
let text = text.into();
if text.trim().is_empty() {
return Err(DomainError::EmptyExtraction);
}
Ok(Self {
source,
text,
method,
})
}
#[must_use]
pub fn source(&self) -> &StagedSource {
&self.source
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub const fn method(&self) -> ExtractionMethod {
self.method
}
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum DomainError {
#[error("source digest must be 64 lowercase hexadecimal characters")]
InvalidSourceDigest,
#[error("unsafe source filename: {0}")]
UnsafeSourceFileName(String),
#[error("invalid ingestion stage: {0}")]
InvalidIngestionStage(String),
#[error("document extraction produced no text")]
EmptyExtraction,
#[error("invalid extraction method: {0}")]
InvalidExtractionMethod(String),
#[error("research draft must contain required prose and grounded evidence")]
InvalidResearchDraft,
#[error("document classification contains invalid or excessive metadata")]
InvalidClassification,
#[error("research draft contains evidence absent from the source")]
UngroundedEvidence,
}
fn normalize_whitespace(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn valid_metadata(value: &str, maximum_characters: usize) -> bool {
!value.trim().is_empty()
&& value.chars().count() <= maximum_characters
&& !value.chars().any(char::is_control)
}