feat: add backup CLI commands, AI correction/modifier services, and timezone-aware date display
DevPlace CI / test (push) Failing after 25m18s
DevPlace CI / test (push) Failing after 25m18s
- Add `devplace backups` CLI subcommands (list, run, prune, clear) with job enqueueing and orphan cleanup - Introduce `BACKUPS_DIR` and `BACKUP_STAGING_DIR` config paths for backup storage - Implement `schedule_correction` and `schedule_modification` calls in content creation, comment creation, and comment editing flows - Add `DEFAULT_CORRECTION_PROMPT` and `DEFAULT_MODIFIER_PROMPT` config constants for AI content processing - Document timezone-aware date display using `local_dt`/`dt_ago` Jinja globals with client-side `Intl` localization - Update README with AI content correction/modifier support in direct messages via `@ai` inline instructions - Add `track_action(user["uid"], "vote")` call on upvote in `apply_vote`
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from devplacepy.database import (
|
||||
db,
|
||||
get_follow_counts,
|
||||
get_table,
|
||||
get_user_post_count,
|
||||
get_user_rank,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_BIO = 280
|
||||
MAX_EXCERPT = 400
|
||||
MAX_SOURCE = 1500
|
||||
MAX_THREAD_MESSAGES = 6
|
||||
MAX_MESSAGE = 240
|
||||
|
||||
TARGET_TABLES = {
|
||||
"post": "posts",
|
||||
"project": "projects",
|
||||
"gist": "gists",
|
||||
"news": "news",
|
||||
}
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
text = " ".join((text or "").split())
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + "..."
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%d/%m/%Y")
|
||||
|
||||
|
||||
def _author_block(user_uid: str) -> str:
|
||||
user = get_table("users").find_one(uid=user_uid)
|
||||
if not user:
|
||||
return ""
|
||||
stats = [
|
||||
f"role {user.get('role') or 'Member'}",
|
||||
f"level {user.get('level', 1)}",
|
||||
f"{user.get('stars', 0)} stars",
|
||||
]
|
||||
try:
|
||||
stats.append(f"{get_user_post_count(user_uid)} posts")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
rank = get_user_rank(user_uid)
|
||||
if rank:
|
||||
stats.append(f"rank #{rank}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
follows = get_follow_counts(user_uid)
|
||||
stats.append(f"{follows.get('followers', 0)} followers")
|
||||
except Exception:
|
||||
pass
|
||||
joined = (user.get("created_at") or "")[:10]
|
||||
if joined:
|
||||
stats.append(f"member since {joined}")
|
||||
line = f"Author: {user.get('username', '')} ({', '.join(stats)})."
|
||||
bio = _truncate(user.get("bio") or "", MAX_BIO)
|
||||
if bio:
|
||||
line += f' Bio: "{bio}".'
|
||||
return line
|
||||
|
||||
|
||||
def _target_excerpt(target_type: str, target_uid: str) -> str:
|
||||
table = TARGET_TABLES.get(target_type)
|
||||
if not table or table not in db.tables or not target_uid:
|
||||
return ""
|
||||
row = get_table(table).find_one(uid=target_uid)
|
||||
if not row:
|
||||
return ""
|
||||
title = (row.get("title") or "").strip()
|
||||
body = _truncate(row.get("content") or row.get("description") or "", MAX_EXCERPT)
|
||||
label = f'the {target_type} "{title}"' if title else f"a {target_type}"
|
||||
text = f"This is a comment on {label}."
|
||||
if body:
|
||||
text += f' Excerpt: "{body}".'
|
||||
return text
|
||||
|
||||
|
||||
def _comment_block(row: dict) -> str:
|
||||
target_type = row.get("target_type") or "post"
|
||||
target_uid = row.get("target_uid") or row.get("post_uid") or ""
|
||||
text = _target_excerpt(target_type, target_uid)
|
||||
parent_uid = row.get("parent_uid")
|
||||
if parent_uid and "comments" in db.tables:
|
||||
parent = get_table("comments").find_one(uid=parent_uid)
|
||||
if parent:
|
||||
text += f' In reply to: "{_truncate(parent.get("content") or "", MAX_EXCERPT)}".'
|
||||
return text
|
||||
|
||||
|
||||
def _post_block(row: dict) -> str:
|
||||
topic = row.get("topic") or "general"
|
||||
text = f"This is a post (topic: {topic})."
|
||||
project_uid = row.get("project_uid")
|
||||
if project_uid and "projects" in db.tables:
|
||||
project = get_table("projects").find_one(uid=project_uid)
|
||||
if project and project.get("title"):
|
||||
text += f' Attached to the project "{project["title"]}".'
|
||||
return text
|
||||
|
||||
|
||||
def _item_block(table: str, row: dict) -> str:
|
||||
kind = "project" if table == "projects" else "gist"
|
||||
title = (row.get("title") or "").strip()
|
||||
text = f'This is a {kind}' + (f' titled "{title}".' if title else ".")
|
||||
description = _truncate(row.get("description") or "", MAX_EXCERPT)
|
||||
if description:
|
||||
text += f' Description: "{description}".'
|
||||
if table == "gists":
|
||||
language = row.get("language") or "plaintext"
|
||||
source = _truncate(row.get("source_code") or "", MAX_SOURCE)
|
||||
text += f" Language: {language}."
|
||||
if source:
|
||||
text += f" Source code (reference only, do not include unless asked): {source}"
|
||||
return text
|
||||
|
||||
|
||||
def _message_block(row: dict) -> str:
|
||||
sender_uid = row.get("sender_uid")
|
||||
receiver_uid = row.get("receiver_uid")
|
||||
receiver = get_table("users").find_one(uid=receiver_uid) if receiver_uid else None
|
||||
receiver_name = (receiver or {}).get("username") or "the recipient"
|
||||
text = f"This is a direct message to {receiver_name}."
|
||||
if "messages" not in db.tables or not sender_uid or not receiver_uid:
|
||||
return text
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT sender_uid, content FROM messages "
|
||||
"WHERE ((sender_uid = :a AND receiver_uid = :b) "
|
||||
"OR (sender_uid = :b AND receiver_uid = :a)) "
|
||||
"AND uid != :current AND deleted_at IS NULL "
|
||||
"ORDER BY id DESC LIMIT :lim",
|
||||
a=sender_uid,
|
||||
b=receiver_uid,
|
||||
current=row.get("uid") or "",
|
||||
lim=MAX_THREAD_MESSAGES,
|
||||
)
|
||||
)
|
||||
if not rows:
|
||||
return text
|
||||
lines = []
|
||||
for entry in reversed(rows):
|
||||
who = "you" if entry["sender_uid"] == sender_uid else receiver_name
|
||||
lines.append(f"{who}: {_truncate(entry['content'] or '', MAX_MESSAGE)}")
|
||||
return text + " Recent conversation:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _location_block(table: str, row: dict) -> str:
|
||||
if table == "comments":
|
||||
return _comment_block(row)
|
||||
if table == "posts":
|
||||
return _post_block(row)
|
||||
if table in ("projects", "gists"):
|
||||
return _item_block(table, row)
|
||||
if table == "messages":
|
||||
return _message_block(row)
|
||||
return ""
|
||||
|
||||
|
||||
def build_context(table: str, uid: str, row: dict, user_uid: str) -> str:
|
||||
parts = [f"Today is {_today()} on the DevPlace developer network."]
|
||||
try:
|
||||
author = _author_block(user_uid)
|
||||
if author:
|
||||
parts.append(author)
|
||||
except Exception as exc:
|
||||
logger.warning("ai context author block failed: %s", exc)
|
||||
try:
|
||||
location = _location_block(table, row)
|
||||
if location:
|
||||
parts.append(location)
|
||||
except Exception as exc:
|
||||
logger.warning("ai context location block failed: %s", exc)
|
||||
return "\n".join(parts)
|
||||
Reference in New Issue
Block a user