mirror of
https://github.com/wahyd4/links.git
synced 2026-08-15 08:06:27 +10:00
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:
+1
-12
@@ -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)")
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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})
|
||||
@@ -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
@@ -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.0–1.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
@@ -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()
|
||||
|
||||
@@ -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 }}
|
||||
• <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 %}
|
||||
@@ -106,155 +106,6 @@
|
||||
<p class="mt-2 text-xs text-gray-400">{% trans "Default: 120. Range: 10–3600. 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." %} •
|
||||
{% 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" %}
|
||||
<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 %}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user