diff --git a/src/api/routers/community.py b/src/api/routers/community.py index 0fc6426..b6e7064 100644 --- a/src/api/routers/community.py +++ b/src/api/routers/community.py @@ -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 @@ -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, @@ -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) # --------------------------------------------------------------------------- @@ -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 diff --git a/src/core/config/community.py b/src/core/config/community.py index 75d01b4..24bd3a2 100644 --- a/src/core/config/community.py +++ b/src/core/config/community.py @@ -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. @@ -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. diff --git a/src/knowledge/search.py b/src/knowledge/search.py index 61563f2..2b3fdb5 100644 --- a/src/knowledge/search.py +++ b/src/knowledge/search.py @@ -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", @@ -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( @@ -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.""" diff --git a/tests/test_api/test_faq_feed.py b/tests/test_api/test_faq_feed.py new file mode 100644 index 0000000..9408a9e --- /dev/null +++ b/tests/test_api/test_faq_feed.py @@ -0,0 +1,256 @@ +"""Tests for the public FAQ feed endpoint: GET /{community_id}/faq. + +Uses a real registered community, a temporary SQLite knowledge database +populated with FAQ rows, and the config gate toggled per test. No business +logic is mocked; only the database path and the opt-in flag are controlled. +""" + +import sqlite3 +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.routers.community import create_community_router +from src.assistants import discover_assistants, registry +from src.core.config.community import PublicFeedsConfig +from src.knowledge.db import get_connection, init_db, upsert_faq_entry + +COMMUNITY_ID = "eeglab" + +discover_assistants() + + +@pytest.fixture +def faq_db(tmp_path: Path) -> Iterator[Path]: + """Temp knowledge DB populated with FAQ entries, including one with an email.""" + db_path = tmp_path / "knowledge" / "test.db" + # Write through the same project the endpoint reads (COMMUNITY_ID) so the + # test does not rely on get_db_path ignoring its project argument. + with patch("src.knowledge.db.get_db_path", return_value=db_path): + init_db(COMMUNITY_ID) + with get_connection(COMMUNITY_ID) as conn: + upsert_faq_entry( + conn, + list_name="eeglablist", + thread_id="t1", + thread_url="https://example.org/t1", + question="How do I run ICA in EEGLAB?", + answer="Use runica from the Tools menu.", + tags=["ica"], + category="how-to", + message_count=3, + participant_count=2, + first_message_date="2020-01-01", + quality_score=0.95, + summary_model="test-model", + ) + # t2 carries an email in the question, the answer, and a tag so the + # endpoint's redaction can be verified across all three fields. + upsert_faq_entry( + conn, + list_name="eeglablist", + thread_id="t2", + thread_url="https://example.org/t2", + question="Who do I contact (e.g. sales@brainproducts.com) for support?", + answer="Email support@brainproducts.com for hardware questions.", + tags=["hardware", "contact:info@vendor.com"], + category="reference", + message_count=2, + participant_count=2, + first_message_date="2021-01-01", + quality_score=0.70, + summary_model="test-model", + ) + conn.commit() + yield db_path + + +@pytest.fixture +def feeds_enabled() -> Iterator[None]: + """Enable public_feeds.faq on the community config, restoring it afterward.""" + info = registry.get(COMMUNITY_ID) + assert info is not None and info.community_config is not None + original = info.community_config.public_feeds + info.community_config.public_feeds = PublicFeedsConfig(faq=True) + try: + yield + finally: + info.community_config.public_feeds = original + + +@pytest.fixture +def feeds_disabled() -> Iterator[None]: + """Force public_feeds off (None), restoring the original afterward.""" + info = registry.get(COMMUNITY_ID) + assert info is not None and info.community_config is not None + original = info.community_config.public_feeds + info.community_config.public_feeds = None + try: + yield + finally: + info.community_config.public_feeds = original + + +@pytest.fixture +def feeds_faq_false() -> Iterator[None]: + """public_feeds present but faq disabled (the non-None gate branch).""" + info = registry.get(COMMUNITY_ID) + assert info is not None and info.community_config is not None + original = info.community_config.public_feeds + info.community_config.public_feeds = PublicFeedsConfig(faq=False) + try: + yield + finally: + info.community_config.public_feeds = original + + +@pytest.fixture +def client() -> TestClient: + app = FastAPI() + app.include_router(create_community_router(COMMUNITY_ID)) + return TestClient(app) + + +class TestFAQFeedGate: + """The endpoint is opt-in via public_feeds.faq.""" + + @pytest.mark.usefixtures("feeds_disabled") + def test_disabled_when_public_feeds_none(self, client, faq_db): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + resp = client.get(f"/{COMMUNITY_ID}/faq") + assert resp.status_code == 404 + + @pytest.mark.usefixtures("feeds_faq_false") + def test_disabled_when_faq_flag_false(self, client, faq_db): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + resp = client.get(f"/{COMMUNITY_ID}/faq") + assert resp.status_code == 404 + + @pytest.mark.usefixtures("feeds_enabled") + def test_enabled_returns_200(self, client, faq_db): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + resp = client.get(f"/{COMMUNITY_ID}/faq") + assert resp.status_code == 200 + + +@pytest.mark.usefixtures("feeds_enabled") +class TestFAQFeedContent: + """Response shape and filtering when enabled.""" + + def test_returns_all_entries(self, client, faq_db): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + resp = client.get(f"/{COMMUNITY_ID}/faq") + body = resp.json() + assert body["community_id"] == COMMUNITY_ID + assert body["total"] == 2 + assert len(body["entries"]) == 2 + # Ordered by quality descending + assert body["entries"][0]["quality_score"] == 0.95 + + def test_exposed_fields_only(self, client, faq_db): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + resp = client.get(f"/{COMMUNITY_ID}/faq") + entry = resp.json()["entries"][0] + assert set(entry.keys()) == { + "question", + "answer", + "tags", + "category", + "quality_score", + "message_count", + "first_message_date", + "thread_url", + } + + def test_emails_are_redacted(self, client, faq_db): + """Emails are stripped from question, answer, and tags alike.""" + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + resp = client.get(f"/{COMMUNITY_ID}/faq") + entries = resp.json()["entries"] + blob = " ".join( + e["question"] + " " + e["answer"] + " " + " ".join(e["tags"]) for e in entries + ) + assert "support@brainproducts.com" not in blob + assert "sales@brainproducts.com" not in blob + assert "info@vendor.com" not in blob + assert "[email redacted]" in blob + # Redaction reached all three field types on the t2 entry. + t2 = next(e for e in entries if e["category"] == "reference") + assert "[email redacted]" in t2["question"] + assert "[email redacted]" in t2["answer"] + assert any("[email redacted]" in tag for tag in t2["tags"]) + + def test_category_filter(self, client, faq_db): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + resp = client.get(f"/{COMMUNITY_ID}/faq", params={"category": "how-to"}) + body = resp.json() + assert body["total"] == 1 + assert body["entries"][0]["category"] == "how-to" + + def test_min_quality_filter(self, client, faq_db): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + resp = client.get(f"/{COMMUNITY_ID}/faq", params={"min_quality": 0.9}) + body = resp.json() + assert body["total"] == 1 + assert body["entries"][0]["quality_score"] >= 0.9 + + def test_search_query(self, client, faq_db): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + resp = client.get(f"/{COMMUNITY_ID}/faq", params={"q": "ICA"}) + body = resp.json() + # Only the t1 entry mentions ICA; total is the real match count. + assert body["total"] == 1 + assert len(body["entries"]) == 1 + assert "ICA" in body["entries"][0]["question"] + + def test_pagination(self, client, faq_db): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + resp = client.get(f"/{COMMUNITY_ID}/faq", params={"limit": 1, "offset": 0}) + body = resp.json() + assert body["total"] == 2 + assert len(body["entries"]) == 1 + assert body["limit"] == 1 + assert body["offset"] == 0 + + def test_cache_control_header(self, client, faq_db): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + resp = client.get(f"/{COMMUNITY_ID}/faq") + assert resp.headers["Cache-Control"] == "public, max-age=3600" + + +@pytest.mark.usefixtures("feeds_enabled", "faq_db") +class TestFAQFeedValidation: + """Query parameter bounds are enforced (rejected before DB access).""" + + def test_invalid_min_quality_rejected(self, client): + resp = client.get(f"/{COMMUNITY_ID}/faq", params={"min_quality": 5}) + assert resp.status_code == 422 + + def test_limit_upper_bound_enforced(self, client): + resp = client.get(f"/{COMMUNITY_ID}/faq", params={"limit": 9999}) + assert resp.status_code == 422 + + +@pytest.mark.usefixtures("feeds_enabled") +class TestFAQFeedErrors: + """Database failures surface as 503, not silent empty responses.""" + + def test_browse_db_error_returns_503(self, client): + with patch( + "src.api.routers.community.list_faq_entries", + side_effect=sqlite3.OperationalError("db is locked"), + ): + resp = client.get(f"/{COMMUNITY_ID}/faq") + assert resp.status_code == 503 + + def test_search_db_error_returns_503(self, client): + with patch( + "src.api.routers.community.list_faq_entries", + side_effect=sqlite3.OperationalError("db is locked"), + ): + resp = client.get(f"/{COMMUNITY_ID}/faq", params={"q": "ICA"}) + assert resp.status_code == 503 diff --git a/tests/test_knowledge/test_faq_feed.py b/tests/test_knowledge/test_faq_feed.py new file mode 100644 index 0000000..e1436f9 --- /dev/null +++ b/tests/test_knowledge/test_faq_feed.py @@ -0,0 +1,213 @@ +"""Tests for the public FAQ feed listing helper. + +Uses a temporary SQLite database populated with real FAQ rows (no mocks of +business logic; only the database path is redirected to a temp file). +""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from src.knowledge.db import get_connection, init_db, upsert_faq_entry +from src.knowledge.search import FAQResult, list_faq_entries + + +@pytest.fixture +def faq_db(tmp_path: Path): + """Create a test database populated with FAQ entries.""" + db_path = tmp_path / "knowledge" / "test.db" + + with patch("src.knowledge.db.get_db_path", return_value=db_path): + init_db() + + with get_connection() as conn: + entries = [ + { + "thread_id": "t1", + "question": "How do I run ICA in EEGLAB?", + "answer": "Use runica via the Tools menu.", + "tags": ["ica", "eeglab"], + "category": "how-to", + "quality_score": 0.95, + "first_message_date": "2020-01-01", + }, + { + "thread_id": "t2", + "question": "Why does my dataset fail to load?", + "answer": "Check the file path and channel locations.", + "tags": ["loading"], + "category": "troubleshooting", + "quality_score": 0.80, + "first_message_date": "2021-06-15", + }, + { + "thread_id": "t3", + "question": "What is a reference electrode?", + "answer": "Contact support@brainproducts.com for hardware details.", + "tags": ["reference"], + "category": "reference", + "quality_score": 0.60, + "first_message_date": "2019-03-20", + }, + ] + for e in entries: + upsert_faq_entry( + conn, + list_name="eeglablist", + thread_id=e["thread_id"], + thread_url=f"https://example.org/{e['thread_id']}", + question=e["question"], + answer=e["answer"], + tags=e["tags"], + category=e["category"], + message_count=3, + participant_count=2, + first_message_date=e["first_message_date"], + quality_score=e["quality_score"], + summary_model="test-model", + ) + conn.commit() + + yield db_path + + +class TestListFAQEntries: + """Tests for list_faq_entries (browse mode, no FTS query).""" + + def test_returns_all_entries_and_total(self, faq_db: Path): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + entries, total = list_faq_entries(project="eeglab") + + assert total == 3 + assert len(entries) == 3 + assert all(isinstance(e, FAQResult) for e in entries) + + def test_ordered_by_quality_descending(self, faq_db: Path): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + entries, _ = list_faq_entries(project="eeglab") + + scores = [e.quality_score for e in entries] + assert scores == sorted(scores, reverse=True) + assert entries[0].quality_score == 0.95 + + def test_min_quality_filter(self, faq_db: Path): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + entries, total = list_faq_entries(project="eeglab", min_quality=0.85) + + assert total == 1 + assert len(entries) == 1 + assert entries[0].quality_score >= 0.85 + + def test_category_filter(self, faq_db: Path): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + entries, total = list_faq_entries(project="eeglab", category="troubleshooting") + + assert total == 1 + assert entries[0].category == "troubleshooting" + + def test_pagination_limit_and_offset(self, faq_db: Path): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + page1, total1 = list_faq_entries(project="eeglab", limit=2, offset=0) + page2, total2 = list_faq_entries(project="eeglab", limit=2, offset=2) + + # total is the full count regardless of pagination window + assert total1 == 3 + assert total2 == 3 + assert len(page1) == 2 + assert len(page2) == 1 + # No overlap between pages + page1_questions = {e.question for e in page1} + page2_questions = {e.question for e in page2} + assert page1_questions.isdisjoint(page2_questions) + + def test_empty_database_returns_zero(self, tmp_path: Path): + db_path = tmp_path / "knowledge" / "empty.db" + with patch("src.knowledge.db.get_db_path", return_value=db_path): + init_db() + entries, total = list_faq_entries(project="eeglab") + + assert total == 0 + assert entries == [] + + def test_list_name_filter(self, tmp_path: Path): + """list_name filter restricts results to a single mailing list.""" + db_path = tmp_path / "knowledge" / "lists.db" + with patch("src.knowledge.db.get_db_path", return_value=db_path): + init_db() + with get_connection() as conn: + for list_name, thread_id in [ + ("list-a", "a1"), + ("list-a", "a2"), + ("list-b", "b1"), + ]: + upsert_faq_entry( + conn, + list_name=list_name, + thread_id=thread_id, + thread_url=f"https://example.org/{thread_id}", + question=f"Question {thread_id}?", + answer="An answer.", + tags=["t"], + category="how-to", + message_count=2, + participant_count=2, + first_message_date="2020-01-01", + quality_score=0.8, + summary_model="test-model", + ) + conn.commit() + + entries, total = list_faq_entries(project="eeglab", list_name="list-a") + + assert total == 2 + assert len(entries) == 2 + assert {e.question for e in entries} == {"Question a1?", "Question a2?"} + + +class TestListFAQEntriesSearch: + """Search mode of list_faq_entries (query set, via FTS5).""" + + def test_query_matches_entries(self, faq_db: Path): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + entries, total = list_faq_entries(project="eeglab", query="ICA") + + assert total >= 1 + assert any("ICA" in e.question for e in entries) + + def test_query_no_match_returns_empty(self, faq_db: Path): + with patch("src.knowledge.db.get_db_path", return_value=faq_db): + entries, total = list_faq_entries(project="eeglab", query="zzzznomatchterm") + + assert total == 0 + assert entries == [] + + def test_query_total_is_full_count_not_page_size(self, tmp_path: Path): + """total reflects all FTS matches, independent of the page limit.""" + db_path = tmp_path / "knowledge" / "search.db" + with patch("src.knowledge.db.get_db_path", return_value=db_path): + init_db() + with get_connection() as conn: + for i in range(3): + upsert_faq_entry( + conn, + list_name="eeglablist", + thread_id=f"c{i}", + thread_url=f"https://example.org/c{i}", + question=f"How do I handle channels in case {i}?", + answer="Inspect the channel locations.", + tags=["channels"], + category="how-to", + message_count=2, + participant_count=2, + first_message_date="2020-01-01", + quality_score=0.8, + summary_model="test-model", + ) + conn.commit() + + page, total = list_faq_entries(project="eeglab", query="channels", limit=1) + + assert len(page) == 1 + assert total == 3 + assert total > len(page)