Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ WORKSPACE_MODE=organization
# RENDER_CACHE_SWEEP=true # delete unreachable cached screens at startup
# A2A_INTERNAL_API_KEY= # a2a profile: key for /ext/v1
# A2A_AUTH_KEY= # a2a profile: bearer token for callers
# GITHUB_TOKEN= # bug reports become issues; fine-grained token, Issues: read and write
# GITHUB_REPO= # owner/name of the repo that receives them
# BUG_REPORT_DAILY_LIMIT=5 # reports a person may send per day (admins: no cap)

# Not listed here: retries, media models, Ollama, MCP, tuning dials.
# All of them: docs/design/configuration.md, with defaults and Docker reachability.
Expand Down
70 changes: 70 additions & 0 deletions apps/skillnet-api/alembic/versions/0038_bug_reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Add ``bug_reports``: problems reported from inside the app, judged by a model.

A genuine report becomes a GitHub issue. The row is written before the judging so nothing
is lost when the model or GitHub fails; ``status`` records the outcome.

Revision ID: 0038
Revises: 0037
"""
from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

revision: str = "0038"
down_revision: str | None = "0037"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.create_table(
"bug_reports",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"org_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("page_path", sa.Text(), nullable=True),
sa.Column("environment", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), nullable=False, server_default="pending"),
sa.Column("verdict", postgresql.JSONB(), nullable=True),
sa.Column("issue_url", sa.Text(), nullable=True),
sa.Column("upstream_receipt", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint(
"status IN ('pending', 'issue', 'discarded', 'failed')",
name="ck_bug_reports_status",
),
)
op.create_index(
"idx_bug_reports_user",
"bug_reports",
["user_id", sa.text("created_at DESC")],
)


def downgrade() -> None:
op.drop_index("idx_bug_reports_user", table_name="bug_reports")
op.drop_table("bug_reports")
8 changes: 8 additions & 0 deletions apps/skillnet-api/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ class Settings(BaseSettings):
GOOGLE_LOGIN_ERROR_PATH: str = "/login"

# LLM (defaults, overridable per org)
# Bug reports: a judged report becomes a GitHub issue on this repo ("owner/name").
# Without a token the reports are still judged and kept, for an admin to file by hand.
GITHUB_TOKEN: str = ""
GITHUB_REPO: str = ""
BUG_REPORT_DAILY_LIMIT: int = 5
# Where a report goes when the admin turned on "share bug reports with the SkillNet team".
UPSTREAM_REPORTS_URL: str = "https://reports.skillnet.es"

LLM_BASE_URL: str = "https://api.openai.com/v1"
LLM_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o-mini"
Expand Down
2 changes: 2 additions & 0 deletions apps/skillnet-api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
ai,
auth,
auth_google,
bug_reports,
chat,
course_folders,
course_schema,
Expand Down Expand Up @@ -226,6 +227,7 @@ def create_app() -> FastAPI:
app.include_router(chat.router, prefix=prefix, tags=["Chat"])
app.include_router(stats.router, prefix=prefix, dependencies=org_only)
app.include_router(settings_routes.router, prefix=prefix)
app.include_router(bug_reports.router, prefix=prefix)
app.include_router(tts.router, prefix=prefix)
# Rich-media artifacts (NotebookLM spine). Additive, own /media prefix.
app.include_router(media.router, prefix=prefix)
Expand Down
3 changes: 3 additions & 0 deletions apps/skillnet-api/src/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from src.models.access_token import AccessToken
from src.models.api_key import ApiKey
from src.models.audit_log import AUDIT_ACTIONS, AuditLog
from src.models.bug_report import BUG_REPORT_STATUSES, BugReport
from src.models.activity_definition import ActivityDefinition, ActivityFamily, ActivityState
from src.models.base import Base, TimestampMixin, UUIDMixin
from src.models.chat_message import ChatMessage
Expand Down Expand Up @@ -169,6 +170,8 @@
"LlmUsageLog",
"USE_CASES",
"AuditLog",
"BugReport",
"BUG_REPORT_STATUSES",
"AUDIT_ACTIONS",
"ActivityDefinition",
"ActivityFamily",
Expand Down
47 changes: 47 additions & 0 deletions apps/skillnet-api/src/models/bug_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""A problem a person reports from inside the app.

The row is written first, before the model judges it or GitHub hears of it, so a report is
never lost when either fails. ``status`` says where it ended up.
"""

import uuid
from datetime import datetime

from sqlalchemy import DateTime, ForeignKey, Index, Text, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

from src.models.base import Base, UUIDMixin

#: pending: waiting to be judged, or judged and waiting for an admin because GitHub is not
#: set up. issue: filed. discarded: judged not genuine. failed: the model or GitHub failed.
BUG_REPORT_STATUSES: tuple[str, ...] = ("pending", "issue", "discarded", "failed")


class BugReport(UUIDMixin, Base):
__tablename__ = "bug_reports"
__table_args__ = (
Index("idx_bug_reports_user", "user_id", text("created_at DESC")),
)

org_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(Text, nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
page_path: Mapped[str | None] = mapped_column(Text, nullable=True)
environment: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
Text, nullable=False, server_default=text("'pending'")
)
# {genuine, severity, kind, title, body, reason, duplicate_of}, or {error}.
verdict: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
issue_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# The receipt the SkillNet relay gave when this report was shared with the team.
upstream_receipt: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=text("now()")
)
56 changes: 56 additions & 0 deletions apps/skillnet-api/src/repositories/bug_report_repo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Data access for ``bug_reports``."""

from __future__ import annotations

import uuid
from collections.abc import Sequence
from datetime import datetime

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from src.models import BugReport
from src.repositories.base import BaseRepository


class BugReportRepository(BaseRepository[BugReport]):
def __init__(self, session: AsyncSession) -> None:
super().__init__(session, BugReport)

async def count_since(self, user_id: uuid.UUID, since: datetime) -> int:
result = await self.session.execute(
select(func.count())
.select_from(BugReport)
.where(BugReport.user_id == user_id, BugReport.created_at >= since)
)
return int(result.scalar_one())

async def count_for_issue(self, issue_url: str) -> int:
result = await self.session.execute(
select(func.count())
.select_from(BugReport)
.where(BugReport.issue_url == issue_url)
)
return int(result.scalar_one())

async def list_for_user(
self, user_id: uuid.UUID, *, limit: int = 50
) -> Sequence[BugReport]:
result = await self.session.execute(
select(BugReport)
.where(BugReport.user_id == user_id)
.order_by(BugReport.created_at.desc())
.limit(limit)
)
return result.scalars().all()

async def list_for_org(
self, org_id: uuid.UUID, *, limit: int = 200
) -> Sequence[BugReport]:
result = await self.session.execute(
select(BugReport)
.where(BugReport.org_id == org_id)
.order_by(BugReport.created_at.desc())
.limit(limit)
)
return result.scalars().all()
Loading
Loading