Regenerate summaries at will
This commit is contained in:
parent
b73da29ee4
commit
556b73ecb5
Binary file not shown.
@ -1,37 +1,3 @@
|
|||||||
"""
|
|
||||||
LocalAIApi — lightweight Python client for the Flatlogic AI proxy.
|
|
||||||
|
|
||||||
Usage (inside the Django workspace):
|
|
||||||
|
|
||||||
from ai.local_ai_api import LocalAIApi
|
|
||||||
|
|
||||||
response = LocalAIApi.create_response({
|
|
||||||
"input": [
|
|
||||||
{"role": "system", "content": "You are a helpful assistant."},
|
|
||||||
{"role": "user", "content": "Summarise this text in two sentences."},
|
|
||||||
],
|
|
||||||
"text": {"format": {"type": "json_object"}},
|
|
||||||
})
|
|
||||||
|
|
||||||
if response.get("success"):
|
|
||||||
data = LocalAIApi.decode_json_from_response(response)
|
|
||||||
# ...
|
|
||||||
|
|
||||||
# Typical successful payload (truncated):
|
|
||||||
# {
|
|
||||||
# "id": "resp_xxx",
|
|
||||||
# "status": "completed",
|
|
||||||
# "output": [
|
|
||||||
# {"type": "reasoning", "summary": []},
|
|
||||||
# {"type": "message", "content": [{"type": "output_text", "text": "Your final answer here."}]}
|
|
||||||
# ],
|
|
||||||
# "usage": { "input_tokens": 123, "output_tokens": 456 }
|
|
||||||
# }
|
|
||||||
|
|
||||||
The helper automatically injects the project UUID header and falls back to
|
|
||||||
reading executor/.env if environment variables are missing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@ -52,10 +18,8 @@ __all__ = [
|
|||||||
"decode_json_from_response",
|
"decode_json_from_response",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
_CONFIG_CACHE: Optional[Dict[str, Any]] = None
|
_CONFIG_CACHE: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
class LocalAIApi:
|
class LocalAIApi:
|
||||||
"""Static helpers mirroring the PHP implementation."""
|
"""Static helpers mirroring the PHP implementation."""
|
||||||
|
|
||||||
@ -76,9 +40,7 @@ class LocalAIApi:
|
|||||||
def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
return decode_json_from_response(response)
|
return decode_json_from_response(response)
|
||||||
|
|
||||||
|
|
||||||
def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
"""Signature compatible with the OpenAI Responses API."""
|
|
||||||
options = options or {}
|
options = options or {}
|
||||||
payload = dict(params)
|
payload = dict(params)
|
||||||
|
|
||||||
@ -111,9 +73,7 @@ def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] =
|
|||||||
|
|
||||||
return initial
|
return initial
|
||||||
|
|
||||||
|
|
||||||
def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
"""Perform a raw request to the AI proxy."""
|
|
||||||
cfg = _config()
|
cfg = _config()
|
||||||
options = options or {}
|
options = options or {}
|
||||||
|
|
||||||
@ -145,6 +105,7 @@ def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
cfg["project_header"]: project_uuid,
|
cfg["project_header"]: project_uuid,
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
}
|
}
|
||||||
extra_headers = options.get("headers")
|
extra_headers = options.get("headers")
|
||||||
if isinstance(extra_headers, Iterable):
|
if isinstance(extra_headers, Iterable):
|
||||||
@ -156,9 +117,7 @@ def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict
|
|||||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
return _http_request(url, "POST", body, headers, timeout, verify_tls)
|
return _http_request(url, "POST", body, headers, timeout, verify_tls)
|
||||||
|
|
||||||
|
|
||||||
def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
"""Fetch status for a queued AI request."""
|
|
||||||
cfg = _config()
|
cfg = _config()
|
||||||
options = options or {}
|
options = options or {}
|
||||||
|
|
||||||
@ -180,6 +139,7 @@ def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -
|
|||||||
headers: Dict[str, str] = {
|
headers: Dict[str, str] = {
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
cfg["project_header"]: project_uuid,
|
cfg["project_header"]: project_uuid,
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
}
|
}
|
||||||
extra_headers = options.get("headers")
|
extra_headers = options.get("headers")
|
||||||
if isinstance(extra_headers, Iterable):
|
if isinstance(extra_headers, Iterable):
|
||||||
@ -190,9 +150,7 @@ def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -
|
|||||||
|
|
||||||
return _http_request(url, "GET", None, headers, timeout, verify_tls)
|
return _http_request(url, "GET", None, headers, timeout, verify_tls)
|
||||||
|
|
||||||
|
|
||||||
def await_response(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
def await_response(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
"""Poll status endpoint until the request is complete or timed out."""
|
|
||||||
options = options or {}
|
options = options or {}
|
||||||
timeout = int(options.get("timeout", 300))
|
timeout = int(options.get("timeout", 300))
|
||||||
interval = int(options.get("interval", 5))
|
interval = int(options.get("interval", 5))
|
||||||
@ -236,14 +194,10 @@ def await_response(ai_request_id: Any, options: Optional[Dict[str, Any]] = None)
|
|||||||
}
|
}
|
||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
def extract_text(response: Dict[str, Any]) -> str:
|
def extract_text(response: Dict[str, Any]) -> str:
|
||||||
"""Public helper to extract plain text from a Responses payload."""
|
|
||||||
return _extract_text(response)
|
return _extract_text(response)
|
||||||
|
|
||||||
|
|
||||||
def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Attempt to decode JSON emitted by the model (handles markdown fences)."""
|
|
||||||
text = _extract_text(response)
|
text = _extract_text(response)
|
||||||
if text == "":
|
if text == "":
|
||||||
return None
|
return None
|
||||||
@ -270,7 +224,6 @@ def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, An
|
|||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _extract_text(response: Dict[str, Any]) -> str:
|
def _extract_text(response: Dict[str, Any]) -> str:
|
||||||
payload = response.get("data") if response.get("success") else response.get("response")
|
payload = response.get("data") if response.get("success") else response.get("response")
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
@ -294,9 +247,8 @@ def _extract_text(response: Dict[str, Any]) -> str:
|
|||||||
return payload
|
return payload
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _config() -> Dict[str, Any]:
|
def _config() -> Dict[str, Any]:
|
||||||
global _CONFIG_CACHE # noqa: PLW0603
|
global _CONFIG_CACHE
|
||||||
if _CONFIG_CACHE is not None:
|
if _CONFIG_CACHE is not None:
|
||||||
return _CONFIG_CACHE
|
return _CONFIG_CACHE
|
||||||
|
|
||||||
@ -320,7 +272,6 @@ def _config() -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
return _CONFIG_CACHE
|
return _CONFIG_CACHE
|
||||||
|
|
||||||
|
|
||||||
def _build_url(path: str, base_url: str) -> str:
|
def _build_url(path: str, base_url: str) -> str:
|
||||||
trimmed = path.strip()
|
trimmed = path.strip()
|
||||||
if trimmed.startswith("http://") or trimmed.startswith("https://"):
|
if trimmed.startswith("http://") or trimmed.startswith("https://"):
|
||||||
@ -329,7 +280,6 @@ def _build_url(path: str, base_url: str) -> str:
|
|||||||
return f"{base_url}{trimmed}"
|
return f"{base_url}{trimmed}"
|
||||||
return f"{base_url}/{trimmed}"
|
return f"{base_url}/{trimmed}"
|
||||||
|
|
||||||
|
|
||||||
def _resolve_status_path(ai_request_id: Any, cfg: Dict[str, Any]) -> str:
|
def _resolve_status_path(ai_request_id: Any, cfg: Dict[str, Any]) -> str:
|
||||||
base_path = (cfg.get("responses_path") or "").rstrip("/")
|
base_path = (cfg.get("responses_path") or "").rstrip("/")
|
||||||
if not base_path:
|
if not base_path:
|
||||||
@ -338,12 +288,8 @@ def _resolve_status_path(ai_request_id: Any, cfg: Dict[str, Any]) -> str:
|
|||||||
base_path = f"{base_path}/ai-request"
|
base_path = f"{base_path}/ai-request"
|
||||||
return f"{base_path}/{ai_request_id}/status"
|
return f"{base_path}/{ai_request_id}/status"
|
||||||
|
|
||||||
|
|
||||||
def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[str, str],
|
def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[str, str],
|
||||||
timeout: int, verify_tls: bool) -> Dict[str, Any]:
|
timeout: int, verify_tls: bool) -> Dict[str, Any]:
|
||||||
"""
|
|
||||||
Shared HTTP helper for GET/POST requests.
|
|
||||||
"""
|
|
||||||
req = urlrequest.Request(url, data=body, method=method.upper())
|
req = urlrequest.Request(url, data=body, method=method.upper())
|
||||||
for name, value in headers.items():
|
for name, value in headers.items():
|
||||||
req.add_header(name, value)
|
req.add_header(name, value)
|
||||||
@ -361,7 +307,7 @@ def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[st
|
|||||||
except urlerror.HTTPError as exc:
|
except urlerror.HTTPError as exc:
|
||||||
status = exc.getcode()
|
status = exc.getcode()
|
||||||
response_body = exc.read().decode("utf-8", errors="replace")
|
response_body = exc.read().decode("utf-8", errors="replace")
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "request_failed",
|
"error": "request_failed",
|
||||||
@ -395,9 +341,7 @@ def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[st
|
|||||||
"response": decoded if decoded is not None else response_body,
|
"response": decoded if decoded is not None else response_body,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _ensure_env_loaded() -> None:
|
def _ensure_env_loaded() -> None:
|
||||||
"""Populate os.environ from executor/.env if variables are missing."""
|
|
||||||
if os.getenv("PROJECT_UUID") and os.getenv("PROJECT_ID"):
|
if os.getenv("PROJECT_UUID") and os.getenv("PROJECT_ID"):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
102
core/tasks.py
102
core/tasks.py
@ -6,9 +6,18 @@ from ai.local_ai_api import LocalAIApi
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
import html2text
|
import html2text
|
||||||
import logging
|
import logging
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_base_url(url):
|
||||||
|
parsed = urlparse(url)
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}/"
|
||||||
|
|
||||||
@shared_task(bind=True, max_retries=3)
|
@shared_task(bind=True, max_retries=3)
|
||||||
def process_bookmark(self, bookmark_id):
|
def process_bookmark(self, bookmark_id):
|
||||||
try:
|
try:
|
||||||
@ -16,14 +25,43 @@ def process_bookmark(self, bookmark_id):
|
|||||||
except Bookmark.DoesNotExist:
|
except Bookmark.DoesNotExist:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
html_content = ""
|
||||||
|
status_code = None
|
||||||
|
content_type = None
|
||||||
|
used_backup = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
|
with httpx.Client(follow_redirects=True, timeout=20.0, headers=DEFAULT_HEADERS) as client:
|
||||||
response = client.get(bookmark.url)
|
response = client.get(bookmark.url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html_content = response.text
|
html_content = response.text
|
||||||
|
status_code = response.status_code
|
||||||
|
content_type = response.headers.get('content-type')
|
||||||
|
|
||||||
|
# If content is too small, maybe it's a redirect or anti-bot page
|
||||||
|
if len(html_content) < 500:
|
||||||
|
raise ValueError("Content too small, likely failed to scrape meaningful data.")
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Error fetching bookmark {bookmark_id}: {exc}")
|
logger.warning(f"Error fetching bookmark {bookmark_id} ({bookmark.url}): {exc}. Trying base domain backup.")
|
||||||
raise self.retry(exc=exc, countdown=60)
|
try:
|
||||||
|
base_url = get_base_url(bookmark.url)
|
||||||
|
if base_url.rstrip('/') != bookmark.url.rstrip('/'):
|
||||||
|
with httpx.Client(follow_redirects=True, timeout=20.0, headers=DEFAULT_HEADERS) as client:
|
||||||
|
response = client.get(base_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
html_content = response.text
|
||||||
|
status_code = response.status_code
|
||||||
|
content_type = response.headers.get('content-type')
|
||||||
|
used_backup = True
|
||||||
|
else:
|
||||||
|
if not html_content:
|
||||||
|
raise exc
|
||||||
|
except Exception as base_exc:
|
||||||
|
logger.error(f"Error fetching base domain for bookmark {bookmark_id}: {base_exc}")
|
||||||
|
if not html_content:
|
||||||
|
html_content = f"<html><body><p>Failed to retrieve content from {bookmark.url} and its base domain.</p></body></html>"
|
||||||
|
status_code = status_code or 0
|
||||||
|
|
||||||
soup = BeautifulSoup(html_content, 'html.parser')
|
soup = BeautifulSoup(html_content, 'html.parser')
|
||||||
|
|
||||||
@ -46,8 +84,9 @@ def process_bookmark(self, bookmark_id):
|
|||||||
'content_html': html_content,
|
'content_html': html_content,
|
||||||
'content_text': text_content,
|
'content_text': text_content,
|
||||||
'metadata': {
|
'metadata': {
|
||||||
'status_code': response.status_code,
|
'status_code': status_code,
|
||||||
'content_type': response.headers.get('content-type'),
|
'content_type': content_type,
|
||||||
|
'used_backup': used_backup,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -62,15 +101,30 @@ def generate_summary(bookmark_id):
|
|||||||
try:
|
try:
|
||||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||||
extraction = bookmark.extraction
|
extraction = bookmark.extraction
|
||||||
except (Bookmark.DoesNotExist, Extraction.DoesNotExist):
|
except Bookmark.DoesNotExist:
|
||||||
|
return
|
||||||
|
except Extraction.DoesNotExist:
|
||||||
|
# If extraction doesn't exist yet, we might want to wait or just return
|
||||||
|
# But in EAGER mode it should be there.
|
||||||
return
|
return
|
||||||
|
|
||||||
if not extraction.content_text:
|
content_to_summarize = extraction.content_text.strip()
|
||||||
|
used_backup = extraction.metadata.get('used_backup', False)
|
||||||
|
|
||||||
|
if not content_to_summarize or len(content_to_summarize) < 50:
|
||||||
|
Summary.objects.update_or_create(
|
||||||
|
bookmark=bookmark,
|
||||||
|
defaults={'content': f"Insufficient content extracted from {bookmark.url} to generate a meaningful AI summary."}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Prepare prompt for AI
|
# Prepare prompt for AI
|
||||||
prompt = f"Summarize the following content from the webpage '{bookmark.title or bookmark.url}' in 2-3 concise sentences. Focus on the main points for a researcher.\n\nContent:\n{extraction.content_text[:4000]}"
|
if used_backup:
|
||||||
|
prompt = f"The specific page '{bookmark.url}' could not be reached. Summarize the main domain front page content instead to describe what this website is about.\n\nContent:\n{content_to_summarize[:4000]}"
|
||||||
|
else:
|
||||||
|
prompt = f"Summarize the following content from the webpage '{bookmark.title or bookmark.url}' in 2-3 concise sentences. Focus on the main points for a researcher.\n\nContent:\n{content_to_summarize[:4000]}"
|
||||||
|
|
||||||
|
try:
|
||||||
response = LocalAIApi.create_response({
|
response = LocalAIApi.create_response({
|
||||||
"input": [
|
"input": [
|
||||||
{"role": "system", "content": "You are a helpful assistant that summarizes web content for researchers and knowledge workers. Be concise and professional."},
|
{"role": "system", "content": "You are a helpful assistant that summarizes web content for researchers and knowledge workers. Be concise and professional."},
|
||||||
@ -78,14 +132,38 @@ def generate_summary(bookmark_id):
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
summary_text = None
|
||||||
if response.get("success"):
|
if response.get("success"):
|
||||||
summary_text = LocalAIApi.extract_text(response)
|
summary_text = LocalAIApi.extract_text(response)
|
||||||
if summary_text:
|
|
||||||
|
if summary_text and len(summary_text.strip()) > 10:
|
||||||
Summary.objects.update_or_create(
|
Summary.objects.update_or_create(
|
||||||
bookmark=bookmark,
|
bookmark=bookmark,
|
||||||
defaults={'content': summary_text}
|
defaults={'content': summary_text.strip()}
|
||||||
)
|
)
|
||||||
return f"Generated summary for bookmark {bookmark_id}"
|
return f"Generated summary for bookmark {bookmark_id}"
|
||||||
|
else:
|
||||||
|
error_msg = response.get('error') or "Empty response from AI"
|
||||||
|
logger.error(f"Failed to generate summary for bookmark {bookmark_id}: {error_msg}")
|
||||||
|
|
||||||
logger.error(f"Failed to generate summary for bookmark {bookmark_id}: {response.get('error')}")
|
# Create a fallback summary to stop the spinner
|
||||||
return f"Failed to generate summary for bookmark {bookmark_id}"
|
fallback_content = "AI summary could not be generated at this time. "
|
||||||
|
if used_backup:
|
||||||
|
fallback_content += "The original page was unreachable, and the home page content was insufficient for a summary."
|
||||||
|
elif bookmark.title:
|
||||||
|
fallback_content += f"The page appears to be titled '{bookmark.title}'."
|
||||||
|
else:
|
||||||
|
fallback_content += f"Please visit the link directly: {bookmark.url}"
|
||||||
|
|
||||||
|
Summary.objects.update_or_create(
|
||||||
|
bookmark=bookmark,
|
||||||
|
defaults={'content': fallback_content}
|
||||||
|
)
|
||||||
|
return f"Failed to generate summary for bookmark {bookmark_id}, created fallback."
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Unexpected error in generate_summary for bookmark {bookmark_id}: {e}")
|
||||||
|
Summary.objects.update_or_create(
|
||||||
|
bookmark=bookmark,
|
||||||
|
defaults={'content': "An unexpected error occurred while generating the AI summary."}
|
||||||
|
)
|
||||||
|
return f"Error in generate_summary for bookmark {bookmark_id}"
|
||||||
@ -16,21 +16,29 @@
|
|||||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
<h1 class="h2">{{ bookmark.title|default:bookmark.url }}</h1>
|
<h1 class="h2">{{ bookmark.title|default:bookmark.url }}</h1>
|
||||||
{% if bookmark.user == request.user %}
|
{% if bookmark.user == request.user %}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<form action="{% url 'bookmark-regenerate' bookmark.pk %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm" title="Regenerate Summary and Content">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Regenerate
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="dropdown">
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="dropdown">
|
||||||
<i class="bi bi-three-dots"></i>
|
<i class="bi bi-three-dots"></i>
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<li><a class="dropdown-item" href="{% url 'bookmark-edit' bookmark.pk %}">Edit</a></li>
|
<li><a class="dropdown-item" href="{% url 'bookmark-edit' bookmark.pk %}">Edit Metadata</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li>
|
<li>
|
||||||
<form action="{% url 'bookmark-delete' bookmark.pk %}" method="post" onsubmit="return confirm('Are you sure?');">
|
<form action="{% url 'bookmark-delete' bookmark.pk %}" method="post" onsubmit="return confirm('Are you sure?');">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="dropdown-item text-danger">Delete</button>
|
<button type="submit" class="dropdown-item text-danger">Delete Bookmark</button>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -47,19 +55,37 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if bookmark.summary %}
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h5 class="text-uppercase small fw-bold text-muted mb-2">AI Summary</h5>
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<div class="p-3 border rounded shadow-sm bg-white">
|
<h5 class="text-uppercase small fw-bold text-muted mb-0">AI Summary</h5>
|
||||||
|
{% if bookmark.user == request.user and bookmark.summary %}
|
||||||
|
<button class="btn btn-link btn-sm p-0 text-decoration-none" onclick="toggleEdit('summary')">Edit</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if bookmark.summary %}
|
||||||
|
<div id="summary-display" class="p-3 border rounded shadow-sm bg-white">
|
||||||
{{ bookmark.summary.content }}
|
{{ bookmark.summary.content }}
|
||||||
</div>
|
</div>
|
||||||
|
{% if bookmark.user == request.user %}
|
||||||
|
<div id="summary-edit" class="d-none">
|
||||||
|
<form action="{% url 'summary-update' bookmark.pk %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<textarea name="content" class="form-control mb-2" rows="4">{{ bookmark.summary.content }}</textarea>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleEdit('summary')">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-light border text-center small py-3">
|
<div class="alert alert-light border text-center small py-3">
|
||||||
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>
|
||||||
AI Summary is being generated...
|
AI Summary is being generated...
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
{% for tag in bookmark.tags.all %}
|
{% for tag in bookmark.tags.all %}
|
||||||
@ -68,14 +94,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if bookmark.extraction %}
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<h5 class="text-uppercase small fw-bold text-muted mb-3">Extracted Text Content</h5>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div class="extraction-content text-muted small" style="max-height: 500px; overflow-y: auto;">
|
<h5 class="text-uppercase small fw-bold text-muted mb-0">Extracted Text Content</h5>
|
||||||
|
{% if bookmark.user == request.user and bookmark.extraction %}
|
||||||
|
<button class="btn btn-link btn-sm p-0 text-decoration-none" onclick="toggleEdit('extraction')">Edit</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if bookmark.extraction %}
|
||||||
|
<div id="extraction-display" class="extraction-content text-muted small" style="max-height: 500px; overflow-y: auto;">
|
||||||
{{ bookmark.extraction.content_text|linebreaks }}
|
{{ bookmark.extraction.content_text|linebreaks }}
|
||||||
</div>
|
</div>
|
||||||
|
{% if bookmark.user == request.user %}
|
||||||
|
<div id="extraction-edit" class="d-none">
|
||||||
|
<form action="{% url 'extraction-update' bookmark.pk %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<textarea name="content_text" class="form-control mb-2" rows="15">{{ bookmark.extraction.content_text }}</textarea>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleEdit('extraction')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-light border text-center small py-3">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>
|
||||||
|
Content is being extracted...
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@ -121,6 +170,18 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
|
function toggleEdit(type) {
|
||||||
|
const display = document.getElementById(type + '-display');
|
||||||
|
const edit = document.getElementById(type + '-edit');
|
||||||
|
if (display.classList.contains('d-none')) {
|
||||||
|
display.classList.remove('d-none');
|
||||||
|
edit.classList.add('d-none');
|
||||||
|
} else {
|
||||||
|
display.classList.add('d-none');
|
||||||
|
edit.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.share-toggle').forEach(button => {
|
document.querySelectorAll('.share-toggle').forEach(button => {
|
||||||
button.addEventListener('click', async function() {
|
button.addEventListener('click', async function() {
|
||||||
const url = this.getAttribute('data-url');
|
const url = this.getAttribute('data-url');
|
||||||
|
|||||||
@ -4,7 +4,8 @@ from core.api_views import BookmarkViewSet, TeamViewSet, ApiStatusView
|
|||||||
from core.views import (
|
from core.views import (
|
||||||
BookmarkListView, BookmarkCreateView, BookmarkDetailView,
|
BookmarkListView, BookmarkCreateView, BookmarkDetailView,
|
||||||
BookmarkUpdateView, BookmarkDeleteView,
|
BookmarkUpdateView, BookmarkDeleteView,
|
||||||
TeamListView, TeamDetailView, BookmarkShareToggleView
|
TeamListView, TeamDetailView, BookmarkShareToggleView,
|
||||||
|
BookmarkRegenerateView, SummaryUpdateView, ExtractionUpdateView
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@ -17,6 +18,9 @@ urlpatterns = [
|
|||||||
path("bookmark/<int:pk>/", BookmarkDetailView.as_view(), name="bookmark-detail"),
|
path("bookmark/<int:pk>/", BookmarkDetailView.as_view(), name="bookmark-detail"),
|
||||||
path("bookmark/<int:pk>/edit/", BookmarkUpdateView.as_view(), name="bookmark-edit"),
|
path("bookmark/<int:pk>/edit/", BookmarkUpdateView.as_view(), name="bookmark-edit"),
|
||||||
path("bookmark/<int:pk>/delete/", BookmarkDeleteView.as_view(), name="bookmark-delete"),
|
path("bookmark/<int:pk>/delete/", BookmarkDeleteView.as_view(), name="bookmark-delete"),
|
||||||
|
path("bookmark/<int:pk>/regenerate/", BookmarkRegenerateView.as_view(), name="bookmark-regenerate"),
|
||||||
|
path("bookmark/<int:pk>/summary/update/", SummaryUpdateView.as_view(), name="summary-update"),
|
||||||
|
path("bookmark/<int:pk>/extraction/update/", ExtractionUpdateView.as_view(), name="extraction-update"),
|
||||||
path("bookmark/<int:pk>/share/<int:team_id>/", BookmarkShareToggleView.as_view(), name="bookmark-share-toggle"),
|
path("bookmark/<int:pk>/share/<int:team_id>/", BookmarkShareToggleView.as_view(), name="bookmark-share-toggle"),
|
||||||
|
|
||||||
path("teams/", TeamListView.as_view(), name="team-list"),
|
path("teams/", TeamListView.as_view(), name="team-list"),
|
||||||
|
|||||||
@ -2,10 +2,10 @@ from django.shortcuts import render, redirect, get_object_or_404
|
|||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
|
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy, reverse
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse, HttpResponseRedirect
|
||||||
from .models import Bookmark, Team, Extraction, BookmarkShare
|
from .models import Bookmark, Team, Extraction, BookmarkShare, Summary
|
||||||
from .tasks import process_bookmark
|
from .tasks import process_bookmark
|
||||||
|
|
||||||
class BookmarkListView(LoginRequiredMixin, ListView):
|
class BookmarkListView(LoginRequiredMixin, ListView):
|
||||||
@ -52,9 +52,6 @@ class BookmarkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
form.instance.user = self.request.user
|
form.instance.user = self.request.user
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
# Handle tags if provided in a separate field or as a comma-separated string
|
# Handle tags if provided in a separate field or as a comma-separated string
|
||||||
# For simplicity, we'll assume the model's TaggableManager handles it if added to fields,
|
|
||||||
# but here we might need to handle it manually if we use a custom field.
|
|
||||||
# Let's add 'tags' to fields in the actual form.
|
|
||||||
tags = self.request.POST.get('tags_input')
|
tags = self.request.POST.get('tags_input')
|
||||||
if tags:
|
if tags:
|
||||||
form.instance.tags.add(*[t.strip() for t in tags.split(',')])
|
form.instance.tags.add(*[t.strip() for t in tags.split(',')])
|
||||||
@ -66,7 +63,9 @@ class BookmarkUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
model = Bookmark
|
model = Bookmark
|
||||||
fields = ['url', 'title', 'notes', 'is_favorite']
|
fields = ['url', 'title', 'notes', 'is_favorite']
|
||||||
template_name = 'core/bookmark_form.html'
|
template_name = 'core/bookmark_form.html'
|
||||||
success_url = reverse_lazy('home')
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('bookmark-detail', kwargs={'pk': self.object.pk})
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Bookmark.objects.filter(user=self.request.user)
|
return Bookmark.objects.filter(user=self.request.user)
|
||||||
@ -91,6 +90,40 @@ class BookmarkDetailView(LoginRequiredMixin, DetailView):
|
|||||||
Q(shares__team__in=user_teams)
|
Q(shares__team__in=user_teams)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
class BookmarkRegenerateView(LoginRequiredMixin, View):
|
||||||
|
def post(self, request, pk):
|
||||||
|
bookmark = get_object_or_404(Bookmark, pk=pk, user=request.user)
|
||||||
|
# Delete existing summary and extraction to force regeneration and show loading states
|
||||||
|
if hasattr(bookmark, 'summary'):
|
||||||
|
bookmark.summary.delete()
|
||||||
|
if hasattr(bookmark, 'extraction'):
|
||||||
|
bookmark.extraction.delete()
|
||||||
|
|
||||||
|
process_bookmark.delay(bookmark.id)
|
||||||
|
return HttpResponseRedirect(reverse('bookmark-detail', args=[pk]))
|
||||||
|
|
||||||
|
class SummaryUpdateView(LoginRequiredMixin, View):
|
||||||
|
def post(self, request, pk):
|
||||||
|
bookmark = get_object_or_404(Bookmark, pk=pk, user=request.user)
|
||||||
|
content = request.POST.get('content')
|
||||||
|
if content:
|
||||||
|
Summary.objects.update_or_create(
|
||||||
|
bookmark=bookmark,
|
||||||
|
defaults={'content': content}
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(reverse('bookmark-detail', args=[pk]))
|
||||||
|
|
||||||
|
class ExtractionUpdateView(LoginRequiredMixin, View):
|
||||||
|
def post(self, request, pk):
|
||||||
|
bookmark = get_object_or_404(Bookmark, pk=pk, user=request.user)
|
||||||
|
content_text = request.POST.get('content_text')
|
||||||
|
if content_text:
|
||||||
|
Extraction.objects.update_or_create(
|
||||||
|
bookmark=bookmark,
|
||||||
|
defaults={'content_text': content_text}
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(reverse('bookmark-detail', args=[pk]))
|
||||||
|
|
||||||
class TeamListView(LoginRequiredMixin, ListView):
|
class TeamListView(LoginRequiredMixin, ListView):
|
||||||
model = Team
|
model = Team
|
||||||
template_name = 'core/team_list.html'
|
template_name = 'core/team_list.html'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user