Skip to content

Commit c7cd791

Browse files
committed
Add dedupe skill
1 parent efe2d3e commit c7cd791

27 files changed

Lines changed: 1223 additions & 43 deletions

‎core/admin.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,38 @@ def queryset(self, request, queryset):
238238
return queryset
239239

240240

241+
class DuplicateStateFilter(admin.SimpleListFilter):
242+
"""Filter content by duplicate retention and suppression state."""
243+
244+
title = "Duplicate State"
245+
parameter_name = "duplicate_state"
246+
247+
def lookups(self, request, model_admin):
248+
"""Return duplicate-state options displayed in the admin sidebar."""
249+
250+
return (
251+
("canonical_with_duplicates", "Canonical rows with duplicate signals"),
252+
("suppressed_duplicates", "Suppressed duplicate rows"),
253+
)
254+
255+
def queryset(self, request, queryset):
256+
"""Apply the selected duplicate-state filter."""
257+
258+
if self.value() == "canonical_with_duplicates":
259+
return queryset.filter(duplicate_signal_count__gt=0)
260+
if self.value() == "suppressed_duplicates":
261+
return queryset.filter(duplicate_of__isnull=False)
262+
return queryset
263+
264+
241265
@admin.register(Content)
242266
class ContentAdmin(admin.ModelAdmin):
243267
"""Admin view for curated content plus trace and score context."""
244268

245269
list_display = (
246270
"display_relevance",
271+
"duplicate_badge",
272+
"duplicate_parent",
247273
"is_active",
248274
"is_reference",
249275
"preview_content",
@@ -255,6 +281,7 @@ class ContentAdmin(admin.ModelAdmin):
255281
list_editable = ("is_reference", "is_active")
256282
list_filter = (
257283
HighValueFilter,
284+
DuplicateStateFilter,
258285
("project", admin.RelatedOnlyFieldListFilter),
259286
"source_plugin",
260287
"is_active",
@@ -377,6 +404,25 @@ def display_relevance(self, obj):
377404
)
378405
return format_html('<b style="color: {};">{}%</b>', color, obj.relevance_score)
379406

407+
@admin.display(description="Duplicates", ordering="duplicate_signal_count")
408+
def duplicate_badge(self, obj):
409+
"""Show how many duplicate sightings point at this content row."""
410+
411+
if obj.duplicate_signal_count <= 0:
412+
return "-"
413+
return format_html(
414+
'<span style="font-weight: bold; color: #0f766e;">Also seen in {} source(s)</span>',
415+
obj.duplicate_signal_count,
416+
)
417+
418+
@admin.display(description="Duplicate Of", ordering="duplicate_of")
419+
def duplicate_parent(self, obj):
420+
"""Show the retained canonical content row when this item is a duplicate."""
421+
422+
if obj.duplicate_of is None:
423+
return "-"
424+
return obj.duplicate_of.title
425+
380426
def changelist_view(self, request, extra_context=None):
381427
"""Augment the changelist with content dashboard statistics."""
382428

‎core/api.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,14 @@
177177
"entity": 4,
178178
"source_plugin": "rss",
179179
"content_type": "article",
180+
"canonical_url": "https://example.com/posts/agent-memory-patterns",
180181
"published_date": "2026-04-25T14:00:00Z",
181182
"ingested_at": "2026-04-26T12:05:00Z",
182183
"content_text": "A walkthrough of short-term and long-term memory patterns for production agents.",
183184
"relevance_score": 0.92,
184185
"embedding_id": "emb_01jabcxyz",
186+
"duplicate_of": None,
187+
"duplicate_signal_count": 2,
185188
"is_reference": False,
186189
"is_active": True,
187190
},

