feat(feedback): implement ChipDip-style floating dev feedback & bug report widget with markdown sync

This commit is contained in:
2026-10-08 11:53:18 +03:00
parent e1cacde543
commit 8cffae46fa
11 changed files with 1104 additions and 3 deletions
+3
View File
@@ -9,6 +9,7 @@ from app.routers.cefr import router as cefr_router
from app.routers.grammar import router as grammar_router
from app.routers.tasks import router as tasks_router
from app.routers.lessons import router as lessons_router
from app.routers.feedback import router as feedback_router
all_routers = [
auth_router,
@@ -22,6 +23,7 @@ all_routers = [
grammar_router,
tasks_router,
lessons_router,
feedback_router,
]
__all__ = [
@@ -37,4 +39,5 @@ __all__ = [
"grammar_router",
"tasks_router",
"lessons_router",
"feedback_router",
]
+234
View File
@@ -0,0 +1,234 @@
import json
import logging
from pathlib import Path
from typing import Optional, Dict, Any, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from app.auth import require_user
from app.database import get_db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/feedback", tags=["Feedback & Bug Tracker"])
# Path to workspace memory file for seamless Antigravity AI reading
MEMORY_REPORTS_FILE = Path("/home/ai-projects/memory/USER_REPORTS.md")
class FeedbackCreateRequest(BaseModel):
category: str = Field(default="bug", description="Category: 'bug', 'feature', 'note'")
message: str = Field(..., min_length=1, max_length=5000)
context_url: Optional[str] = Field(default="", max_length=500)
context_data: Optional[Dict[str, Any]] = None
class FeedbackStatusUpdateRequest(BaseModel):
status: str = Field(..., pattern="^(new|in_progress|resolved|closed)$")
def sync_reports_to_markdown(conn):
"""
Synchronizes feedback_reports from SQLite to /home/ai-projects/memory/USER_REPORTS.md.
This enables Antigravity and AI agents to instantly inspect user-reported bugs and feature requests.
"""
try:
rows = conn.execute("""
SELECT f.id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username
FROM feedback_reports f
JOIN users u ON f.user_id = u.id
ORDER BY f.id DESC
""").fetchall()
lines = [
"# 📋 Пользовательские репорты & Запросы на доработку (study.qpid.pro)",
"",
"> 🔄 Данный файл автоматически синхронизируется с виджетом обратной связи на сайте.",
"> AI-ассистент (Antigravity) считывает этот файл для анализа найденных багов и планирования задач.",
"",
f"**Всего репортов:** {len(rows)} | **Последняя синхронизация:** сейчас",
"",
"## 🔴 Активные репорты (Требуют внимания / В работе)",
"",
]
active_rows = [r for r in rows if r["status"] in ("new", "in_progress")]
resolved_rows = [r for r in rows if r["status"] in ("resolved", "closed")]
if not active_rows:
lines.append("🎉 *Активных багов или задач нет! Все известные репорты закрыты.*")
lines.append("")
else:
for r in active_rows:
cat_emoji = "🐛 БАГ" if r["category"] == "bug" else ("💡 ФИЧА" if r["category"] == "feature" else "📝 ЗАМЕТКА")
status_icon = "🆕 Новый" if r["status"] == "new" else "⏳ В работе"
ctx_extra = ""
if r["context_data_json"]:
try:
c_dict = json.loads(r["context_data_json"])
parts = []
if "screen" in c_dict: parts.append(f"Экран: {c_dict['screen']}")
if "device" in c_dict: parts.append(f"Устройство: {c_dict['device']}")
if parts: ctx_extra = f" ({', '.join(parts)})"
except Exception:
pass
lines.append(f"### #{r['id']} [{cat_emoji}] — {status_icon} (`{r['created_at']}`)")
lines.append(f"- **Пользователь**: `{r['username']}`")
if r["context_url"]:
lines.append(f"- **Раздел/Страница**: `{r['context_url']}`{ctx_extra}")
lines.append(f"- **Сообщение**:\n> {r['message'].strip()}")
lines.append("")
lines.extend([
"---",
"",
"## ✅ Решенные / Закрытые задачи",
"",
])
if not resolved_rows:
lines.append("*Пока нет закрытых репортов.*")
lines.append("")
else:
for r in resolved_rows:
cat_emoji = "🐛" if r["category"] == "bug" else ("💡" if r["category"] == "feature" else "📝")
lines.append(f"- [x] **#{r['id']} {cat_emoji} ({r['created_at']})**: {r['message'].strip()} *(Страница: {r['context_url'] or 'общая'})*")
lines.append("")
MEMORY_REPORTS_FILE.parent.mkdir(parents=True, exist_ok=True)
MEMORY_REPORTS_FILE.write_text("\n".join(lines), encoding="utf-8")
except Exception as e:
logger.warning(f"Could not sync reports to markdown: {e}")
@router.get("", response_model=List[Dict[str, Any]])
def list_feedback(
category: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
user: dict = Depends(require_user),
):
"""Lists feedback reports. Admins see all reports; regular users see their own."""
is_admin = bool(user.get("is_admin"))
with get_db() as conn:
query = """
SELECT f.id, f.user_id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username
FROM feedback_reports f
JOIN users u ON f.user_id = u.id
WHERE 1=1
"""
params = []
if not is_admin:
query += " AND f.user_id = ?"
params.append(user["id"])
if category:
query += " AND f.category = ?"
params.append(category)
if status_filter:
query += " AND f.status = ?"
params.append(status_filter)
query += " ORDER BY f.id DESC LIMIT 100"
rows = conn.execute(query, params).fetchall()
result = []
for r in rows:
ctx_data = {}
if r["context_data_json"]:
try:
ctx_data = json.loads(r["context_data_json"])
except Exception:
pass
result.append({
"id": r["id"],
"user_id": r["user_id"],
"username": r["username"],
"category": r["category"],
"message": r["message"],
"context_url": r["context_url"],
"context_data": ctx_data,
"status": r["status"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
})
return result
@router.post("", status_code=status.HTTP_201_CREATED)
def create_feedback(
payload: FeedbackCreateRequest,
user: dict = Depends(require_user),
):
"""Submits a new bug, feature request, or note from the website chat widget."""
allowed_categories = {"bug", "feature", "note"}
category = payload.category if payload.category in allowed_categories else "bug"
ctx_json = json.dumps(payload.context_data or {}, ensure_ascii=False)
with get_db() as conn:
cursor = conn.execute(
"""
INSERT INTO feedback_reports (user_id, category, message, context_url, context_data_json, status)
VALUES (?, ?, ?, ?, ?, 'new')
""",
(user["id"], category, payload.message.strip(), payload.context_url or "", ctx_json),
)
report_id = cursor.lastrowid
sync_reports_to_markdown(conn)
return {
"ok": True,
"id": report_id,
"category": category,
"status": "new",
"message": "Репорт успешно отправлен и передан в систему задач",
}
@router.patch("/{report_id}")
def update_feedback_status(
report_id: int,
payload: FeedbackStatusUpdateRequest,
user: dict = Depends(require_user),
):
"""Updates the status of a report (e.g. 'in_progress', 'resolved')."""
with get_db() as conn:
existing = conn.execute("SELECT id, user_id FROM feedback_reports WHERE id = ?", (report_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Репорт не найден")
is_admin = bool(user.get("is_admin"))
if not is_admin and existing["user_id"] != user["id"]:
raise HTTPException(status_code=403, detail="Недостаточно прав")
conn.execute(
"UPDATE feedback_reports SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(payload.status, report_id),
)
sync_reports_to_markdown(conn)
return {"ok": True, "id": report_id, "status": payload.status}
@router.delete("/{report_id}")
def delete_feedback(
report_id: int,
user: dict = Depends(require_user),
):
"""Deletes a report."""
with get_db() as conn:
existing = conn.execute("SELECT id, user_id FROM feedback_reports WHERE id = ?", (report_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Репорт не найден")
is_admin = bool(user.get("is_admin"))
if not is_admin and existing["user_id"] != user["id"]:
raise HTTPException(status_code=403, detail="Недостаточно прав")
conn.execute("DELETE FROM feedback_reports WHERE id = ?", (report_id,))
sync_reports_to_markdown(conn)
return {"ok": True, "id": report_id}