Remove knowledge graph feature

The knowledge graph feature (links graph) was not functioning correctly
and was slowing down the application. This commit removes it entirely:

- Delete knowledge_graph_urls.py, knowledge_graph_views.py, llm_client.py
- Delete knowledge_graph.html template
- Remove KnowledgeGraphSnapshot model and all llm_*/kg_* fields from
  SiteSettings (migration 0048)
- Remove build_knowledge_graph() and schedule_kg_build() from tasks.py
- Remove KG settings save logic and bulk delete action from views.py
- Remove knowledge_graph_urls include from links/urls.py
- Remove schedule_kg_build scheduler job from core/apps.py
- Remove KG snapshot job registry from links/apps.py
- Remove Knowledge Graph nav item from base.html
- Remove KG settings cards and llmSettingsHelper JS from settings.html
This commit is contained in:
2026-04-14 12:04:44 +10:00
parent bf11b675e8
commit 049d417cc8
13 changed files with 59 additions and 1893 deletions
+1 -12
View File
@@ -18,7 +18,7 @@ class CoreConfig(AppConfig):
from core.scheduler import scheduler, start_scheduler
from links.tasks import (
schedule_pending_pages, schedule_pending_screenshots,
retry_stuck_image_imports, flush_click_buffer, schedule_kg_build,
retry_stuck_image_imports, flush_click_buffer,
)
from apscheduler.triggers.interval import IntervalTrigger
@@ -31,11 +31,9 @@ class CoreConfig(AppConfig):
ss = SiteSettings.get()
pages_interval = ss.schedule_pending_pages_interval or 120
screenshots_interval = ss.schedule_pending_screenshots_interval or 120
kg_interval = ss.kg_auto_schedule_interval or 3600
except Exception:
pages_interval = 120
screenshots_interval = 120
kg_interval = 3600
# Add periodic job for checking pending pages
scheduler.add_job(
@@ -72,12 +70,3 @@ class CoreConfig(AppConfig):
replace_existing=True,
)
logger.info("Scheduled periodic task: flush_click_buffer (every 60s)")
# Add periodic job for knowledge graph auto-build
scheduler.add_job(
schedule_kg_build,
trigger=IntervalTrigger(seconds=kg_interval),
id='schedule_kg_build',
replace_existing=True,
)
logger.info(f"Scheduled periodic task: schedule_kg_build (every {kg_interval}s)")
-72
View File
@@ -181,75 +181,3 @@ class LinksConfig(AppConfig):
},
})
# ── Knowledge Graph Snapshots ────────────────────────────────────
from .models import KnowledgeGraphSnapshot
def kg_stats():
return {
'total': KnowledgeGraphSnapshot.objects.count(),
'processing': KnowledgeGraphSnapshot.objects.filter(
status=KnowledgeGraphSnapshot.Status.BUILDING
).count(),
'completed': KnowledgeGraphSnapshot.objects.filter(
status=KnowledgeGraphSnapshot.Status.READY
).count(),
'failed': KnowledgeGraphSnapshot.objects.filter(
status=KnowledgeGraphSnapshot.Status.FAILED
).count(),
}
def kg_queryset(sf):
qs = KnowledgeGraphSnapshot.objects.order_by('-created_at')
if sf == 'processing':
return qs.filter(status=KnowledgeGraphSnapshot.Status.BUILDING)
if sf == 'completed':
return qs.filter(status=KnowledgeGraphSnapshot.Status.READY)
if sf == 'failed':
return qs.filter(status=KnowledgeGraphSnapshot.Status.FAILED)
return qs
def kg_serialize(obj):
if obj.status == KnowledgeGraphSnapshot.Status.BUILDING:
pct = obj.progress_data.get('pct', 0) if isinstance(obj.progress_data, dict) else 0
title = f'Building… {pct}%'
else:
title = f'{obj.node_count} nodes / {obj.edge_count} edges'
if obj.used_llm:
title += ' (LLM)'
return {
'id': str(obj.id),
'title': title,
'detail_url': reverse('knowledge-graph'),
'status': obj.status,
'retry': None,
'retry_max': None,
'error': obj.error_message or '',
'updated_at': obj.completed_at or obj.created_at,
'extra': {
'duration': (
f'{obj.build_duration_ms // 1000}s' if obj.build_duration_ms else ''
),
},
}
job_registry.register({
'id': 'knowledge_graph',
'label': 'Knowledge Graph',
'icon_color': 'text-pink-500',
'icon_path': (
'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101'
'm-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1'
),
'title_label': 'Snapshot',
'status_choices': [
('all', 'All'), ('processing', 'Building'), ('completed', 'Ready'),
('failed', 'Failed'),
],
'columns': ['id', 'title', 'status', 'error', 'updated'],
'get_stats': kg_stats,
'get_queryset': kg_queryset,
'serialize': kg_serialize,
'bulk_actions': {
'delete': 'bulk_delete_knowledge_graph_snapshots',
},
})
-30
View File
@@ -1,30 +0,0 @@
from django.urls import path
from . import knowledge_graph_views
urlpatterns = [
path(
"ui/knowledge-graph/",
knowledge_graph_views.KnowledgeGraphPageView.as_view(),
name="knowledge-graph",
),
path(
"api/knowledge-graph/data/",
knowledge_graph_views.KnowledgeGraphDataView.as_view(),
name="knowledge-graph-data",
),
path(
"api/knowledge-graph/build/",
knowledge_graph_views.KnowledgeGraphBuildView.as_view(),
name="knowledge-graph-build",
),
path(
"api/knowledge-graph/status/",
knowledge_graph_views.KnowledgeGraphStatusView.as_view(),
name="knowledge-graph-status",
),
path(
"api/knowledge-graph/test-llm/",
knowledge_graph_views.LLMTestView.as_view(),
name="knowledge-graph-test-llm",
),
]
-148
View File
@@ -1,148 +0,0 @@
"""
Views for the Knowledge Graph feature.
"""
import json
import logging
from datetime import timedelta
from django.http import JsonResponse
from django.shortcuts import render
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from .models import KnowledgeGraphSnapshot, SiteSettings
logger = logging.getLogger(__name__)
# A BUILDING snapshot with no progress after this many minutes is considered stuck
STALE_BUILD_MINUTES = 5
class KnowledgeGraphPageView(View):
template_name = "links/knowledge_graph.html"
def get(self, request):
snapshot = KnowledgeGraphSnapshot.get_latest()
latest_building = KnowledgeGraphSnapshot.objects.filter(
status=KnowledgeGraphSnapshot.Status.BUILDING
).first()
ss = SiteSettings.get()
return render(request, self.template_name, {
"snapshot": snapshot,
"latest_building": latest_building,
"site_settings": ss,
})
class KnowledgeGraphDataView(View):
def get(self, request):
snapshot = KnowledgeGraphSnapshot.get_latest()
if not snapshot:
return JsonResponse({"error": "No ready snapshot found."}, status=404)
return JsonResponse(snapshot.graph_data, safe=False)
class KnowledgeGraphBuildView(View):
def post(self, request):
try:
body = json.loads(request.body or "{}")
except json.JSONDecodeError:
body = {}
# Cancel any stuck BUILDING snapshots before starting a new one
stale_cutoff = timezone.now() - timedelta(minutes=STALE_BUILD_MINUTES)
stuck = KnowledgeGraphSnapshot.objects.filter(
status=KnowledgeGraphSnapshot.Status.BUILDING,
created_at__lt=stale_cutoff,
)
if stuck.exists():
stuck.update(
status=KnowledgeGraphSnapshot.Status.FAILED,
error_message="Build was cancelled (process was killed or server restarted)",
completed_at=timezone.now(),
)
logger.info("Marked %d stale BUILDING snapshot(s) as FAILED", stuck.count())
ss = SiteSettings.get()
use_llm_requested = body.get("use_llm", False)
# Only use LLM if a provider is configured
use_llm = use_llm_requested and ss.llm_provider not in ("none", "")
snapshot = KnowledgeGraphSnapshot.objects.create(
status=KnowledgeGraphSnapshot.Status.BUILDING,
used_llm=use_llm,
)
from threading import Thread
from .tasks import build_knowledge_graph
thread = Thread(
target=build_knowledge_graph,
kwargs={"snapshot_id": snapshot.pk, "use_llm": use_llm},
daemon=True,
)
thread.start()
return JsonResponse({
"snapshot_id": snapshot.pk,
"status": snapshot.status,
"use_llm": use_llm,
})
class KnowledgeGraphStatusView(View):
def get(self, request):
# Latest snapshot regardless of status (so the UI can poll while building)
snapshot = KnowledgeGraphSnapshot.objects.first()
if not snapshot:
return JsonResponse({"status": "none"})
# Auto-detect stale BUILDING snapshots (e.g. server restarted, thread was killed)
status = snapshot.status
if status == KnowledgeGraphSnapshot.Status.BUILDING:
progress = snapshot.progress_data or {}
age_s = (timezone.now() - snapshot.created_at).total_seconds()
last_pct = progress.get("pct", 0)
# Mark stuck if: no progress at all after 5 min, or no change for 10 min
if age_s > STALE_BUILD_MINUTES * 60 and last_pct == 0:
snapshot.status = KnowledgeGraphSnapshot.Status.FAILED
snapshot.error_message = "Build timed out — the worker thread was likely killed (server restart). Click Build Graph to try again."
snapshot.completed_at = timezone.now()
snapshot.save(update_fields=["status", "error_message", "completed_at"])
status = KnowledgeGraphSnapshot.Status.FAILED
return JsonResponse({
"snapshot_id": snapshot.pk,
"status": status,
"node_count": snapshot.node_count,
"edge_count": snapshot.edge_count,
"used_llm": snapshot.used_llm,
"build_duration_ms": snapshot.build_duration_ms,
"error_message": snapshot.error_message,
"progress_data": snapshot.progress_data or {},
"created_at": snapshot.created_at.isoformat() if snapshot.created_at else None,
"completed_at": snapshot.completed_at.isoformat() if snapshot.completed_at else None,
})
class LLMTestView(View):
def post(self, request):
try:
body = json.loads(request.body or "{}")
except json.JSONDecodeError:
body = {}
# Accept inline params from the UI test modal, or fall back to SiteSettings
from .llm_client import LLMClient
provider = body.get("provider") or SiteSettings.get().llm_provider
base_url = body.get("base_url") or SiteSettings.get().llm_base_url
model = body.get("model") or SiteSettings.get().llm_model
api_key = body.get("api_key") or SiteSettings.get().llm_api_key
if provider in ("none", ""):
return JsonResponse({"success": False, "error": "No LLM provider configured."})
client = LLMClient(provider=provider, base_url=base_url, model=model, api_key=api_key)
success, error = client.test_connection()
return JsonResponse({"success": success, "error": error})
-111
View File
@@ -1,111 +0,0 @@
"""
LLM / embedding client for the knowledge graph builder.
Supports:
- Ollama (local, free — recommended for manual builds)
- OpenRouter (cloud, requires API key)
"""
import logging
import math
import requests
logger = logging.getLogger(__name__)
EMBED_TIMEOUT = 15 # seconds per request
class LLMClientError(Exception):
pass
class LLMClient:
"""Thin wrapper for embedding generation via Ollama or OpenRouter."""
def __init__(self, provider: str, base_url: str, model: str, api_key: str = ""):
self.provider = provider
self.base_url = base_url.rstrip("/")
self.model = model
self.api_key = api_key
@classmethod
def from_settings(cls) -> "LLMClient":
from links.models import SiteSettings
ss = SiteSettings.get()
return cls(
provider=ss.llm_provider,
base_url=ss.llm_base_url,
model=ss.llm_model,
api_key=ss.llm_api_key,
)
def get_embedding(self, text: str) -> list[float]:
"""Return a float vector for *text*. Raises LLMClientError on failure."""
if not text or not text.strip():
raise LLMClientError("Empty text passed to get_embedding")
if self.provider == "ollama":
return self._ollama_embed(text)
elif self.provider == "openrouter":
return self._openrouter_embed(text)
else:
raise LLMClientError(f"Unsupported provider: {self.provider}")
def _ollama_embed(self, text: str) -> list[float]:
url = f"{self.base_url}/api/embeddings"
try:
resp = requests.post(
url,
json={"model": self.model, "prompt": text},
timeout=EMBED_TIMEOUT,
)
resp.raise_for_status()
data = resp.json()
vec = data.get("embedding")
if not vec:
raise LLMClientError(f"Ollama returned no embedding. Response: {data}")
return vec
except requests.RequestException as exc:
raise LLMClientError(f"Ollama request failed: {exc}") from exc
def _openrouter_embed(self, text: str) -> list[float]:
url = "https://openrouter.ai/api/v1/embeddings"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
try:
resp = requests.post(
url,
headers=headers,
json={"model": self.model, "input": text},
timeout=EMBED_TIMEOUT,
)
resp.raise_for_status()
data = resp.json()
try:
vec = data["data"][0]["embedding"]
except (KeyError, IndexError) as exc:
raise LLMClientError(f"Unexpected OpenRouter response shape: {data}") from exc
return vec
except requests.RequestException as exc:
raise LLMClientError(f"OpenRouter request failed: {exc}") from exc
def test_connection(self) -> tuple[bool, str]:
"""Returns (success: bool, error_message: str)."""
try:
vec = self.get_embedding("ping")
if not vec:
return False, "Got empty embedding vector"
return True, ""
except LLMClientError as exc:
return False, str(exc)
def cosine_similarity(a: list[float], b: list[float]) -> float:
"""Compute cosine similarity between two vectors."""
dot = sum(x * y for x, y in zip(a, b))
mag_a = math.sqrt(sum(x * x for x in a))
mag_b = math.sqrt(sum(x * x for x in b))
if mag_a == 0 or mag_b == 0:
return 0.0
return dot / (mag_a * mag_b)
@@ -0,0 +1,58 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('links', '0047_add_is_public_to_post'),
]
operations = [
migrations.DeleteModel(
name='KnowledgeGraphSnapshot',
),
migrations.RemoveField(
model_name='sitesettings',
name='llm_provider',
),
migrations.RemoveField(
model_name='sitesettings',
name='llm_base_url',
),
migrations.RemoveField(
model_name='sitesettings',
name='llm_model',
),
migrations.RemoveField(
model_name='sitesettings',
name='llm_api_key',
),
migrations.RemoveField(
model_name='sitesettings',
name='kg_auto_schedule_enabled',
),
migrations.RemoveField(
model_name='sitesettings',
name='kg_auto_schedule_interval',
),
migrations.RemoveField(
model_name='sitesettings',
name='kg_semantic_threshold',
),
migrations.RemoveField(
model_name='sitesettings',
name='kg_include_links',
),
migrations.RemoveField(
model_name='sitesettings',
name='kg_include_pages',
),
migrations.RemoveField(
model_name='sitesettings',
name='kg_include_posts',
),
migrations.RemoveField(
model_name='sitesettings',
name='kg_include_tags',
),
]
-103
View File
@@ -417,11 +417,6 @@ class SiteSettings(models.Model):
Always use SiteSettings.get() to retrieve the instance.
"""
class LLMProvider(models.TextChoices):
NONE = 'none', _('None (no LLM)')
OLLAMA = 'ollama', _('Ollama (local)')
OPENROUTER = 'openrouter', _('OpenRouter')
public_sharing_domain = models.CharField(
_('Public Sharing Domain'),
max_length=255,
@@ -459,57 +454,6 @@ class SiteSettings(models.Model):
),
)
# ── Knowledge Graph: LLM settings ────────────────────────────────────
llm_provider = models.CharField(
_('LLM Provider'),
max_length=20,
choices=LLMProvider.choices,
default=LLMProvider.NONE,
help_text=_('LLM/embedding provider used to generate semantic edges in the knowledge graph.'),
)
llm_base_url = models.CharField(
_('LLM Base URL'),
max_length=255,
blank=True,
default='http://localhost:11434',
help_text=_('Base URL for the Ollama server (e.g. http://192.168.1.2:11434).'),
)
llm_model = models.CharField(
_('LLM Embedding Model'),
max_length=100,
blank=True,
default='nomic-embed-text',
help_text=_('Model name used for embeddings (e.g. nomic-embed-text for Ollama).'),
)
llm_api_key = models.CharField(
_('LLM API Key'),
max_length=255,
blank=True,
default='',
help_text=_('API key for OpenRouter (not needed for Ollama).'),
)
# ── Knowledge Graph: schedule & build options ─────────────────────────
kg_auto_schedule_enabled = models.BooleanField(
_('Auto-rebuild Knowledge Graph'),
default=False,
help_text=_('When enabled, the knowledge graph is rebuilt on the configured interval.'),
)
kg_auto_schedule_interval = models.IntegerField(
_('Knowledge Graph Rebuild Interval (seconds)'),
default=3600,
help_text=_('How often (in seconds) to auto-rebuild the knowledge graph. Minimum 60.'),
)
kg_semantic_threshold = models.FloatField(
_('Semantic Similarity Threshold'),
default=0.70,
help_text=_('Minimum cosine similarity (0.01.0) required to draw a semantic edge between two items.'),
)
kg_include_links = models.BooleanField(_('Include Links in Graph'), default=True)
kg_include_pages = models.BooleanField(_('Include Pages in Graph'), default=True)
kg_include_posts = models.BooleanField(_('Include Posts in Graph'), default=True)
kg_include_tags = models.BooleanField(_('Include Tags in Graph'), default=True)
class Meta:
verbose_name = _('Site Settings')
@@ -524,50 +468,3 @@ class SiteSettings(models.Model):
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
class KnowledgeGraphSnapshot(models.Model):
"""Stores a point-in-time serialized knowledge graph for immediate front-end consumption."""
class Status(models.TextChoices):
BUILDING = 'building', _('Building')
READY = 'ready', _('Ready')
FAILED = 'failed', _('Failed')
status = models.CharField(
_('Status'),
max_length=20,
choices=Status.choices,
default=Status.BUILDING,
db_index=True,
)
graph_data = models.JSONField(
_('Graph Data'),
default=dict,
help_text=_('Graphology-compatible serialization: {nodes: [...], edges: [...]}'),
)
node_count = models.IntegerField(_('Node Count'), default=0)
edge_count = models.IntegerField(_('Edge Count'), default=0)
used_llm = models.BooleanField(_('Used LLM'), default=False)
error_message = models.TextField(_('Error Message'), blank=True)
build_duration_ms = models.IntegerField(_('Build Duration (ms)'), default=0)
progress_data = models.JSONField(
_('Progress Data'),
default=dict,
blank=True,
help_text=_('Live build progress: {pct, step, logs}'),
)
created_at = models.DateTimeField(_('Created At'), auto_now_add=True)
completed_at = models.DateTimeField(_('Completed At'), null=True, blank=True)
class Meta:
ordering = ['-created_at']
verbose_name = _('Knowledge Graph Snapshot')
verbose_name_plural = _('Knowledge Graph Snapshots')
def __str__(self):
return f"KG Snapshot [{self.status}] {self.node_count} nodes / {self.edge_count} edges @ {self.created_at}"
@classmethod
def get_latest(cls):
return cls.objects.filter(status=cls.Status.READY).first()
-338
View File
@@ -622,341 +622,3 @@ def retry_stuck_image_imports():
logger.info(f"retry_stuck_image_imports: re-queuing download for FileUpload {record.pk} ({record.source_url})")
thread = Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True)
thread.start()
# ── Knowledge Graph ───────────────────────────────────────────────────────────
def build_knowledge_graph(snapshot_id: int | None = None, use_llm: bool = False):
"""
Build a graphology-compatible knowledge graph from all Links, Pages, Posts and Tags.
Nodes are coloured by type:
Link → #4B9CD3 (blue)
Page → #22C55E (green)
Post → #F97316 (orange)
Tag → #8B5CF6 (purple)
Edges (always):
has_tag item → tag (#94A3B8, structural)
same_domain items sharing netloc (#FCD34D, domain)
Edges (with LLM):
semantic cosine-sim ≥ threshold (#F472B6)
"""
import time
import random
from urllib.parse import urlparse
from django.utils import timezone as tz
from .models import KnowledgeGraphSnapshot, SiteSettings, Link, Page, Post, Tag
start_ms = int(time.time() * 1000)
# Resolve / create the snapshot record
if snapshot_id:
try:
snapshot = KnowledgeGraphSnapshot.objects.get(pk=snapshot_id)
except KnowledgeGraphSnapshot.DoesNotExist:
logger.error(f"build_knowledge_graph: snapshot {snapshot_id} not found")
return
else:
snapshot = KnowledgeGraphSnapshot.objects.create(
status=KnowledgeGraphSnapshot.Status.BUILDING,
used_llm=use_llm,
)
snapshot.status = KnowledgeGraphSnapshot.Status.BUILDING
snapshot.used_llm = use_llm
snapshot.progress_data = {"pct": 0, "step": "Starting…", "logs": []}
snapshot.save(update_fields=["status", "used_llm", "progress_data"])
try:
ss = SiteSettings.get()
nodes = []
edges = []
edge_key_counter = [0]
_log_entries: list[dict] = []
def _elapsed_s() -> float:
return round((time.time() * 1000 - start_ms) / 1000, 1)
def emit(pct: int, msg: str) -> None:
"""Save a progress update to the snapshot so the UI can poll it."""
_log_entries.append({"elapsed_s": _elapsed_s(), "msg": msg})
snapshot.progress_data = {
"pct": pct,
"step": msg,
"logs": list(_log_entries),
}
snapshot.save(update_fields=["progress_data"])
logger.info(f"build_knowledge_graph [{pct}%]: {msg}")
def next_edge_key():
edge_key_counter[0] += 1
return f"e{edge_key_counter[0]}"
def rand_pos():
return round(random.uniform(0, 100), 2)
emit(2, "Build started")
# ── Collect nodes ────────────────────────────────────────────────
# Links
if ss.kg_include_links:
link_qs = list(Link.objects.prefetch_related("tags").all())
for link in link_qs:
size = max(6, min(20, 6 + link.click_count // 5))
nodes.append({
"key": f"link-{link.id}",
"attributes": {
"label": link.alias,
"color": "#4B9CD3",
"size": size,
"node_type": "link",
"item_id": link.id,
"item_url": f"/detail/{link.pk}/",
"original_url": link.original_url or "",
"description": link.description or "",
"tag_ids": [t.id for t in link.tags.all()],
"x": rand_pos(),
"y": rand_pos(),
},
})
emit(15, f"Collected {len(link_qs)} links")
# Pages
if ss.kg_include_pages:
page_qs = list(Page.objects.prefetch_related("tags").all())
for page in page_qs:
nodes.append({
"key": f"page-{page.id}",
"attributes": {
"label": (page.title or page.url)[:80],
"color": "#22C55E",
"size": 8,
"node_type": "page",
"item_id": page.id,
"item_url": f"/ui/pages/{page.pk}/",
"original_url": page.url,
"description": page.summary or "",
"tag_ids": [t.id for t in page.tags.all()],
"x": rand_pos(),
"y": rand_pos(),
},
})
emit(28, f"Collected {len(page_qs)} pages")
# Posts
if ss.kg_include_posts:
post_qs = list(Post.objects.prefetch_related("tags").all())
for post in post_qs:
nodes.append({
"key": f"post-{post.id}",
"attributes": {
"label": post.title[:80],
"color": "#F97316",
"size": 8,
"node_type": "post",
"item_id": post.id,
"item_url": f"/ui/posts/{post.pk}/",
"original_url": "",
"description": post.summary or "",
"tag_ids": [t.id for t in post.tags.all()],
"x": rand_pos(),
"y": rand_pos(),
},
})
emit(38, f"Collected {len(post_qs)} posts")
# Tags
if ss.kg_include_tags:
tag_qs = list(Tag.objects.all())
for tag in tag_qs:
nodes.append({
"key": f"tag-{tag.id}",
"attributes": {
"label": tag.name,
"color": "#8B5CF6",
"size": 14,
"node_type": "tag",
"item_id": tag.id,
"item_url": f"/ui/tags/{tag.slug}/",
"original_url": "",
"description": tag.description or "",
"x": rand_pos(),
"y": rand_pos(),
},
})
emit(45, f"Collected {len(tag_qs)} tags — {len(nodes)} total nodes")
# Build a set of existing node keys for fast membership checks
node_keys = {n["key"] for n in nodes}
# ── Structural edges ─────────────────────────────────────────────
emit(48, "Building tag edges…")
for node in nodes:
ntype = node["attributes"]["node_type"]
nkey = node["key"]
if ntype in ("link", "page", "post") and ss.kg_include_tags:
for tag_id in node["attributes"].get("tag_ids", []):
tag_key = f"tag-{tag_id}"
if tag_key in node_keys:
edges.append({
"key": next_edge_key(),
"source": nkey,
"target": tag_key,
"attributes": {
"edge_type": "has_tag",
"color": "#94A3B8",
"size": 1,
},
})
if ntype == "tag":
pass # Tag has no parent field; hierarchy edges skipped
emit(55, f"Built {len(edges)} tag edges")
# ── Domain edges ─────────────────────────────────────────────────
emit(57, "Building domain edges…")
domain_map: dict[str, list[str]] = {}
for node in nodes:
url = node["attributes"].get("original_url", "")
if url:
try:
netloc = urlparse(url).netloc
if netloc:
domain_map.setdefault(netloc, []).append(node["key"])
except Exception:
pass
domain_edge_count = 0
for netloc, keys in domain_map.items():
if len(keys) < 2:
continue
for i in range(len(keys)):
for j in range(i + 1, len(keys)):
if domain_edge_count >= 100:
break
edges.append({
"key": next_edge_key(),
"source": keys[i],
"target": keys[j],
"attributes": {
"edge_type": "same_domain",
"color": "#FCD34D",
"size": 0.5,
"domain": netloc,
},
})
domain_edge_count += 1
if domain_edge_count >= 100:
break
emit(65, f"Built {domain_edge_count} domain edges across {len(domain_map)} domains")
# ── Semantic edges (LLM) ─────────────────────────────────────────
if use_llm and ss.llm_provider != "none":
from .llm_client import LLMClient, LLMClientError, cosine_similarity
client = LLMClient.from_settings()
threshold = ss.kg_semantic_threshold
embed_nodes = [n for n in nodes if n["attributes"]["node_type"] != "tag"]
total_embed = len(embed_nodes)
emit(67, f"Computing embeddings for {total_embed} nodes via {ss.llm_provider}")
embeddings: dict[str, list[float]] = {}
emit_every = max(1, total_embed // 10) # emit ~10 progress steps
for idx, node in enumerate(embed_nodes):
text_parts = [node["attributes"].get("label", "")]
desc = node["attributes"].get("description", "")
if desc:
text_parts.append(desc[:500])
text = " ".join(text_parts).strip()
try:
embeddings[node["key"]] = client.get_embedding(text)
except LLMClientError as exc:
logger.warning(f"build_knowledge_graph: embedding failed for {node['key']}: {exc}")
if (idx + 1) % emit_every == 0 or (idx + 1) == total_embed:
pct = 67 + int(18 * (idx + 1) / total_embed)
emit(pct, f"Embedded {idx + 1}/{total_embed} nodes…")
emit(85, f"Got {len(embeddings)} embeddings — computing pairwise similarity…")
semantic_count = 0
keys_with_embeds = list(embeddings.keys())
for i in range(len(keys_with_embeds)):
for j in range(i + 1, len(keys_with_embeds)):
ka, kb = keys_with_embeds[i], keys_with_embeds[j]
sim = cosine_similarity(embeddings[ka], embeddings[kb])
if sim >= threshold:
edges.append({
"key": next_edge_key(),
"source": ka,
"target": kb,
"attributes": {
"edge_type": "semantic",
"color": "#F472B6",
"size": round(sim, 3),
"similarity": round(sim, 3),
},
})
semantic_count += 1
emit(90, f"Built {semantic_count} semantic edges (threshold={threshold})")
else:
emit(65, "Skipping LLM semantic edges (no provider configured)")
# ── Save snapshot ─────────────────────────────────────────────────
emit(92, f"Saving snapshot — {len(nodes)} nodes, {len(edges)} edges…")
graph_data = {
"attributes": {"title": "Knowledge Graph"},
"nodes": nodes,
"edges": edges,
}
elapsed_ms = int(time.time() * 1000) - start_ms
_log_entries.append({"elapsed_s": _elapsed_s(), "msg": f"Done in {elapsed_ms / 1000:.1f}s"})
snapshot.graph_data = graph_data
snapshot.node_count = len(nodes)
snapshot.edge_count = len(edges)
snapshot.status = KnowledgeGraphSnapshot.Status.READY
snapshot.build_duration_ms = elapsed_ms
snapshot.completed_at = tz.now()
snapshot.progress_data = {
"pct": 100,
"step": f"Done — {len(nodes)} nodes, {len(edges)} edges in {elapsed_ms / 1000:.1f}s",
"logs": list(_log_entries),
}
snapshot.save()
logger.info(
f"build_knowledge_graph: done — {len(nodes)} nodes, {len(edges)} edges "
f"(LLM={use_llm}) in {elapsed_ms}ms"
)
except Exception as exc:
logger.error(f"build_knowledge_graph: failed — {exc}", exc_info=True)
snapshot.status = KnowledgeGraphSnapshot.Status.FAILED
snapshot.error_message = str(exc)
snapshot.completed_at = tz.now()
snapshot.save(update_fields=["status", "error_message", "completed_at", "progress_data"])
def schedule_kg_build():
"""Periodic task: trigger a knowledge graph rebuild if auto-schedule is enabled."""
from .models import SiteSettings, KnowledgeGraphSnapshot
ss = SiteSettings.get()
if not ss.kg_auto_schedule_enabled:
return
# Don't start a new build if one is already running
if KnowledgeGraphSnapshot.objects.filter(
status=KnowledgeGraphSnapshot.Status.BUILDING
).exists():
logger.debug("schedule_kg_build: skipped — a build is already in progress")
return
logger.info("schedule_kg_build: starting auto knowledge graph build")
use_llm = ss.llm_provider != "none"
thread = Thread(target=build_knowledge_graph, kwargs={"use_llm": use_llm}, daemon=True)
thread.start()
-847
View File
@@ -1,847 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% block extra_css %}
<style>
/* ------------------------------------------------------------------
Full-bleed graph canvas that fills the viewport below the navbar.
base.html wraps content in div.container.mx-auto.mt-20.p-2
So the effective top offset is ~88px. We break out by using
negative margins so the graph can fill edge-to-edge.
------------------------------------------------------------------ */
#kg-page-wrapper {
margin: -8px calc(-50vw + 50%); /* cancel container padding & centering */
height: calc(100vh - 80px);
position: relative;
overflow: hidden;
background: #f1f5f9;
}
#sigma-canvas-wrapper {
position: absolute;
inset: 0;
background: #f1f5f9;
/* The container background shows through transparent WebGL pixels.
Do NOT set background on the individual canvas elements — sigma's
label canvas is transparent by design so you can see the WebGL
node/edge canvas beneath it. Making it opaque would hide everything. */
}
/* ------------------------------------------------------------------
Top toolbar — single compact row, never wraps
------------------------------------------------------------------ */
#kg-toolbar {
position: absolute;
top: 12px;
left: 12px;
z-index: 30;
display: flex;
align-items: center;
gap: 8px;
/* prevents toolbar from ever covering the node panel */
max-width: calc(100% - 320px);
}
/* ------------------------------------------------------------------
Bottom-left legend / filter bar
------------------------------------------------------------------ */
#kg-legend {
position: absolute;
bottom: 14px;
left: 12px;
z-index: 30;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
padding: 6px 10px;
border-radius: 10px;
border: 1px solid rgba(226, 232, 240, 0.9);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
max-width: calc(100% - 340px);
}
.filter-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
border: 1.5px solid transparent;
transition: opacity 0.15s;
user-select: none;
white-space: nowrap;
}
.filter-badge.inactive { opacity: 0.3; }
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
/* ------------------------------------------------------------------
Right side panel
------------------------------------------------------------------ */
#node-panel {
position: absolute;
top: 0;
right: 0;
width: 300px;
height: 100%;
background: #fff;
border-left: 1px solid #e2e8f0;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 30;
}
#node-panel.open { transform: translateX(0); }
/* ------------------------------------------------------------------
Layout-computing overlay (shown while ForceAtlas2 runs)
------------------------------------------------------------------ */
#graph-loading-overlay {
position: absolute;
inset: 0;
background: rgba(241, 245, 249, 0.88);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
/* ------------------------------------------------------------------
Bottom-right zoom control
------------------------------------------------------------------ */
#kg-zoom-ctl {
position: absolute;
bottom: 14px;
right: 14px;
z-index: 30;
transition: right 0.25s ease;
}
/* style the range thumb */
#kg-zoom-ctl input[type=range] {
-webkit-appearance: none;
appearance: none;
width: 100px;
height: 4px;
border-radius: 2px;
background: #e2e8f0;
outline: none;
cursor: pointer;
}
#kg-zoom-ctl input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
#kg-zoom-ctl input[type=range]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
[x-cloak] { display: none !important; }
</style>
{% endblock %}
{% block content %}
<div id="kg-page-wrapper" x-data="kgApp()" x-init="init()">
<!-- ── Top toolbar (single row, never wraps) ───────────────────── -->
<div id="kg-toolbar">
<button
@click="showBuildModal = true"
class="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium shadow-sm transition flex-shrink-0">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
{% trans "Build Graph" %}
</button>
<span class="px-2.5 py-1 rounded-full bg-white/90 border border-gray-200 text-xs text-gray-600 shadow-sm whitespace-nowrap flex-shrink-0"
x-text="statusText"></span>
{% if snapshot %}{% with built_at=snapshot.completed_at|default:snapshot.created_at %}
<a href="{% url 'jobs' %}?tab=knowledge_graph"
class="px-2.5 py-1 rounded-full bg-amber-50/90 border border-amber-200 text-xs text-amber-700 shadow-sm hover:bg-amber-100 transition whitespace-nowrap flex-shrink-0 hidden sm:inline-flex"
title="{{ built_at|date:'Y-m-d H:i:s' }}">
{{ built_at|timesince }} {% trans "ago" %}
</a>
{% endwith %}{% endif %}
</div>
<!-- ── Sigma canvas ──────────────────────────────────────────────── -->
<div id="sigma-canvas-wrapper"></div>
<!-- ── Layout-computing overlay ──────────────────────────────────── -->
<div id="graph-loading-overlay" x-show="graphComputing" x-cloak>
<svg class="w-9 h-9 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
<p class="text-sm font-semibold text-gray-700" x-text="statusText"></p>
<p class="text-xs text-gray-400">{% trans "Computing force-directed layout…" %}</p>
</div>
<!-- ── Zoom control (bottom-right) ─────────────────────────────────── -->
<div id="kg-zoom-ctl"
x-show="graphLoaded" x-cloak
:style="selectedNode ? 'right:314px' : 'right:14px'"
class="flex items-center gap-1 bg-white/95 backdrop-blur border border-gray-200 rounded-lg shadow-sm px-2 py-1.5">
<button @click="zoomOut()"
title="{% trans 'Zoom out' %}"
class="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 text-gray-500 text-base font-semibold leading-none select-none"></button>
<input type="range" min="0" max="100" step="1"
:value="zoomSlider"
@input="setZoomFromSlider(+$event.target.value)"
title="{% trans 'Zoom level' %}">
<button @click="zoomIn()"
title="{% trans 'Zoom in' %}"
class="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 text-gray-500 text-base font-semibold leading-none select-none">+</button>
<span class="text-xs text-gray-500 tabular-nums w-11 text-right"
x-text="Math.round(100 / (zoomRatio || 1)) + '%'"></span>
</div>
<!-- ── Bottom-left legend / type filters ─────────────────────────── -->
<div id="kg-legend" x-show="graphLoaded" x-cloak>
<template x-for="f in filters" :key="f.type">
<span class="filter-badge"
:class="f.active ? '' : 'inactive'"
:style="`background:${f.color}22; border-color:${f.color}; color:${f.color}`"
@click="toggleFilter(f.type)">
<span class="dot" :style="`background:${f.color}`"></span>
<span x-text="f.label + ' (' + f.count + ')'"></span>
</span>
</template>
<span class="h-4 w-px bg-gray-200 mx-0.5 flex-shrink-0"></span>
<button @click="resetCamera()"
class="px-2 py-0.5 text-xs text-gray-500 hover:text-gray-800 bg-white rounded border border-gray-200 transition flex-shrink-0">
{% trans "Reset" %}
</button>
</div>
<!-- ── Right side panel ──────────────────────────────────────────── -->
<div id="node-panel" :class="selectedNode ? 'open' : ''">
<div class="p-4 border-b border-gray-100 flex items-center justify-between">
<h3 class="font-semibold text-gray-800 text-sm">{% trans "Node Details" %}</h3>
<button @click="deselectNode()" class="text-gray-400 hover:text-gray-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div x-show="selectedNode" class="p-4 space-y-3">
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold"
:style="`background:${selectedNode?.color}22; color:${selectedNode?.color};`">
<span class="dot" :style="`background:${selectedNode?.color}`"></span>
<span x-text="selectedNode?.node_type?.toUpperCase()"></span>
</span>
<p class="font-semibold text-gray-900 text-sm break-words" x-text="selectedNode?.label"></p>
<p class="text-xs text-gray-500 break-words" x-show="selectedNode?.description"
x-text="selectedNode?.description?.slice(0,200) + (selectedNode?.description?.length > 200 ? '…' : '')"></p>
<div x-show="selectedNode?.original_url">
<p class="text-xs text-gray-400 mb-0.5">{% trans "URL" %}</p>
<a :href="selectedNode?.original_url" target="_blank" rel="noopener noreferrer"
class="text-xs text-blue-500 hover:underline break-all"
x-text="selectedNode?.original_url?.slice(0,80) + (selectedNode?.original_url?.length > 80 ? '…' : '')"></a>
</div>
<a :href="selectedNode?.item_url" target="_blank" rel="noopener noreferrer"
x-show="selectedNode?.item_url"
class="inline-flex items-center gap-1.5 w-full justify-center px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
{% trans "View Item" %}
</a>
<div x-show="connectedNodes.length > 0">
<p class="text-xs font-semibold text-gray-500 mb-1">
{% trans "Connected" %} (<span x-text="connectedNodes.length"></span>)
</p>
<ul class="space-y-1">
<template x-for="cn in connectedNodes.slice(0,10)" :key="cn.key">
<li class="flex items-center gap-1.5 text-xs text-gray-700 cursor-pointer hover:text-indigo-600"
@click="selectNodeByKey(cn.key)">
<span class="dot flex-shrink-0" :style="`background:${cn.color}`"></span>
<span class="truncate" x-text="cn.label"></span>
</li>
</template>
<li x-show="connectedNodes.length > 10" class="text-xs text-gray-400 italic">
+ <span x-text="connectedNodes.length - 10"></span> {% trans "more" %}
</li>
</ul>
</div>
</div>
</div>
<!-- ── Empty state ───────────────────────────────────────────────── -->
<div x-show="!graphLoaded && !building && !graphComputing"
class="absolute inset-0 flex flex-col items-center justify-center gap-4 text-gray-400">
<svg class="w-16 h-16 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
<p class="text-sm font-medium">{% trans "No knowledge graph yet." %}</p>
<button @click="showBuildModal = true"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition">
{% trans "Build Graph Now" %}
</button>
</div>
<!-- ── Building progress panel ───────────────────────────────────── -->
<div x-show="building"
class="absolute inset-0 flex items-center justify-center p-6 z-10">
<div class="bg-white rounded-xl shadow-lg border border-gray-100 w-full max-w-lg p-6 space-y-4">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 animate-spin text-blue-500 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
<span class="font-semibold text-gray-800 text-sm">{% trans "Building Knowledge Graph" %}</span>
</div>
<div>
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span x-text="progress.step || '{% trans "Working" %}'" class="truncate max-w-xs"></span>
<span class="flex-shrink-0 ml-2 font-medium" x-text="(progress.pct || 0) + '%'"></span>
</div>
<div class="h-2 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 rounded-full transition-all duration-500"
:style="'width:' + (progress.pct || 0) + '%'"></div>
</div>
</div>
<div class="flex gap-4 text-xs text-gray-500">
<span>⏱ {% trans "Elapsed" %}: <span class="font-medium text-gray-700" x-text="fmtSecs(elapsedSecs)"></span></span>
<span x-show="progress.pct > 5 && progress.pct < 100">
{% trans "Est. remaining" %}: <span class="font-medium text-gray-700" x-text="fmtSecs(estRemaining)"></span>
</span>
</div>
<div>
<button @click="logsExpanded = !logsExpanded"
class="flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-700 transition">
<svg class="w-3.5 h-3.5 transition-transform" :class="logsExpanded ? 'rotate-90' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
{% trans "Logs" %} (<span x-text="buildLogs.length"></span>)
</button>
<div x-show="logsExpanded" x-transition
class="mt-2 max-h-40 overflow-y-auto rounded-lg bg-gray-900 p-3 space-y-0.5 font-mono">
<template x-for="(log, i) in buildLogs" :key="i">
<div class="flex gap-2 text-xs leading-5">
<span class="text-gray-500 flex-shrink-0" x-text="'+' + log.elapsed_s.toFixed(1) + 's'"></span>
<span class="text-green-400" x-text="log.msg"></span>
</div>
</template>
<div x-show="buildLogs.length === 0" class="text-xs text-gray-500">{% trans "No log entries yet…" %}</div>
</div>
</div>
</div>
</div>
<!-- ── Build modal ──────────────────────────────────────────────── -->
<div x-show="showBuildModal" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6 space-y-4">
<h2 class="text-lg font-bold text-gray-900">{% trans "Build Knowledge Graph" %}</h2>
<div class="space-y-3">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" x-model="buildUseLLM" :disabled="!llmAvailable"
class="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<span>
<span class="text-sm font-medium text-gray-800">{% trans "Use LLM for semantic edges" %}</span>
<span x-show="!llmAvailable" class="ml-1 text-xs text-gray-400">({% trans "not configured" %})</span>
<p class="text-xs text-gray-400 mt-0.5" x-show="llmAvailable">
{{ site_settings.llm_provider|upper }}
&bull; <span x-text="'{{ site_settings.llm_model }}'"></span>
</p>
</span>
</label>
<p class="text-xs text-gray-400">
{% trans "Semantic edges (pink) connect items with similar meaning using embeddings. Without LLM, the graph uses tag and domain edges only." %}
</p>
</div>
<div class="flex gap-3 pt-2">
<button @click="showBuildModal = false"
class="flex-1 py-2 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 transition">
{% trans "Cancel" %}
</button>
<button @click="startBuild()"
class="flex-1 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition">
{% trans "Start Build" %}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- graphology + sigma + ForceAtlas2 -->
<script src="https://cdn.jsdelivr.net/npm/graphology@0.25.4/dist/graphology.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sigma@2.4.0/build/sigma.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/graphology-layout-forceatlas2@0.10.1/build/graphology-layout-forceatlas2.umd.min.js"></script>
<script>
function kgApp() {
return {
// ── state ──────────────────────────────────────────────────────
sigmaInstance: null,
graph: null,
graphLoaded: false,
graphComputing: false,
zoomRatio: 1,
zoomSlider: 50,
building: {% if latest_building %}true{% else %}false{% endif %},
showBuildModal: false,
buildUseLLM: false,
llmAvailable: '{{ site_settings.llm_provider|default:"none" }}' !== 'none',
selectedNode: null,
connectedNodes: [],
statusText: '{% trans "No graph" %}',
pollTimer: null,
progress: { pct: 0, step: '', logs: [] },
buildLogs: [],
logsExpanded: false,
elapsedSecs: 0,
estRemaining: 0,
_buildStartMs: null,
_elapsedTimer: null,
_cameraRefreshTimer: null,
filters: [
{ type: 'link', label: '{% trans "Links" %}', color: '#3B82F6', active: true, count: 0 },
{ type: 'page', label: '{% trans "Pages" %}', color: '#10B981', active: true, count: 0 },
{ type: 'post', label: '{% trans "Posts" %}', color: '#F97316', active: true, count: 0 },
{ type: 'tag', label: '{% trans "Tags" %}', color: '#8B5CF6', active: true, count: 0 },
],
// ── init ───────────────────────────────────────────────────────
init() {
// Ensure only one KG renderer instance is active on the page.
// If Alpine re-initializes this block, tear down the old instance first.
if (window.__kgActive && window.__kgActive !== this && typeof window.__kgActive.destroy === 'function') {
window.__kgActive.destroy();
}
window.__kgActive = this;
this.llmAvailable = '{{ site_settings.llm_provider }}' !== 'none';
this.buildUseLLM = this.llmAvailable;
{% if snapshot %}
this.loadGraph();
{% elif latest_building %}
this.pollStatus();
{% endif %}
},
destroy() {
clearInterval(this.pollTimer);
clearInterval(this._elapsedTimer);
clearTimeout(this._cameraRefreshTimer);
if (this.sigmaInstance) {
this.sigmaInstance.kill();
this.sigmaInstance = null;
}
const container = document.getElementById('sigma-canvas-wrapper');
if (container) container.innerHTML = '';
if (window.__kgActive === this) window.__kgActive = null;
},
// ── load graph data ────────────────────────────────────────────
async loadGraph() {
try {
const resp = await fetch('/api/knowledge-graph/data/');
if (!resp.ok) return;
const data = await resp.json();
await this.renderGraph(data);
} catch(e) {
console.error('KG load error:', e);
this.statusText = '{% trans "Load error" %}';
this.graphComputing = false;
}
},
// ── render + layout (async so the UI stays responsive) ─────────
async renderGraph(data) {
// Kill previous instance and wipe all canvas/event elements sigma
// may have left in the container — without this, the old graph
// remains visible as a ghost underneath the new one.
const container = document.getElementById('sigma-canvas-wrapper');
if (this.sigmaInstance) {
this.sigmaInstance.kill();
this.sigmaInstance = null;
}
clearTimeout(this._cameraRefreshTimer);
this._cameraRefreshTimer = null;
container.innerHTML = '';
const g = new graphology.Graph({ multi: false });
for (const n of (data.nodes || [])) {
if (!g.hasNode(n.key)) g.addNode(n.key, n.attributes || {});
}
for (const e of (data.edges || [])) {
if (g.hasNode(e.source) && g.hasNode(e.target)) {
try {
if (!g.hasEdge(e.key)) g.addEdgeWithKey(e.key, e.source, e.target, e.attributes || {});
} catch(_) {}
}
}
// Count nodes by type for the legend
const typeCounts = {};
g.forEachNode((k, a) => {
typeCounts[a.node_type] = (typeCounts[a.node_type] || 0) + 1;
});
this.filters.forEach(f => { f.count = typeCounts[f.type] || 0; });
// Show the computing overlay while ForceAtlas2 runs
const nodeCount = g.order;
this.statusText = `{% trans "Laying out" %} ${nodeCount.toLocaleString()} {% trans "nodes" %}…`;
this.graphComputing = true;
this.building = false;
// Yield to let Alpine re-render the overlay before blocking computation
await new Promise(r => setTimeout(r, 80));
// Check whether nodes already have meaningful positions from the server
let withPos = 0;
g.forEachNode((k, a) => {
if (typeof a.x === 'number' && typeof a.y === 'number' && Math.abs(a.x) + Math.abs(a.y) > 0.001) withPos++;
});
const hasMeaningfulPositions = withPos > nodeCount * 0.3;
if (!hasMeaningfulPositions) {
// ── Step 1: pre-position in a circle for fast FA2 convergence ──
// Starting from random/zero positions causes FA2 to produce a tight
// blob. A circular seed gives every node room to "settle" outward.
const nks = g.nodes();
const r0 = Math.sqrt(nks.length) * 12;
nks.forEach((nk, i) => {
const θ = (2 * Math.PI * i) / nks.length;
// small jitter breaks the rotational symmetry
g.setNodeAttribute(nk, 'x', r0 * Math.cos(θ) + (Math.random() - 0.5) * 3);
g.setNodeAttribute(nk, 'y', r0 * Math.sin(θ) + (Math.random() - 0.5) * 3);
});
// ── Step 2: ForceAtlas2 with settings tuned for dense graphs ──
// Key choices:
// linLogMode=true → log-scale attraction; hubs don't collapse to a point
// outboundAttractionDistrib → attraction force ÷ degree; prevents mass clustering
// gravity=0.3 → gentle pull to center; nodes can spread freely
// barnesHutOptimize=true → O(n log n) repulsion; essential for 1000+ nodes
try {
graphologyLayoutForceAtlas2.assign(g, {
iterations: 250,
settings: {
gravity: 0.3,
scalingRatio: 2,
slowDown: 20,
barnesHutOptimize: true,
barnesHutTheta: 0.7,
linLogMode: true,
outboundAttractionDistribution:true,
adjustSizes: false,
strongGravityMode: false,
},
});
} catch(e) {
console.warn('ForceAtlas2 failed, continuing with circle layout:', e);
}
}
this.graph = g;
this.graphLoaded = true;
this.graphComputing = false;
this.statusText = `${nodeCount.toLocaleString()} {% trans "nodes" %} · ${g.size.toLocaleString()} {% trans "edges" %}`;
this.sigmaInstance = new Sigma(g, container, {
// Hide edge/label layers while moving. We'll force-clear and repaint
// once interaction settles to avoid stale ghost frames.
hideEdgesOnMove: true,
hideLabelsOnMove: true,
// Only show labels for nodes that appear >= 12px on screen.
labelRenderedSizeThreshold: 12,
labelColor: { color: '#1e293b' },
labelSize: 11,
// ── Defaults ─────────────────────────────────────────────────
defaultNodeColor: '#6366f1',
renderEdgeLabels: false,
// ── Node reducer ─────────────────────────────────────────────
// Dim nodes that are NOT connected to the currently selected node.
// "Dim" = render in a near-transparent gray so the selected node's
// neighbourhood stands out clearly.
nodeReducer: (node, attrs) => {
const res = { ...attrs, hidden: attrs._hidden || false };
if (attrs._dim) {
res.color = '#cbd5e1'; // light slate
res.size = Math.max(1.5, (attrs.size || 4) * 0.55);
res.label = null; // suppress label for dimmed nodes
}
return res;
},
// ── Edge reducer ─────────────────────────────────────────────
// Default: edges are almost invisible (dense graph = unreadable otherwise).
// Highlighted edges (connected to selected node) are shown at full opacity.
edgeReducer: (edge, attrs) => {
const res = { ...attrs, hidden: attrs._hidden || false };
if (attrs._highlighted) {
res.color = attrs.color || '#64748b';
res.size = Math.max(1, (attrs.size || 0.5) * 2);
} else {
// Sigma's floatColor() only parses 3/6-digit hex. Use a light
// slate-400 hex so edges are visible but don't overwhelm nodes.
res.color = '#94a3b8';
res.size = 0.5;
}
return res;
},
});
this.sigmaInstance.on('clickNode', ({ node }) => this.selectNodeByKey(node));
this.sigmaInstance.on('clickStage', () => this.deselectNode());
// Sync zoom slider with touchpad / programmatic camera changes.
// Throttled to ~30fps to avoid flooding Alpine with reactive updates
// (which would trigger DOM reconciliation and potentially interfere
// with sigma's canvas clearing between frames).
let _lastZoomSync = 0;
this.sigmaInstance.getCamera().on('updated', (state) => {
const now = Date.now();
if (now - _lastZoomSync > 33) {
_lastZoomSync = now;
this._syncZoomFromCamera(state.ratio);
}
// Force a clean repaint when zoom/pan settles.
clearTimeout(this._cameraRefreshTimer);
this._cameraRefreshTimer = setTimeout(() => {
this.forceCanvasClear();
if (this.sigmaInstance) this.sigmaInstance.refresh();
}, 80);
});
this._syncZoomFromCamera(this.sigmaInstance.getCamera().ratio);
},
forceCanvasClear() {
const wrapper = document.getElementById('sigma-canvas-wrapper');
if (!wrapper) return;
const canvases = wrapper.querySelectorAll('canvas');
canvases.forEach((canvas) => {
const ctx2d = canvas.getContext('2d');
if (ctx2d) {
ctx2d.clearRect(0, 0, canvas.width, canvas.height);
return;
}
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (gl) {
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
}
});
},
// ── node selection ─────────────────────────────────────────────
selectNodeByKey(nodeKey) {
if (!this.graph || !this.sigmaInstance || !this.graph.hasNode(nodeKey)) return;
const attrs = this.graph.getNodeAttributes(nodeKey);
this.selectedNode = { key: nodeKey, ...attrs };
const neighborKeys = new Set();
this.graph.forEachNeighbor(nodeKey, nbr => neighborKeys.add(nbr));
this.connectedNodes = Array.from(neighborKeys).map(k => ({
key: k, ...this.graph.getNodeAttributes(k),
})).sort((a, b) => (b.size || 4) - (a.size || 4)); // largest first
this.graph.forEachNode((n) => {
const relevant = n === nodeKey || neighborKeys.has(n);
this.graph.setNodeAttribute(n, '_dim', !relevant);
this.graph.setNodeAttribute(n, '_hidden', false);
});
this.graph.forEachEdge((e, a, s, t) => {
const isConn = s === nodeKey || t === nodeKey;
this.graph.setEdgeAttribute(e, '_highlighted', isConn);
this.graph.setEdgeAttribute(e, '_hidden', false);
});
this.sigmaInstance.refresh();
},
deselectNode() {
this.selectedNode = null;
this.connectedNodes = [];
if (!this.graph || !this.sigmaInstance) return;
this.graph.forEachNode((n) => {
this.graph.setNodeAttribute(n, '_dim', false);
this.graph.setNodeAttribute(n, '_hidden', false);
});
this.graph.forEachEdge((e) => {
this.graph.setEdgeAttribute(e, '_highlighted', false);
this.graph.setEdgeAttribute(e, '_hidden', false);
});
this.sigmaInstance.refresh();
},
// ── type filter toggle ─────────────────────────────────────────
toggleFilter(type) {
const f = this.filters.find(f => f.type === type);
if (!f || !this.graph || !this.sigmaInstance) return;
f.active = !f.active;
const activeTypes = new Set(this.filters.filter(f => f.active).map(f => f.type));
this.graph.forEachNode((n, a) => {
this.graph.setNodeAttribute(n, '_hidden', !activeTypes.has(a.node_type));
});
this.graph.forEachEdge((e, a, s, t) => {
const sa = this.graph.getNodeAttributes(s);
const ta = this.graph.getNodeAttributes(t);
this.graph.setEdgeAttribute(e, '_hidden',
!activeTypes.has(sa.node_type) || !activeTypes.has(ta.node_type));
});
this.sigmaInstance.refresh();
},
// ── camera reset ───────────────────────────────────────────────
resetCamera() {
if (this.sigmaInstance) this.sigmaInstance.getCamera().animatedReset();
},
// ── zoom controls ──────────────────────────────────────────────
zoomIn() { if (this.sigmaInstance) this.sigmaInstance.getCamera().animatedZoom({ duration: 200, factor: 1.5 }); },
zoomOut() { if (this.sigmaInstance) this.sigmaInstance.getCamera().animatedUnzoom({ duration: 200, factor: 1.5 }); },
// Slider 0..100 → camera ratio on a log scale
// val=0 → ratio=4 → 25% zoom (very zoomed out)
// val=50 → ratio=1 → 100% zoom (default)
// val=100 → ratio=0.25 → 400% zoom (very zoomed in)
setZoomFromSlider(val) {
this.zoomSlider = val;
const ratio = Math.exp(Math.log(4) + (Math.log(0.25) - Math.log(4)) * val / 100);
if (this.sigmaInstance) this.sigmaInstance.getCamera().setState({ ratio });
},
_syncZoomFromCamera(ratio) {
this.zoomRatio = ratio;
const logMin = Math.log(4); // most zoomed out
const logMax = Math.log(0.25); // most zoomed in
const raw = 100 * (Math.log(Math.max(ratio, 0.001)) - logMin) / (logMax - logMin);
this.zoomSlider = Math.max(0, Math.min(100, Math.round(raw)));
},
// ── build ──────────────────────────────────────────────────────
async startBuild() {
this.showBuildModal = false;
this.building = true;
this.progress = { pct: 0, step: '{% trans "Starting…" %}', logs: [] };
this.buildLogs = [];
this.elapsedSecs = 0;
this.estRemaining = 0;
this._buildStartMs = Date.now();
this._startElapsedTimer(this._buildStartMs);
this.statusText = '{% trans "Building…" %}';
try {
const resp = await fetch('/api/knowledge-graph/build/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCsrf() },
body: JSON.stringify({ use_llm: this.buildUseLLM }),
});
if (resp.ok) this.pollStatus();
} catch(e) {
this.building = false;
clearInterval(this._elapsedTimer);
this.statusText = '{% trans "Build failed" %}';
}
},
// ── elapsed timer ──────────────────────────────────────────────
fmtSecs(s) {
if (s < 60) return Math.round(s) + 's';
return Math.floor(s / 60) + 'm ' + (Math.round(s) % 60) + 's';
},
_startElapsedTimer(startMs) {
this._buildStartMs = startMs;
clearInterval(this._elapsedTimer);
this._elapsedTimer = setInterval(() => {
this.elapsedSecs = (Date.now() - this._buildStartMs) / 1000;
const pct = this.progress.pct || 0;
if (pct > 5 && pct < 100) this.estRemaining = (this.elapsedSecs / pct) * (100 - pct);
}, 500);
},
// ── status polling ─────────────────────────────────────────────
pollStatus() {
clearInterval(this.pollTimer);
this.pollTimer = setInterval(async () => {
try {
const resp = await fetch('/api/knowledge-graph/status/');
const data = await resp.json();
if (data.progress_data?.pct !== undefined) {
this.progress = data.progress_data;
this.buildLogs = data.progress_data.logs || [];
if (this.logsExpanded && this.buildLogs.length) {
this.$nextTick(() => {
const el = document.querySelector('.font-mono.overflow-y-auto');
if (el) el.scrollTop = el.scrollHeight;
});
}
}
if (data.status === 'building' && data.created_at && !this._buildStartMs) {
this._startElapsedTimer(new Date(data.created_at).getTime());
}
if (data.status === 'ready') {
clearInterval(this.pollTimer);
clearInterval(this._elapsedTimer);
this.building = false;
await this.loadGraph();
} else if (data.status === 'failed') {
clearInterval(this.pollTimer);
clearInterval(this._elapsedTimer);
this.building = false;
this.statusText = '{% trans "Build failed" %}: ' + (data.error_message || '?');
} else if (data.status === 'building') {
this.building = true;
this.statusText = '{% trans "Building…" %}';
}
} catch(_) {}
}, 2000);
},
// ── CSRF helper ────────────────────────────────────────────────
getCsrf() {
const c = document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='));
return c ? c.trim().split('=')[1] : '';
},
};
}
</script>
{% endblock %}
-183
View File
@@ -106,155 +106,6 @@
<p class="mt-2 text-xs text-gray-400">{% trans "Default: 120. Range: 103600. Changes take effect immediately." %}</p>
</div>
<!-- ══ Knowledge Graph: LLM Provider ══════════════════════════════ -->
<div class="bg-white rounded-lg shadow p-6" x-data="llmSettingsHelper()">
<h2 class="text-lg font-semibold text-gray-700 mb-1">{% trans "Knowledge Graph — LLM Provider" %}</h2>
<p class="text-sm text-gray-500 mb-4">
{% trans "Configure an LLM/embedding provider to generate semantic similarity edges in the knowledge graph. Select Ollama to use a local model for free." %}
</p>
<div class="space-y-4">
<!-- Provider select -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "Provider" %}</label>
<select name="llm_provider" id="llm_provider" x-model="provider"
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="none">{% trans "None (no LLM)" %}</option>
<option value="ollama">{% trans "Ollama (local, free)" %}</option>
<option value="openrouter">{% trans "OpenRouter (cloud)" %}</option>
</select>
</div>
<!-- Ollama base URL -->
<div x-show="provider === 'ollama'">
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "Ollama Base URL" %}</label>
<input type="url" name="llm_base_url" id="llm_base_url"
value="{{ site_settings.llm_base_url }}"
placeholder="http://192.168.1.2:11434"
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="mt-1 text-xs text-gray-400">{% trans "E.g. http://localhost:11434 or your LAN Ollama address." %}</p>
</div>
<!-- Model -->
<div x-show="provider !== 'none'">
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "Embedding Model" %}</label>
<input type="text" name="llm_model" id="llm_model"
value="{{ site_settings.llm_model }}"
placeholder="qwen3-embedding:0.6b"
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="mt-1 text-xs text-gray-400">
{% trans "Ollama: qwen3-embedding:0.6b, mxbai-embed-large, etc." %}&nbsp;&bull;&nbsp;
{% trans "OpenRouter: any embedding model slug." %}
</p>
</div>
<!-- API key (OpenRouter only) -->
<div x-show="provider === 'openrouter'">
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "OpenRouter API Key" %}</label>
<input type="password" name="llm_api_key" id="llm_api_key"
value="{{ site_settings.llm_api_key }}"
autocomplete="off"
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- Test connection -->
<div x-show="provider !== 'none'" class="flex items-center gap-3">
<button type="button" @click="testConnection()"
:disabled="testing"
class="px-3 py-1.5 rounded-md text-sm font-medium border border-gray-300 hover:bg-gray-50 transition disabled:opacity-50">
<span x-text="testing ? '{% trans "Testing" %}' : '{% trans "Test Connection" %}'"></span>
</button>
<span x-show="testResult !== null"
:class="testResult ? 'text-green-600' : 'text-red-600'"
class="text-sm font-medium" x-text="testMessage"></span>
</div>
</div>
</div>
<!-- ══ Knowledge Graph: Schedule ══════════════════════════════════ -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold text-gray-700 mb-1">{% trans "Knowledge Graph — Auto Rebuild" %}</h2>
<p class="text-sm text-gray-500 mb-4">
{% trans "Optionally rebuild the knowledge graph on a schedule. You can also trigger a manual build from the " %}
<a href="{% url 'knowledge-graph' %}" class="text-blue-500 hover:underline">{% trans "Knowledge Graph page" %}</a>.
</p>
<div class="space-y-4">
<!-- Enable toggle -->
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="kg_auto_schedule_enabled" id="kg_auto_schedule_enabled"
value="1" {% if site_settings.kg_auto_schedule_enabled %}checked{% endif %}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<span class="text-sm font-medium text-gray-700">{% trans "Enable auto-rebuild" %}</span>
</label>
<!-- Interval -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "Rebuild Interval (seconds)" %}</label>
<div class="flex items-center gap-3">
<input type="number" name="kg_auto_schedule_interval" id="kg_auto_schedule_interval"
value="{{ site_settings.kg_auto_schedule_interval }}"
min="60" max="86400"
class="w-32 border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<span class="text-sm text-gray-400">{% trans "seconds" %}</span>
</div>
<p class="mt-1 text-xs text-gray-400">{% trans "Minimum 60 (1 minute). Default 3600 (1 hour)." %}</p>
</div>
<!-- Semantic threshold -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{% trans "Semantic Similarity Threshold" %}&nbsp;
<span class="font-mono text-gray-500" id="threshold-display">{{ site_settings.kg_semantic_threshold }}</span>
</label>
<input type="range" name="kg_semantic_threshold" id="kg_semantic_threshold"
value="{{ site_settings.kg_semantic_threshold }}"
min="0.50" max="0.95" step="0.01"
oninput="document.getElementById('threshold-display').textContent=this.value"
class="w-full accent-indigo-600">
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>0.50 ({% trans "more edges" %})</span>
<span>0.95 ({% trans "fewer, tighter edges" %})</span>
</div>
</div>
<!-- Include types -->
<div>
<p class="text-sm font-medium text-gray-700 mb-2">{% trans "Include in graph:" %}</p>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" name="kg_include_links" value="1"
{% if site_settings.kg_include_links %}checked{% endif %}
class="rounded border-gray-300 text-blue-400 focus:ring-blue-400">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-blue-400"></span>
{% trans "Links" %}
</label>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" name="kg_include_pages" value="1"
{% if site_settings.kg_include_pages %}checked{% endif %}
class="rounded border-gray-300 text-green-500 focus:ring-green-500">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-green-500"></span>
{% trans "Pages" %}
</label>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" name="kg_include_posts" value="1"
{% if site_settings.kg_include_posts %}checked{% endif %}
class="rounded border-gray-300 text-orange-400 focus:ring-orange-400">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-orange-400"></span>
{% trans "Posts" %}
</label>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" name="kg_include_tags" value="1"
{% if site_settings.kg_include_tags %}checked{% endif %}
class="rounded border-gray-300 text-purple-500 focus:ring-purple-500">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-purple-500"></span>
{% trans "Tags" %}
</label>
</div>
</div>
</div>
</div>
</div><!-- /grid -->
<div class="flex justify-end mt-6">
@@ -369,39 +220,5 @@ function sharedPostsMgr() {
},
};
}
function llmSettingsHelper() {
return {
provider: '{{ site_settings.llm_provider }}',
testing: false,
testResult: null,
testMessage: '',
async testConnection() {
this.testing = true;
this.testResult = null;
const provider = document.getElementById('llm_provider').value;
const base_url = document.getElementById('llm_base_url')?.value || '';
const model = document.getElementById('llm_model')?.value || '';
const api_key = document.getElementById('llm_api_key')?.value || '';
try {
const resp = await fetch('/api/knowledge-graph/test-llm/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '',
},
body: JSON.stringify({ provider, base_url, model, api_key }),
});
const data = await resp.json();
this.testResult = data.success;
this.testMessage = data.success ? '✓ {% trans "Connection OK" %}' : '✗ ' + (data.error || '{% trans "Failed" %}');
} catch(e) {
this.testResult = false;
this.testMessage = '✗ ' + e.message;
}
this.testing = false;
},
};
}
</script>
{% endblock %}
-2
View File
@@ -74,8 +74,6 @@ urlpatterns = [
path('', include('links.collection_urls')),
# Include tag URLs
path('', include('links.tag_urls')),
# Include knowledge graph URLs
path('', include('links.knowledge_graph_urls')),
# Aliases - these should always be last
path('<str:alias>/', views.redirect_to_original, name='redirect_to_original'),
-37
View File
@@ -594,32 +594,6 @@ class SiteSettingsView(View):
site_settings.schedule_pending_screenshots_interval = max(10, min(screenshots_interval, 3600))
except (ValueError, TypeError):
pass
# ── Knowledge Graph LLM settings ──────────────────────────────────
llm_provider = request.POST.get('llm_provider', 'none').strip()
if llm_provider in ('none', 'ollama', 'openrouter'):
site_settings.llm_provider = llm_provider
site_settings.llm_base_url = request.POST.get('llm_base_url', '').strip() or 'http://localhost:11434'
site_settings.llm_model = request.POST.get('llm_model', '').strip() or 'qwen3-embedding:0.6b'
site_settings.llm_api_key = request.POST.get('llm_api_key', '').strip()
# ── Knowledge Graph schedule settings ─────────────────────────────
site_settings.kg_auto_schedule_enabled = bool(request.POST.get('kg_auto_schedule_enabled'))
try:
kg_interval = int(request.POST.get('kg_auto_schedule_interval', 3600))
site_settings.kg_auto_schedule_interval = max(60, min(kg_interval, 86400))
except (ValueError, TypeError):
pass
try:
threshold = float(request.POST.get('kg_semantic_threshold', 0.70))
site_settings.kg_semantic_threshold = max(0.0, min(threshold, 1.0))
except (ValueError, TypeError):
pass
site_settings.kg_include_links = bool(request.POST.get('kg_include_links'))
site_settings.kg_include_pages = bool(request.POST.get('kg_include_pages'))
site_settings.kg_include_posts = bool(request.POST.get('kg_include_posts'))
site_settings.kg_include_tags = bool(request.POST.get('kg_include_tags'))
site_settings.save()
# Reschedule periodic jobs with the new intervals
@@ -632,11 +606,6 @@ class SiteSettingsView(View):
'schedule_pending_screenshots',
trigger=IntervalTrigger(seconds=site_settings.schedule_pending_screenshots_interval),
)
# Reschedule knowledge graph job
scheduler.reschedule_job(
'schedule_kg_build',
trigger=IntervalTrigger(seconds=site_settings.kg_auto_schedule_interval),
)
except Exception:
pass # Scheduler may not be running in test/CLI context
@@ -817,12 +786,6 @@ class JobsView(View):
messages.success(request, _(f'Deleted {n} netscan run(s).'))
elif action == 'bulk_delete_knowledge_graph_snapshots':
from .models import KnowledgeGraphSnapshot
qs = KnowledgeGraphSnapshot.objects.filter(pk__in=ids) if ids else KnowledgeGraphSnapshot.objects.none()
n = qs.delete()[0]
messages.success(request, _(f'Deleted {n} knowledge graph snapshot(s).'))
else:
messages.error(request, _('Unknown action.'))
# Preserve tab/status after POST
-10
View File
@@ -191,16 +191,6 @@
</a>
<a href="{% url 'knowledge-graph' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6a2 2 0 100-4 2 2 0 000 4zm16 0a2 2 0 100-4 2 2 0 000 4zM4 20a2 2 0 100-4 2 2 0 000 4zm16 0a2 2 0 100-4 2 2 0 000 4zm-8-8a2 2 0 100-4 2 2 0 000 4zM8.5 8.5l-3 3m13-3l-3 3m-7 0l3 3m1 0l3-3"/>
</svg>
{% trans "Knowledge Graph" %}
</div>
</a>
<a href="{% url 'jobs' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">