‎core/deduplication.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Helpers for canonical URL normalization used by content deduplication."""
2+
3+
from __future__ import annotations
4+
5+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
6+
7+
import httpx
8+
9+
TRACKING_QUERY_KEYS = frozenset(
10+
{
11+
"fbclid",
12+
"gclid",
13+
"mc_cid",
14+
"mc_eid",
15+
"ref",
16+
"ref_src",
17+
"s",
18+
"t",
19+
}
20+
)
21+
KNOWN_SHORTENER_HOSTS = frozenset({"bit.ly", "buff.ly", "lnkd.in", "t.co"})
22+
23+
24+
def canonicalize_url(raw_url: str) -> str:
25+
"""Normalize a URL into a stable canonical form for deduplication."""
26+
27+
if not raw_url:
28+
return ""
29+
30+
resolved_url = _resolve_known_shortener(raw_url.strip())
31+
parsed_url = urlsplit(resolved_url)
32+
scheme = (parsed_url.scheme or "https").lower()
33+
hostname = (parsed_url.hostname or "").lower()
34+
if hostname.startswith("www."):
35+
hostname = hostname[4:]
36+
37+
netloc = hostname
38+
if parsed_url.port and not _is_default_port(scheme, parsed_url.port):
39+
netloc = f"{hostname}:{parsed_url.port}"
40+
41+
path = parsed_url.path or "/"
42+
if path != "/":
43+
path = path.rstrip("/") or "/"
44+
45+
filtered_query = urlencode(
46+
[
47+
(key, value)
48+
for key, value in parse_qsl(parsed_url.query, keep_blank_values=True)
49+
if not _should_drop_query_parameter(key)
50+
],
51+
doseq=True,
52+
)
53+
54+
return urlunsplit((scheme, netloc, path, filtered_query, ""))
55+
56+
57+
def _resolve_known_shortener(raw_url: str) -> str:
58+
"""Expand a supported short URL when the network request succeeds."""
59+
60+
hostname = (urlsplit(raw_url).hostname or "").lower()
61+
if hostname not in KNOWN_SHORTENER_HOSTS:
62+
return raw_url
63+
64+
try:
65+
response = httpx.head(raw_url, follow_redirects=True, timeout=5.0)
66+
response.raise_for_status()
67+
except httpx.HTTPError:
68+
return raw_url
69+
return str(response.url)
70+
71+
72+
def _should_drop_query_parameter(key: str) -> bool:
73+
normalized_key = key.lower()
74+
return normalized_key.startswith("utm_") or normalized_key in TRACKING_QUERY_KEYS
75+
76+
77+
def _is_default_port(scheme: str, port: int) -> bool:
78+
return (scheme == "http" and port == 80) or (scheme == "https" and port == 443)

‎core/llm.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
import re
55
import time
66
from dataclasses import dataclass
7+
from functools import lru_cache
8+
from pathlib import Path
79
from typing import Any
810

911
import httpx
12+
import markdown # type: ignore[import-untyped]
1013
from django.conf import settings
1114

1215
JSON_OBJECT_PATTERN = re.compile(r"\{.*\}", re.DOTALL)
@@ -19,6 +22,17 @@ class OpenRouterJSONResponse:
1922
latency_ms: int
2023

2124

25+
@dataclass(slots=True)
26+
class SkillDefinition:
27+
"""Represents one Claude-style skill markdown document."""
28+
29+
name: str
30+
input_fields: tuple[str, ...]
31+
output_fields: tuple[str, ...]
32+
instructions_markdown: str
33+
instructions_html: str
34+
35+
2236
def openrouter_chat_json(
2337
*, model: str, system_prompt: str, user_prompt: str
2438
) -> OpenRouterJSONResponse:
@@ -62,6 +76,42 @@ def openrouter_chat_json(
6276
)
6377

6478

79+
@lru_cache(maxsize=16)
80+
def get_skill_definition(skill_name: str) -> SkillDefinition:
81+
"""Load a skill definition from the repository skill markdown directory."""
82+
83+
skill_path = Path(__file__).resolve().parent.parent / "skills" / skill_name / "SKILL.md"
84+
raw_text = skill_path.read_text(encoding="utf-8")
85+
frontmatter, body = _split_frontmatter(raw_text)
86+
name = frontmatter.get("name", skill_name).strip() or skill_name
87+
input_fields = _csv_field_list(frontmatter.get("input", ""))
88+
output_fields = _csv_field_list(frontmatter.get("output", ""))
89+
instructions_markdown = body.strip()
90+
return SkillDefinition(
91+
name=name,
92+
input_fields=input_fields,
93+
output_fields=output_fields,
94+
instructions_markdown=instructions_markdown,
95+
instructions_html=markdown.markdown(instructions_markdown),
96+
)
97+
98+
99+
def build_skill_user_prompt(skill_name: str, inputs: dict[str, Any]) -> str:
100+
"""Render a consistent user prompt from a skill's declared input fields."""
101+
102+
skill = get_skill_definition(skill_name)
103+
sections = []
104+
for field_name in skill.input_fields:
105+
value = inputs.get(field_name, "")
106+
sections.append(f"{field_name}:\n{_stringify_skill_input(value)}")
107+
if skill.output_fields:
108+
sections.append(
109+
"Return only a JSON object with these fields: "
110+
+ ", ".join(skill.output_fields)
111+
)
112+
return "\n\n".join(sections)
113+
114+
65115
def _extract_json_object(message_content: str) -> dict[str, Any]:
66116
try:
67117
payload = json.loads(message_content)
@@ -73,3 +123,35 @@ def _extract_json_object(message_content: str) -> dict[str, Any]:
73123
if not isinstance(payload, dict):
74124
raise ValueError("Model response JSON must be an object.")
75125
return payload
126+
127+
128+
def _split_frontmatter(raw_text: str) -> tuple[dict[str, str], str]:
129+
"""Split a skill markdown document into simple frontmatter and body."""
130+
131+
if not raw_text.startswith("---\n"):
132+
return {}, raw_text
133+
_, _, remainder = raw_text.partition("\n")
134+
frontmatter_block, separator, body = remainder.partition("\n---\n")
135+
if not separator:
136+
return {}, raw_text
137+
frontmatter: dict[str, str] = {}
138+
for line in frontmatter_block.splitlines():
139+
if not line.strip() or ":" not in line:
140+
continue
141+
key, value = line.split(":", 1)
142+
frontmatter[key.strip()] = value.strip()
143+
return frontmatter, body
144+
145+
146+
def _csv_field_list(raw_value: str) -> tuple[str, ...]:
147+
"""Parse a comma-separated frontmatter field list."""
148+
149+
return tuple(part.strip() for part in raw_value.split(",") if part.strip())
150+
151+
152+
def _stringify_skill_input(value: Any) -> str:
153+
"""Serialize skill input values into prompt-safe text."""
154+
155+
if isinstance(value, (dict, list, tuple)):
156+
return json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True)
157+
return str(value)

