use serde::{Deserialize, Serialize};
use super::{build_dimension, AiSignal, DimensionScore};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeGraphAnalysis {
pub dimension: DimensionScore,
pub entities: Vec<GraphEntity>,
pub relationships: Vec<EntityRelationship>,
pub link_suggestions: Vec<LinkSuggestion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphEntity {
pub name: String,
pub entity_type: String,
pub source: EntitySource,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntitySource {
SchemaOrg,
Heading,
Meta,
}
impl std::fmt::Display for EntitySource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EntitySource::SchemaOrg => write!(f, "Schema.org"),
EntitySource::Heading => write!(f, "Heading"),
EntitySource::Meta => write!(f, "Meta"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityRelationship {
pub subject: String,
pub predicate: String,
pub object: String,
pub source: EntitySource,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkSuggestion {
pub entity: String,
pub reason: String,
}
pub(crate) struct KnowledgeGraphInput {
pub schemas: Vec<SchemaEntity>,
pub page_title: Option<String>,
pub site_name: Option<String>,
pub internal_links: u32,
pub has_breadcrumb: bool,
}
pub(crate) struct SchemaEntity {
pub schema_type: String,
pub name: Option<String>,
pub properties: Vec<(String, String)>,
}
pub(crate) fn analyze_knowledge_graph(input: &KnowledgeGraphInput) -> KnowledgeGraphAnalysis {
let mut entities = Vec::new();
let mut relationships = Vec::new();
let mut link_suggestions = Vec::new();
for schema in &input.schemas {
if let Some(name) = &schema.name {
entities.push(GraphEntity {
name: name.clone(),
entity_type: schema.schema_type.clone(),
source: EntitySource::SchemaOrg,
properties: schema.properties.clone(),
});
}
}
if let Some(title) = &input.page_title {
if !title.is_empty() && !entities.iter().any(|e| e.name == *title) {
entities.push(GraphEntity {
name: title.clone(),
entity_type: "WebPage".into(),
source: EntitySource::Meta,
properties: vec![],
});
}
}
if let Some(site) = &input.site_name {
if !site.is_empty() && !entities.iter().any(|e| e.name == *site) {
entities.push(GraphEntity {
name: site.clone(),
entity_type: "WebSite".into(),
source: EntitySource::Meta,
properties: vec![],
});
}
}
build_schema_relationships(&entities, &input.schemas, &mut relationships);
if let (Some(title), Some(site)) = (&input.page_title, &input.site_name) {
if !title.is_empty() && !site.is_empty() {
relationships.push(EntityRelationship {
subject: title.clone(),
predicate: "isPartOf".into(),
object: site.clone(),
source: EntitySource::Meta,
});
}
}
if input.has_breadcrumb {
if let Some(title) = &input.page_title {
relationships.push(EntityRelationship {
subject: title.clone(),
predicate: "breadcrumbPath".into(),
object: "BreadcrumbList".into(),
source: EntitySource::SchemaOrg,
});
}
}
if entities.len() > 1 && input.internal_links < 5 {
link_suggestions.push(LinkSuggestion {
entity: "Seite".into(),
reason: format!(
"Nur {} interne Links — Entitäten sollten untereinander verlinkt werden",
input.internal_links
),
});
}
for entity in &entities {
if entity.source == EntitySource::Heading && entity.entity_type == "Topic" {
link_suggestions.push(LinkSuggestion {
entity: entity.name.clone(),
reason: format!(
"Thema '{}' nur als Überschrift erkannt — Schema.org-Markup oder interne Verlinkung empfohlen",
entity.name
),
});
}
}
let mut signals = Vec::new();
let good_entity_count = entities.len() >= 3;
signals.push(AiSignal {
name: "Entitäten erkannt".into(),
present: good_entity_count,
weight: 0.20,
detail: format!(
"{} Entitäten extrahiert — {}",
entities.len(),
if good_entity_count {
"reichhaltiges Wissensmodell"
} else {
"wenig maschinenlesbare Entitäten"
}
),
});
let schema_entities = entities
.iter()
.filter(|e| e.source == EntitySource::SchemaOrg)
.count();
let good_schema_ratio = schema_entities >= 2;
signals.push(AiSignal {
name: "Schema.org-Entitäten".into(),
present: good_schema_ratio,
weight: 0.20,
detail: format!(
"{} Entitäten aus Schema.org — {}",
schema_entities,
if good_schema_ratio {
"gute Maschinenlesbarkeit"
} else {
"wenig strukturierte Entitäten"
}
),
});
let good_relationships = relationships.len() >= 2;
signals.push(AiSignal {
name: "Beziehungen".into(),
present: good_relationships,
weight: 0.20,
detail: format!(
"{} Beziehungen zwischen Entitäten — {}",
relationships.len(),
if good_relationships {
"Wissensnetz erkennbar"
} else {
"isolierte Entitäten, kein Netz"
}
),
});
let unique_types: std::collections::HashSet<&str> =
entities.iter().map(|e| e.entity_type.as_str()).collect();
let diverse = unique_types.len() >= 3;
signals.push(AiSignal {
name: "Typen-Vielfalt".into(),
present: diverse,
weight: 0.10,
detail: format!(
"{} verschiedene Entitätstypen — {}",
unique_types.len(),
if diverse {
"vielfältiges Wissensmodell"
} else {
"wenig Typen-Diversität"
}
),
});
signals.push(AiSignal {
name: "Breadcrumb-Hierarchie".into(),
present: input.has_breadcrumb,
weight: 0.10,
detail: if input.has_breadcrumb {
"Breadcrumb vorhanden — thematische Einordnung im Graph möglich".into()
} else {
"Kein Breadcrumb — thematische Einordnung fehlt".into()
},
});
let good_linking = input.internal_links >= 5;
signals.push(AiSignal {
name: "Verlinkungsdichte".into(),
present: good_linking,
weight: 0.10,
detail: format!(
"{} interne Links — {}",
input.internal_links,
if good_linking {
"gute Vernetzung im Graph"
} else {
"schwache Vernetzung"
}
),
});
let entities_with_props = entities.iter().filter(|e| !e.properties.is_empty()).count();
let good_props = entities_with_props >= 1 && schema_entities >= 1;
signals.push(AiSignal {
name: "Eigenschafts-Vollständigkeit".into(),
present: good_props,
weight: 0.10,
detail: format!(
"{}/{} Entitäten mit Eigenschaften — {}",
entities_with_props,
entities.len(),
if good_props {
"Entitäten sind beschrieben"
} else {
"Entitäten ohne Details"
}
),
});
KnowledgeGraphAnalysis {
dimension: build_dimension("Strukturierte Daten", &signals),
entities,
relationships,
link_suggestions,
}
}
fn build_schema_relationships(
entities: &[GraphEntity],
schemas: &[SchemaEntity],
relationships: &mut Vec<EntityRelationship>,
) {
for schema in schemas {
let subject = match &schema.name {
Some(n) => n.clone(),
None => continue,
};
for (key, value) in &schema.properties {
match key.as_str() {
"author" | "creator" => {
relationships.push(EntityRelationship {
subject: subject.clone(),
predicate: key.clone(),
object: value.clone(),
source: EntitySource::SchemaOrg,
});
}
"publisher" | "provider" => {
relationships.push(EntityRelationship {
subject: subject.clone(),
predicate: key.clone(),
object: value.clone(),
source: EntitySource::SchemaOrg,
});
}
"isPartOf" | "mainEntityOfPage" | "about" => {
relationships.push(EntityRelationship {
subject: subject.clone(),
predicate: key.clone(),
object: value.clone(),
source: EntitySource::SchemaOrg,
});
}
_ => {}
}
}
}
let org_entities: Vec<&GraphEntity> = entities
.iter()
.filter(|e| e.entity_type == "Organization" || e.entity_type == "LocalBusiness")
.collect();
let article_entities: Vec<&GraphEntity> = entities
.iter()
.filter(|e| {
e.entity_type == "Article"
|| e.entity_type == "BlogPosting"
|| e.entity_type == "NewsArticle"
})
.collect();
for article in &article_entities {
let has_publisher = relationships
.iter()
.any(|r| r.subject == article.name && r.predicate == "publisher");
if !has_publisher {
if let Some(org) = org_entities.first() {
relationships.push(EntityRelationship {
subject: article.name.clone(),
predicate: "publisher (inferred)".into(),
object: org.name.clone(),
source: EntitySource::SchemaOrg,
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rich_input() -> KnowledgeGraphInput {
KnowledgeGraphInput {
schemas: vec![
SchemaEntity {
schema_type: "Article".into(),
name: Some("Understanding Rust".into()),
properties: vec![
("author".into(), "Jane Doe".into()),
("publisher".into(), "TechBlog".into()),
],
},
SchemaEntity {
schema_type: "Organization".into(),
name: Some("TechBlog".into()),
properties: vec![("url".into(), "https://techblog.example".into())],
},
SchemaEntity {
schema_type: "Person".into(),
name: Some("Jane Doe".into()),
properties: vec![],
},
],
page_title: Some("Understanding Rust".into()),
site_name: Some("TechBlog".into()),
internal_links: 10,
has_breadcrumb: true,
}
}
fn minimal_input() -> KnowledgeGraphInput {
KnowledgeGraphInput {
schemas: vec![],
page_title: None,
site_name: None,
internal_links: 0,
has_breadcrumb: false,
}
}
#[test]
fn rich_input_produces_high_score() {
let result = analyze_knowledge_graph(&rich_input());
assert!(result.dimension.score >= 60);
assert!(!result.entities.is_empty());
assert!(!result.relationships.is_empty());
}
#[test]
fn minimal_input_produces_low_score() {
let result = analyze_knowledge_graph(&minimal_input());
assert_eq!(result.dimension.score, 0);
assert!(result.entities.is_empty());
assert!(result.relationships.is_empty());
assert!(result.link_suggestions.is_empty());
}
#[test]
fn page_and_site_titles_become_entities() {
let input = KnowledgeGraphInput {
schemas: vec![],
page_title: Some("My Page".into()),
site_name: Some("My Site".into()),
internal_links: 0,
has_breadcrumb: false,
};
let result = analyze_knowledge_graph(&input);
let names: Vec<&str> = result.entities.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"My Page"));
assert!(names.contains(&"My Site"));
assert!(result
.relationships
.iter()
.any(|r| r.predicate == "isPartOf"));
}
#[test]
fn low_internal_links_triggers_suggestion() {
let input = KnowledgeGraphInput {
schemas: vec![SchemaEntity {
schema_type: "Article".into(),
name: Some("Article One".into()),
properties: vec![],
}],
page_title: Some("Article One".into()),
site_name: Some("Blog".into()),
internal_links: 2,
has_breadcrumb: false,
};
let result = analyze_knowledge_graph(&input);
assert!(!result.link_suggestions.is_empty());
}
#[test]
fn breadcrumb_adds_relationship() {
let input = KnowledgeGraphInput {
schemas: vec![],
page_title: Some("Leaf Page".into()),
site_name: None,
internal_links: 5,
has_breadcrumb: true,
};
let result = analyze_knowledge_graph(&input);
assert!(result
.relationships
.iter()
.any(|r| r.predicate == "breadcrumbPath"));
}
}