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
121 changes: 120 additions & 1 deletion src/api/routers/community.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from pathlib import Path
from typing import Annotated, Any, Literal

from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi import APIRouter, Header, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, StreamingResponse
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.messages.utils import count_tokens_approximately
Expand All @@ -34,6 +34,7 @@
from src.assistants.registry import AssistantInfo
from src.core.config.community import WidgetConfig
from src.core.services.litellm_llm import create_openrouter_llm
from src.knowledge.search import FAQResult, list_faq_entries
from src.metrics.cost import COST_BLOCK_THRESHOLD, COST_WARN_THRESHOLD, MODEL_PRICING, estimate_cost
from src.metrics.db import (
RequestLogEntry,
Expand Down Expand Up @@ -205,6 +206,58 @@ class CommunityConfigResponse(BaseModel):
status: str = Field(..., description="Health status: healthy, degraded, or error")


class FAQEntryResponse(BaseModel):
"""A single FAQ entry exposed via the public feed."""

question: str = Field(..., description="Synthesized question")
answer: str = Field(..., description="Synthesized answer")
tags: list[str] = Field(default_factory=list, description="Keyword tags")
category: str = Field(..., description="Entry category (how-to, troubleshooting, etc.)")
quality_score: float = Field(..., description="LLM quality score (0.0-1.0)")
message_count: int = Field(..., description="Number of source messages in the thread")
first_message_date: str = Field(..., description="Date of the first message in the thread")
thread_url: str = Field(..., description="URL of the source discussion thread")


class FAQFeedResponse(BaseModel):
"""Paginated public FAQ feed for a community."""

community_id: str = Field(..., description="Community identifier")
total: int = Field(..., description="Total entries matching the filters")
limit: int = Field(..., description="Page size used for this response")
offset: int = Field(..., description="Offset used for this response")
entries: list[FAQEntryResponse] = Field(default_factory=list, description="FAQ entries")


# Matches bare email addresses so they can be stripped from the public feed.
_EMAIL_PATTERN = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")


def _redact_emails(text: str) -> str:
"""Replace any email address in ``text`` with a redaction marker.

The FAQ feed is derived from public mailing-list content. The summarizer
strips most personal data, but a handful of entries still embed addresses
(mostly vendor support lines). A public JSON feed should not emit raw
addresses, so they are redacted at serialization time.
"""
return _EMAIL_PATTERN.sub("[email redacted]", text)


def _faq_result_to_response(entry: FAQResult) -> FAQEntryResponse:
"""Convert a knowledge-layer FAQResult into a public response model."""
return FAQEntryResponse(
question=_redact_emails(entry.question),
answer=_redact_emails(entry.answer),
tags=[_redact_emails(tag) for tag in entry.tags],
category=entry.category,
quality_score=entry.quality_score,
message_count=entry.message_count,
first_message_date=entry.first_message_date,
thread_url=entry.thread_url,
)


# ---------------------------------------------------------------------------
# Session Management (In-Memory, per-community isolation)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1502,6 +1555,72 @@ async def community_usage_public(
detail="Metrics database is temporarily unavailable.",
)

@router.get("/faq", response_model=FAQFeedResponse)
async def community_faq(
response: Response,
q: str | None = Query(
default=None,
description="Optional full-text search phrase. If omitted, browses all entries.",
max_length=200,
),
category: str | None = Query(
default=None,
description="Filter by category (how-to, troubleshooting, reference, etc.)",
max_length=50,
),
min_quality: float = Query(
default=0.0, ge=0.0, le=1.0, description="Minimum quality score"
),
limit: int = Query(default=50, ge=1, le=200, description="Page size"),
offset: int = Query(default=0, ge=0, description="Pagination offset"),
) -> FAQFeedResponse:
"""Public, read-only FAQ feed for this community.

Returns synthesized question/answer entries generated from the
community's mailing-list and forum archives. Disabled by default;
a community opts in via ``public_feeds.faq: true`` in its config.
Email addresses are redacted from the output. ``total`` is the full
match count before pagination, in both browse and search modes.
"""
config = info.community_config
if config is None or config.public_feeds is None or not config.public_feeds.faq:
raise HTTPException(
status_code=404,
detail="Public FAQ feed is not enabled for this community.",
)

try:
entries, total = list_faq_entries(
project=community_id,
limit=limit,
offset=offset,
query=q,
category=category,
min_quality=min_quality,
)
except sqlite3.Error:
logger.exception("Failed to query FAQ feed for community %s", community_id)
raise HTTPException(
status_code=503,
detail="Knowledge database is temporarily unavailable.",
)
except Exception:
logger.exception("Unexpected error serving FAQ feed for community %s", community_id)
raise HTTPException(
status_code=500,
detail="An unexpected error occurred while building the FAQ feed.",
)

# Public, read-only data; cacheable like the other /…/public endpoints.
response.headers["Cache-Control"] = "public, max-age=3600"
return FAQFeedResponse(
community_id=community_id,
total=total,
limit=limit,
offset=offset,
entries=[_faq_result_to_response(e) for e in entries],
)

return router


Expand Down
20 changes: 20 additions & 0 deletions src/core/config/community.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,23 @@ def validate_agent_roles(self) -> "FAQGenerationConfig":
return self


class PublicFeedsConfig(BaseModel):
"""Opt-in flags for exposing community data as public, read-only JSON feeds.

Both feeds are off by default. Enabling a feed publishes already-synced
data (FAQ entries, citation counts) at unauthenticated endpoints so
communities can build their own frontends on top of it.
"""