‎core/management/commands/seed_demo.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from httpx import HTTPError
1313
from qdrant_client.http.exceptions import ResponseHandlingException
1414

15+
from core.deduplication import canonicalize_url
1516
from core.embeddings import upsert_content_embedding
1617
from core.models import (
1718
Content,
@@ -612,6 +613,7 @@ def _seed_articles(
612613
"author": article["author"],
613614
"entity": entities_by_name.get(article.get("entity_name", "")),
614615
"source_plugin": source_plugin or article["source_plugin"],
616+
"canonical_url": canonicalize_url(article["url"]),
615617
"published_date": now - timedelta(days=article["days_ago"]),
616618
"content_text": article["content_text"],
617619
"is_reference": is_reference,
@@ -887,10 +889,36 @@ def _build_reference_articles(self) -> list[dict[str, Any]]:
887889

888890
def _build_demo_content(self) -> list[dict[str, Any]]:
889891
articles = list(LEGACY_SAMPLE_CONTENT)
890-
articles.extend(self._build_generated_rss_content())
891-
articles.extend(self._build_generated_reddit_content())
892+
generated_rss = self._build_generated_rss_content()
893+
generated_reddit = self._build_generated_reddit_content()
894+
self._inject_duplicate_variants(articles, generated_rss, generated_reddit)
895+
articles.extend(generated_rss)
896+
articles.extend(generated_reddit)
892897
return articles
893898

899+
@staticmethod
900+
def _inject_duplicate_variants(
901+
legacy_articles: list[dict[str, Any]],
902+
generated_rss: list[dict[str, Any]],
903+
generated_reddit: list[dict[str, Any]],
904+
) -> None:
905+
duplicate_pairs = [
906+
(legacy_articles[0], generated_reddit[0], "reddit"),
907+
(generated_rss[2], generated_reddit[1], "community"),
908+
(generated_rss[9], generated_reddit[2], "social"),
909+
]
910+
for base_article, duplicate_article, source_tag in duplicate_pairs:
911+
duplicate_article["url"] = (
912+
f"{base_article['url']}?utm_source={source_tag}&ref=seed-demo"
913+
)
914+
duplicate_article["title"] = (
915+
f"{duplicate_article['title']} linking to {base_article['title']}"
916+
)
917+
duplicate_article["content_text"] = (
918+
f"This seeded item points readers to the same underlying article as '{base_article['title']}'. "
919+
f"{duplicate_article['content_text']}"
920+
)
921+
894922
def _build_generated_rss_content(self) -> list[dict[str, Any]]:
895923
articles = []
896924
for index in range(147):
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 6.0.4 on 2026-04-29 01:33
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("core", "0004_blueskycredentials"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="content",
16+
name="canonical_url",
17+
field=models.URLField(blank=True, db_index=True, default=""),
18+
),
19+
migrations.AddField(
20+
model_name="content",
21+
name="duplicate_of",
22+
field=models.ForeignKey(
23+
blank=True,
24+
null=True,
25+
on_delete=django.db.models.deletion.SET_NULL,
26+
related_name="duplicates",
27+
to="core.content",
28+
),
29+
),
30+
migrations.AddField(
31+
model_name="content",
32+
name="duplicate_signal_count",
33+
field=models.IntegerField(default=0),
34+
),
35+
]

0 commit comments

Comments
 (0)