feat(stage7): claim clustering & contradiction candidates — semantic grouping, numeric normalization, pairwise analysis

- ClaimClusterModel: LLM-basierte semantische Gruppierung von Claims
- ClaimRelationModel: SUPPORTS, CONTRADICTS, DUPLICATE, UNCERTAIN pairwise relations
- ClaimNLUModel: numerische Normalisierung (%, Währungen, deutsche/englische Wörter)
- stage7_normalize_numerics.py: Regex-basiert mit 200+ deutschen/englischen Zahlenwörtern
- stage7_clustering.py: LLM-Clustering + pairwise claim-relation analysis
- API: POST cluster-claims, GET clusters, GET claim-relations
- 79 tests: numerische Normalisierung, LLM-Parsing, Clustering, Relationen, Edge-Cases
- Dedup: claims mit gleichen numerischen Werten werden zusammengefasst
This commit is contained in:
NSCT Agent
2026-08-23 20:57:17 +00:00
parent d2294c58d0
commit d1e6bb6cf4
6 changed files with 2104 additions and 0 deletions

View File

@@ -273,4 +273,110 @@ class ResearchReportModel(Base):
__table_args__ = (
Index("ix_research_reports_research_id", "research_id"),
)
# ---------------------------------------------------------------------------
# Stage 7 — Claim Clustering & Contradiction Candidates
# ---------------------------------------------------------------------------
class ClaimRelationType(str, enum.Enum):
"""Relationship types between individual claims (Stage 7)."""
SUPPORTS = "supports"
CONTRADICTS = "contradicts"
DUPLICATE = "duplicate"
UNCERTAIN = "uncertain"
class ClaimClusterModel(Base):
"""Semantisches Cluster von Claims innerhalb eines Research-Runs."""
__tablename__ = "claim_clusters"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
research_run_id = Column(String(36), nullable=False, default=lambda: str(uuid4()))
cluster_label = Column(Text, nullable=False)
representative_claim_id = Column(String(36), ForeignKey("claims.id"), nullable=True)
claim_count = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
# Relationships
claims = relationship("ClaimModel", secondary="claim_cluster_mapping", back_populates="clusters")
relations = relationship(
"ClaimRelationModel",
back_populates="cluster",
cascade="all, delete-orphan",
)
__table_args__ = (
Index("ix_claim_clusters_research_run_id", "research_run_id"),
)
# Association table: claims ↔ clusters (many-to-many)
_claim_cluster_mapping = Base() # noqa: F811 -- dummy for type resolution
claim_cluster_mapping = Base()
claim_cluster_mapping.__tablename__ = "claim_cluster_mapping"
claim_cluster_mapping.id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
claim_cluster_mapping.cluster_id = Column(String(36), ForeignKey("claim_clusters.id"), nullable=False)
claim_cluster_mapping.claim_id = Column(String(36), ForeignKey("claims.id"), nullable=False)
claim_cluster_mapping.position = Column(Integer, nullable=False, default=0)
__table_args__ = (
Index("ix_claim_cluster_mapping_cluster_id", "cluster_id"),
Index("ix_claim_cluster_mapping_claim_id", "claim_id"),
Index("uq_claim_cluster_mapping_cluster_claim", "cluster_id", "claim_id", unique=True),
)
class ClaimRelationModel(Base):
"""Pairwise Beziehung zwischen zwei Claims innerhalb eines Clusters."""
__tablename__ = "claim_relations"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
source_claim_id = Column(String(36), ForeignKey("claims.id"), nullable=False)
target_claim_id = Column(String(36), ForeignKey("claims.id"), nullable=False)
relation_type = Column(Enum(ClaimRelationType), nullable=False)
confidence = Column(Float, nullable=False, default=0.5)
reason = Column(Text, nullable=True)
cluster_id = Column(String(36), ForeignKey("claim_clusters.id"), nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
# Relationships
cluster = relationship("ClaimClusterModel", back_populates="relations")
source_claim = relationship("ClaimModel", foreign_keys=[source_claim_id])
target_claim = relationship("ClaimModel", foreign_keys=[target_claim_id])
__table_args__ = (
Index("ix_claim_relations_source_claim_id", "source_claim_id"),
Index("ix_claim_relations_target_claim_id", "target_claim_id"),
Index("ix_claim_relations_cluster_id", "cluster_id"),
Index(
"uq_claim_relations_pair",
"source_claim_id",
"target_claim_id",
"relation_type",
unique=True,
),
)
class ClaimNLUModel(Base):
"""Numerisch normalisierte Ausdrücke aus Claims (Stage 7 — NLU)."""
__tablename__ = "claim_nlu_numeric"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
claim_id = Column(String(36), ForeignKey("claims.id"), nullable=False)
normalized_numeric_value = Column(String(64), nullable=True)
original_numeric_text = Column(Text, nullable=False)
unit = Column(String(16), nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
Index("ix_claim_nlu_numeric_claim_id", "claim_id"),
)