feat(stage6): source independence & citation graph — detect syndication, shared origins, text similarity

- SourceIndependenceModel: per-source independence_score (0.0-1.0), syndication_group_id, primary_source_id, content_hash, shared_urls
- CitationGraphEdgeModel: directed edges (SYNDICATED, QUOTES, LINKS_TO, REPOST, SIMILAR_CONTENT) with confidence + evidence
- Content-Hash (SHA-256): instant syndication detection for identical content
- difflib Vorfilterung: >60% → LLM, >80% → high confidence, 100% → immediate syndication
- LLM-Pairwise-Analysis: two-text-comparison for suspicious pairs only (bounded concurrency)
- independence_score: 1.0 base, -0.4 for syndicated, -0.1 per high-similarity pair
- Pydantic schemas: SourceIndependenceScore, CitationGraphEdge, SyntacticSimilarityResult, LlmSyndicationAnalysis, SourceIndependenceAnalysisResult
- LLM response parser: handles JSON, markdown code blocks, partial/invalid JSON
- 43 tests: content hash, similarity thresholds, LLM parsing, analyzer integration, edge cases, prompt templates
This commit is contained in:
NSCT Agent
2026-08-23 18:47:24 +00:00
parent 27e494d161
commit a60cf21a2c
4 changed files with 1234 additions and 1 deletions

View File

@@ -0,0 +1,172 @@
"""Pydantic v2 schemas — Source Independence & Citation Graph (Stage 6).
Jede Quelle erhält einen independence_score (0.01.0).
Quellen mit gemeinsamem Ursprung werden im Citation Graph verknüpft.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from enum import Enum
from typing import Any
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
class CitationEdgeType(str, Enum):
"""Kanten-Typen im Source-Citation-Graph (Stage 6)."""
SYNDICATED = "syndicated"
QUOTES = "quotes"
LINKS_TO = "links_to"
REPOST = "repost"
SIMILAR_CONTENT = "similar_content"
class SyndicationDirection(str, Enum):
"""Richtung der Syndication zwischen zwei Quellen."""
SOURCE_B = "B->A"
SOURCE_A = "A->B"
NONE = "none"
class SourceIndependenceScore(BaseModel):
"""Independence-Score für eine einzelne Quelle."""
source_id: UUID = Field(..., description="UUID der Quelle.")
independence_score: float = Field(
..., ge=0.0, le=1.0, description="Unabhängigkeits-Score (0.0=Duplikat, 1.0=vollständig unabhängig)"
)
syndication_group_id: UUID | None = Field(
default=None, description="Gruppe syndizierter Quellen."
)
primary_source_id: UUID | None = Field(
default=None, description="Primärquelle wenn diese Quelle syndiziert wurde."
)
content_hash: str | None = Field(
default=None, description="SHA-256 Hash des Inhalts."
)
text_similarity_high_count: int = Field(
default=0, description="Anzahl Quellen mit >80% Text-Ähnlichkeit."
)
shared_urls: list[str] = Field(
default_factory=list, description="URLs die auf andere Quellen verweisen."
)
similarity_pairs: list[dict[str, Any]] = Field(
default_factory=list, description="Paare mit >60% Similarität und LLM-Ergebnis."
)
class CitationGraphEdge(BaseModel):
"""Gerichtete Kante zwischen zwei Quellen."""
id: UUID = Field(default_factory=uuid4)
source_id: UUID = Field(..., description="Quelle die die Beziehung aufweist.")
target_source_id: UUID = Field(..., description="Quelle die referenziert wird.")
edge_type: CitationEdgeType = Field(..., description="Typ der Beziehung.")
confidence: float = Field(..., ge=0.0, le=1.0, description="Vertrauen in die Kante.")
evidence: dict[str, Any] = Field(
default_factory=dict,
description="Begründung, shared_urls, similarity_score",
)
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
@field_validator("evidence", mode="before")
@classmethod
def _ensure_dict(cls, v):
if isinstance(v, str):
try:
return json.loads(v)
except (json.JSONDecodeError, TypeError):
return {"reason": v}
if v is None:
return {"reason": ""}
return v
class SyntacticSimilarityResult(BaseModel):
"""Ergebnis einer textuellen Ähnlichkeitsanalyse."""
source_a: UUID
source_b: UUID
similarity_ratio: float = Field(ge=0.0, le=1.0)
identical_hash: bool = False
llm_result: dict[str, Any] | None = None
edge_type: CitationEdgeType | None = None
confidence: float = 0.0
class SourceIndependenceAnalysisResult(BaseModel):
"""Gesamtes Ergebnis der Source Independence Analyse."""
research_run_id: UUID = Field(default_factory=uuid4)
source_scores: list[SourceIndependenceScore] = Field(default_factory=list)
citation_edges: list[CitationGraphEdge] = Field(default_factory=list)
syndication_groups: dict[str, list[UUID]] = Field(default_factory=dict)
total_sources: int = 0
unique_sources: int = 0
llm_calls_made: int = 0
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# ---------------------------------------------------------------------------
# LLM-Parsing-Helfer
# ---------------------------------------------------------------------------
class LlmSyndicationAnalysis(BaseModel):
"""LLM-Antwort für Syndication-Analyse (Zwei-Text-Vergleich)."""
syndicated: bool = Field(default=False)
syndication_direction: str = Field(default="none")
similarity_score: float = Field(default=0.0, ge=0.0, le=1.0)
shared_urls: list[str] = Field(default_factory=list)
common_origins: list[str] = Field(default_factory=list)
confidence: float = Field(default=0.5, ge=0.0, le=1.0)
reason: str = Field(default="", description="Kurze Begründung.")
@field_validator("syndication_direction", mode="before")
@classmethod
def _validate_direction(cls, v):
if v is None or not isinstance(v, str):
return "none"
return v.strip()
def parse_llm_syndication_response(raw_text: str) -> LlmSyndicationAnalysis:
"""Parst eine LLM-Antwort (JSON) für Syndication-Analyse."""
try:
text = raw_text.strip()
# Extract JSON from possible markdown code blocks
if "```" in text:
for block in text.split("```"):
block = block.strip()
if block.startswith("json"):
block = block[4:].strip()
try:
data = json.loads(block)
break
except json.JSONDecodeError:
continue
else:
# Try entire block as JSON
data = json.loads(text)
else:
data = json.loads(text)
except (json.JSONDecodeError, ValueError):
data = {}
return LlmSyndicationAnalysis(
syndicated=data.get("syndicated", False),
syndication_direction=data.get("syndication_direction", "none"),
similarity_score=float(data.get("similarity_score", 0.0)),
shared_urls=data.get("shared_urls", []),
common_origins=data.get("common_origins", []),
confidence=float(data.get("confidence", 0.5)),
reason=data.get("reason", ""),
)