model_config = ConfigDict(extra="forbid")

faq: bool = False
"""Expose generated FAQ entries at GET /{community_id}/faq."""

citations: bool = False
"""Expose canonical-paper citation counts at GET /{community_id}/citations."""


class BudgetConfig(BaseModel):
"""Budget limits and alert thresholds for a community.

Expand Down Expand Up @@ -918,6 +935,9 @@ def validate_id(cls, v: str) -> str:
faq_generation: FAQGenerationConfig | None = None
"""FAQ generation configuration from threaded discussions (mailman, discourse, etc.)."""

public_feeds: PublicFeedsConfig | None = None
"""Opt-in flags for exposing FAQ/citation data as public JSON feeds."""

sync: SyncConfig | None = None
"""Per-community sync schedule configuration.

Expand Down
129 changes: 128 additions & 1 deletion src/knowledge/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,28 @@ class FAQResult:
first_message_date: str


def _parse_faq_tags(raw: str | None, *, thread_url: str, project: str) -> list[str]:
"""Decode a FAQ entry's JSON ``tags`` column, tolerating malformed data.

The column is written by the summarizer as a JSON array. A corrupt value
should degrade to an empty tag list (and a warning) rather than raise a
``JSONDecodeError`` that escapes the sqlite handlers and surfaces as an
unlogged 500 at the API layer.
"""
if not raw:
return []
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
logger.warning(
"Invalid JSON in FAQ tags (thread_url=%s, project=%s): %r",
thread_url,
project,
raw,
)
return []


def search_faq_entries(
query: str,
project: str = "eeglab",
Expand Down Expand Up @@ -845,7 +867,7 @@ def search_faq_entries(
params[0] = safe_query

for row in conn.execute(sql, params):
tags = json.loads(row["tags"]) if row["tags"] else []
tags = _parse_faq_tags(row["tags"], thread_url=row["thread_url"], project=project)

results.append(
FAQResult(
Expand Down Expand Up @@ -876,6 +898,111 @@ def search_faq_entries(
return results


def list_faq_entries(
project: str = "eeglab",
limit: int = 50,
offset: int = 0,
query: str | None = None,
list_name: str | None = None,
category: str | None = None,
min_quality: float = 0.0,
) -> tuple[list[FAQResult], int]:
"""List FAQ entries for the public feed, with pagination metadata.

Serves both browse mode (no ``query``) and search mode (``query`` set, via
FTS5). Unlike :func:`search_faq_entries`, this always returns the full
matching ``total`` count computed before LIMIT/OFFSET, so callers can
paginate correctly in either mode.

Args:
project: Community ID for database isolation. Defaults to 'eeglab'.
limit: Maximum number of entries to return.
offset: Number of entries to skip (for pagination).
query: Optional full-text search phrase. When omitted, all entries
matching the filters are browsed, ordered by quality then recency.
list_name: Filter by mailing list name.
category: Filter by category (e.g., 'troubleshooting', 'how-to').
min_quality: Minimum quality score (0.0-1.0).

Returns:
Tuple of (entries, total_count) where total_count is the number of
entries matching the query and filters before limit/offset are applied.
"""
use_fts = bool(query and query.strip())

leading_params: list[str | int | float] = []
if use_fts:
from_clause = "faq_entries_fts fts JOIN faq_entries f ON fts.rowid = f.id"
where_clause = "faq_entries_fts MATCH ?"
order_clause = "f.quality_score DESC, rank"
# Sanitize to prevent FTS5 injection (query is guaranteed non-None here).
leading_params.append(_sanitize_fts5_query(query)) # type: ignore[arg-type]
else:
from_clause = "faq_entries f"
where_clause = "1=1"
order_clause = "f.quality_score DESC, f.first_message_date DESC"

filters = ""
filter_params: list[str | int | float] = []
if list_name:
filters += " AND f.list_name = ?"
filter_params.append(list_name)
if category:
filters += " AND f.category = ?"
filter_params.append(category)
if min_quality > 0:
filters += " AND f.quality_score >= ?"
filter_params.append(min_quality)

base_params = [*leading_params, *filter_params]
count_sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}{filters}"
rows_sql = (
"SELECT f.question, f.answer, f.thread_url, f.tags, f.category, "
"f.quality_score, f.message_count, f.first_message_date "
f"FROM {from_clause} WHERE {where_clause}{filters} "
f"ORDER BY {order_clause} LIMIT ? OFFSET ?"
)

results: list[FAQResult] = []
try:
with get_connection(project) as conn:
total = conn.execute(count_sql, base_params).fetchone()[0]

for row in conn.execute(rows_sql, [*base_params, limit, offset]):
tags = _parse_faq_tags(row["tags"], thread_url=row["thread_url"], project=project)
results.append(
FAQResult(
question=row["question"],
answer=row["answer"],
thread_url=row["thread_url"],
tags=tags,
category=row["category"],
quality_score=row["quality_score"],
message_count=row["message_count"],
first_message_date=row["first_message_date"] or "",
)
)
except sqlite3.OperationalError as e:
logger.error(
"Database operational error listing FAQ entries: %s",
e,
exc_info=True,
extra={"project": project},
)
raise
except sqlite3.Error as e:
logger.warning(
"Database error listing FAQ entries (project=%s, limit=%d, offset=%d): %s",
project,
limit,
offset,
e,
)
raise

return results, total


@dataclass
class BEPResult:
"""A BEP search result from the knowledge database."""
Expand Down
Loading
Loading