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
26 changes: 20 additions & 6 deletions frontend/osa-chat-widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -1938,6 +1938,7 @@
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ community_id: CONFIG.communityId, ...payload }),
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
console.warn('[OSA] Feedback submission returned', response.status);
Expand Down Expand Up @@ -2069,12 +2070,25 @@
return;
}

const ok = await postFeedback({
feedback_type: 'general',
comment,
session_id: sessionId || null,
page_url: (typeof window !== 'undefined' && window.location) ? window.location.href : null,
});
// Guard against a double-click submitting the comment twice while the POST
// is in flight (each would store a separate row).
const sendBtn = container.querySelector('.osa-feedback-send-btn');
if (sendBtn) {
if (sendBtn.disabled) return;
sendBtn.disabled = true;
}

let ok = false;
try {
ok = await postFeedback({
feedback_type: 'general',
comment,
session_id: sessionId || null,
page_url: (typeof window !== 'undefined' && window.location) ? window.location.href : null,
});
} finally {
if (sendBtn) sendBtn.disabled = false;
}

if (ok) {
const thanks = container.querySelector('.osa-feedback-modal-thanks');
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ server = [
"langchain-litellm>=0.2.0",
# External APIs
"pygithub>=2.8.0",
"pyalex>=0.19",
# Database
"psycopg[binary]>=3.3.0",
# Utilities
Expand Down
2 changes: 1 addition & 1 deletion scripts/widget-sri.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def main() -> None:

hash_value = sri(content)
print(f"\nSRI hash ({label}):")
print(f" integrity=\"{hash_value}\"")
print(f' integrity="{hash_value}"')

if tag:
print(f"\nVersioned embed snippet for {tag}:")
Expand Down
29 changes: 17 additions & 12 deletions src/api/routers/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,18 +125,23 @@ async def submit_feedback(
body.community_id,
)

entry = FeedbackEntry(
feedback_id=str(uuid.uuid4()),
timestamp=now_iso(),
community_id=body.community_id,
feedback_type=body.feedback_type,
sentiment=body.sentiment,
request_id=body.request_id,
session_id=body.session_id,
message_index=body.message_index,
comment=body.comment,
page_url=body.page_url,
)
try:
entry = FeedbackEntry(
feedback_id=str(uuid.uuid4()),
timestamp=now_iso(),
community_id=body.community_id,
feedback_type=body.feedback_type,
sentiment=body.sentiment,
request_id=body.request_id,
session_id=body.session_id,
message_index=body.message_index,
comment=body.comment,
page_url=body.page_url,
)
except ValueError as e:
# FeedbackRequest already validates these invariants; this guards against
# the two validators drifting apart so a bad shape returns 422, not 500.
raise HTTPException(status_code=422, detail=str(e)) from e

# write_feedback is best-effort: it logs and swallows storage errors (and
# escalates after repeated failures) rather than failing the user's request,
Expand Down
1 change: 1 addition & 0 deletions src/assistants/eeglab/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ github:

# Paper/citation search configuration
citations:
live_search: true # prompt already instructs the agent to ask before running it
queries:
- EEGLAB tutorial
- EEGLAB plugin
Expand Down
2 changes: 1 addition & 1 deletion src/cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ def _safe_load_config() -> tuple[str | None, str | None]:
if not pubmed_key:
pubmed_key = os.environ.get("PUBMED_API_KEY")

# Configure OpenAlex from env vars (pyalex uses global config)
# Configure OpenAlex credentials from env vars (merged into the opencite Config)
openalex_key = os.environ.get("OPENALEX_API_KEY")
openalex_email = os.environ.get("OPENALEX_EMAIL")
configure_openalex(api_key=openalex_key, email=openalex_email)
Expand Down
8 changes: 6 additions & 2 deletions src/core/config/community.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,12 @@ class CitationConfig(BaseModel):
dois: list[str] = Field(default_factory=list)
"""Core paper DOIs to track citations for (format: '10.xxxx/yyyy')."""

live_search: bool = Field(default=True)
"""Expose an on-demand live paper search tool (opencite) for recent literature."""
live_search: bool = Field(default=False)
"""Expose an on-demand live paper search tool (opencite) for recent literature.

Off by default: the tool adds external-API latency to a turn and queries
OpenAlex anonymously. Communities opt in explicitly, and their prompt should
tell the agent to ask the user before running it."""

@field_validator("queries")
@classmethod
Expand Down
48 changes: 35 additions & 13 deletions src/knowledge/papers_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
import logging
import os
import threading
from collections.abc import Iterable
from collections.abc import Coroutine, Iterable
from concurrent.futures import ThreadPoolExecutor
from typing import Any, TypeVar

from opencite import Config, Paper
from opencite.citations import CitationExplorer
Expand Down Expand Up @@ -198,7 +199,10 @@ def _store_papers(
return counts


def _run(coro):
_T = TypeVar("_T")


def _run(coro: Coroutine[Any, Any, _T]) -> _T:
"""Execute an async coroutine from synchronous code.

OSA's sync callers (CLI command, scheduler thread) have no running event
Expand Down Expand Up @@ -232,9 +236,15 @@ async def _search_queries(
try:
result = await searcher.search(query, max_results=max_results, sources=sources)
out.append((query, result.papers))
except Exception as e:
except (OpenCiteError, TimeoutError) as e:
logger.warning("opencite search error for '%s': %s", query, e)
out.append((query, []))
except Exception:
# Unexpected (likely a bug, not an API failure): keep the batch
# going but log loudly with a traceback so it is not mistaken
# for a routine "no results" outcome.
logger.exception("unexpected error searching '%s'", query)
out.append((query, []))
return out


Expand All @@ -250,9 +260,12 @@ async def _citing_for_dois(
try:
result = await explorer.citing_papers(doi, max_results=max_results)
out.append((doi, result.papers))
except Exception as e:
except (OpenCiteError, TimeoutError) as e:
logger.warning("opencite citation error for DOI %s: %s", doi, e)
out.append((doi, []))
except Exception:
logger.exception("unexpected error fetching citations for DOI %s", doi)
out.append((doi, []))
return out


Expand Down Expand Up @@ -357,10 +370,15 @@ def sync_all_papers(
return results

for query, papers in searched:
counts = _store_papers(papers, project)
for source, n in counts.items():
results[source] = results.get(source, 0) + n
update_sync_metadata("papers", f"opencite:{query}", sum(counts.values()), project)
try:
counts = _store_papers(papers, project)
for source, n in counts.items():
results[source] = results.get(source, 0) + n
update_sync_metadata("papers", f"opencite:{query}", sum(counts.values()), project)
except Exception:
# Isolate per-query: a DB failure on one query must not abort the
# whole batch or leave sync metadata inconsistent for the others.
logger.exception("failed to store papers for '%s' (%s)", query, project)

total = sum(results.values())
logger.info("Total papers synced for %s: %d", project, total)
Expand Down Expand Up @@ -401,11 +419,15 @@ def sync_citing_papers(

total = 0
for doi, papers in cited:
counts = _store_papers(papers, project)
count = sum(counts.values())
update_sync_metadata("papers", f"citing_{doi}", count, project)
logger.info("Synced %d papers citing %s", count, doi)
total += count
try:
counts = _store_papers(papers, project)
count = sum(counts.values())
update_sync_metadata("papers", f"citing_{doi}", count, project)
logger.info("Synced %d papers citing %s", count, doi)
total += count
except Exception:
# Isolate per-DOI so one DB failure does not abort the batch.
logger.exception("failed to store citing papers for %s (%s)", doi, project)

return total

Expand Down
9 changes: 9 additions & 0 deletions tests/test_api/test_feedback_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ def test_general_without_comment_rejected(self, client):
)
assert resp.status_code == 422

def test_general_whitespace_comment_rejected(self, client):
# _normalize collapses a whitespace-only comment to None, and _check_shape
# then rejects general feedback that carries no real comment.
resp = client.post(
"/feedback",
json={"community_id": "hed", "feedback_type": "general", "comment": " "},
)
assert resp.status_code == 422

def test_oversized_comment_rejected(self, client):
resp = client.post(
"/feedback",
Expand Down
5 changes: 5 additions & 0 deletions tests/test_core/test_config/test_community.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ def test_empty_defaults(self) -> None:
assert config.queries == []
assert config.dois == []

def test_live_search_off_by_default(self) -> None:
"""Live search is opt-in: a community must enable it explicitly."""
assert CitationConfig().live_search is False
assert CitationConfig(live_search=True).live_search is True

def test_validates_doi_format(self) -> None:
"""Should validate DOI format."""
with pytest.raises(ValidationError, match="Invalid DOI format"):
Expand Down
Loading
Loading