diff --git a/.env.example b/.env.example index 2d7343334..e121dc9ff 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,12 @@ NARUON_BACKEND_HOST_PORT=127.0.0.1:8000 # CORS settings for backend development. ALLOWED_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8000,http://127.0.0.1:8000 +# Comma-separated host allowlist for the scopeweave promote action (SSRF guard). +# The per-workspace scopeweave base URL and PAT are stored encrypted in the +# database (scopeweave_promotion_target); only hosts listed here may be targets. +# Empty (default) disables promotion. Example: scopeweave.example.com +ALLOWED_SCOPEWEAVE_HOSTS= + # Sending is configured per user in tenant_configs. Without real credentials, # the backend send service returns an explicit simulated result. SMTP_MODE=simulated diff --git a/backend/alembic/versions/0013_scopeweave_promotion.py b/backend/alembic/versions/0013_scopeweave_promotion.py new file mode 100644 index 000000000..22c56842d --- /dev/null +++ b/backend/alembic/versions/0013_scopeweave_promotion.py @@ -0,0 +1,108 @@ +"""add scopeweave promotion target and link tables + +Revision ID: 0013_scopeweave_promotion +Revises: 0012_llm_batch_orchestrator +Create Date: 2026-07-08 00:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0013_scopeweave_promotion" +down_revision = "0012_llm_batch_orchestrator" + +_TARGET_TABLE = "scopeweave_promotion_target" +_LINK_TABLE = "scopeweave_promotion_link" + + +def upgrade() -> None: + connection = op.get_bind() + inspector = sa.inspect(connection) + + if not inspector.has_table(_TARGET_TABLE): + op.create_table( + _TARGET_TABLE, + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=True), + sa.Column("workspace_id", sa.String(), nullable=False), + sa.Column("base_url", sa.String(), nullable=False), + sa.Column("access_token", sa.String(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "organization_id", + "workspace_id", + name="uq_scopeweave_promotion_target_scope", + ), + ) + + if not inspector.has_table(_LINK_TABLE): + op.create_table( + _LINK_TABLE, + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=True), + sa.Column("workspace_id", sa.String(), nullable=False), + sa.Column("project_uid", sa.String(), nullable=False), + sa.Column("object_uid", sa.String(), nullable=False), + sa.Column("object_type", sa.String(), nullable=False), + sa.Column("scopeweave_work_item_id", sa.String(), nullable=False), + sa.Column("scopeweave_work_item_url", sa.String(), nullable=True), + sa.Column("promoted_confidence", sa.Float(), nullable=False), + sa.Column("citation_count", sa.Integer(), nullable=False), + sa.Column("promoted_by_user_id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "workspace_id", + "object_uid", + name="uq_scopeweave_promotion_link_object", + ), + ) + + for table_name, indexes in _scopeweave_indexes().items(): + for index_name, column_names in indexes: + op.create_index( + index_name, + table_name, + column_names, + if_not_exists=True, + ) + + +def downgrade() -> None: + connection = op.get_bind() + inspector = sa.inspect(connection) + + for table_name in (_LINK_TABLE, _TARGET_TABLE): + if inspector.has_table(table_name): + for index_name, _column_names in reversed( + _scopeweave_indexes()[table_name] + ): + op.drop_index(index_name, table_name=table_name, if_exists=True) + op.drop_table(table_name) + + +def _scopeweave_indexes() -> dict[str, list[tuple[str, list[str]]]]: + return { + _TARGET_TABLE: [ + ("ix_scopeweave_promotion_target_user", ["user_id"]), + ( + "ix_scopeweave_promotion_target_scope", + ["organization_id", "workspace_id"], + ), + ], + _LINK_TABLE: [ + ("ix_scopeweave_promotion_link_user", ["user_id"]), + ("ix_scopeweave_promotion_link_object_uid", ["object_uid"]), + ("ix_scopeweave_promotion_link_project", ["project_uid"]), + ( + "ix_scopeweave_promotion_link_scope", + ["organization_id", "workspace_id", "project_uid"], + ), + ], + } diff --git a/backend/api/projects.py b/backend/api/projects.py index b34c47d63..598efd112 100644 --- a/backend/api/projects.py +++ b/backend/api/projects.py @@ -25,6 +25,12 @@ get_project_evidence, get_project_traceability, ) +from services.scopeweave_client import ScopeweaveConfigError, ScopeweavePushError +from services.scopeweave_promotion import ( + ScopeweaveNotConfiguredError, + ScopeweavePromotionOutcome, + promote_project_object, +) router = APIRouter(prefix="/api/projects", tags=["projects"]) @@ -101,6 +107,23 @@ class ProjectEvidenceResponse(BaseModel): citation_bundle: list[ProjectCitationResponse] +class ProjectPromoteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + object_uid: str = Field(min_length=1, max_length=160) + + +class ProjectPromoteResponse(BaseModel): + project_uid: str + object_uid: str + object_type: str + scopeweave_work_item_id: str + scopeweave_work_item_url: str | None + promoted_confidence: float + citation_count: int + created: bool + + class ProjectCorrectionRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -249,6 +272,37 @@ async def apply_project_correction_endpoint( return _correction_response(correction) +@router.post( + "/{project_uid}/promote", + response_model=ProjectPromoteResponse, +) +async def promote_project_object_endpoint( + project_uid: str, + request: ProjectPromoteRequest, + auth_context: AuthContext = Depends(get_auth_context), + db: AsyncSession = Depends(get_db), +): + try: + outcome = await promote_project_object( + db, + scope=_project_scope(auth_context), + project_uid=project_uid, + object_uid=request.object_uid, + actor_user_id=auth_context.user_id, + ) + await db.commit() + except ProjectGraphNotFoundError as exc: + await db.rollback() + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ScopeweaveNotConfiguredError as exc: + await db.rollback() + raise HTTPException(status_code=409, detail=str(exc)) from exc + except (ScopeweaveConfigError, ScopeweavePushError) as exc: + await db.rollback() + raise HTTPException(status_code=502, detail=str(exc)) from exc + return _promote_response(outcome) + + def _citation_response(citation: ProjectCitation) -> ProjectCitationResponse: return ProjectCitationResponse( content_segment_uid=citation.content_segment_uid, @@ -338,6 +392,21 @@ def _evidence_response(evidence: ProjectEvidence) -> ProjectEvidenceResponse: ) +def _promote_response( + outcome: ScopeweavePromotionOutcome, +) -> ProjectPromoteResponse: + return ProjectPromoteResponse( + project_uid=outcome.project_uid, + object_uid=outcome.object_uid, + object_type=outcome.object_type, + scopeweave_work_item_id=outcome.scopeweave_work_item_id, + scopeweave_work_item_url=outcome.scopeweave_work_item_url, + promoted_confidence=outcome.promoted_confidence, + citation_count=outcome.citation_count, + created=outcome.created, + ) + + def _correction_response(correction: ProjectCorrection) -> ProjectCorrectionResponse: return ProjectCorrectionResponse( correction_uid=correction.correction_uid, diff --git a/backend/core/config.py b/backend/core/config.py index 31b5ff8ae..2925e6076 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -76,6 +76,11 @@ class Settings(BaseSettings): ALLOWED_POP3_PORTS: str = "995" ALLOWED_LLM_BASE_URL_HOSTS: str = "" ALLOW_LOCAL_LLM_PROVIDERS: bool = False + # Host allowlist for the scopeweave promotion target. The per-workspace + # base URL and PAT themselves live encrypted in the database + # (scopeweave_promotion_target); this setting only pins which hosts an + # operator is permitted to promote work items to (SSRF host allowlist). + ALLOWED_SCOPEWEAVE_HOSTS: str = "" ALLOWED_CORS_ORIGINS: str = "" ENABLE_PROMETHEUS_METRICS: bool = False DATA_REGION: str = "kr" diff --git a/backend/db/models.py b/backend/db/models.py index 199e86a00..0c9a75830 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -169,6 +169,98 @@ class LLMProvider(Base): ) +class ScopeweavePromotionTarget(Base): + """Per-workspace scopeweave instance the promote action pushes work items to. + + The ``base_url`` is validated against ``ALLOWED_SCOPEWEAVE_HOSTS`` at runtime + and the personal access token is stored Fernet-encrypted, mirroring how + ``LLMProvider`` keeps its ``api_key``. Config is resolved from the database + at request time (never from ``os.getenv``). + """ + + __tablename__ = "scopeweave_promotion_target" + __table_args__ = ( + UniqueConstraint( + "organization_id", + "workspace_id", + name="uq_scopeweave_promotion_target_scope", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[str] = mapped_column(String, index=True, nullable=False) + organization_id: Mapped[str | None] = mapped_column( + String, index=True, nullable=True + ) + workspace_id: Mapped[str] = mapped_column(String, index=True, nullable=False) + base_url: Mapped[str] = mapped_column(String, nullable=False) + access_token: Mapped[str] = mapped_column(EncryptedString, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + nullable=False, + ) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + onupdate=lambda: datetime.datetime.now(datetime.timezone.utc), + nullable=False, + ) + + +class ScopeweavePromotionLink(Base): + """Mapping of a naruon project-graph object to its scopeweave work item. + + One row per promoted ``object_uid`` within a workspace; re-promoting updates + the same row so the citation-carrying link between naruon evidence and the + scopeweave work item stays stable and idempotent. + """ + + __tablename__ = "scopeweave_promotion_link" + __table_args__ = ( + UniqueConstraint( + "workspace_id", + "object_uid", + name="uq_scopeweave_promotion_link_object", + ), + Index( + "ix_scopeweave_promotion_link_scope", + "organization_id", + "workspace_id", + "project_uid", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[str] = mapped_column(String, index=True, nullable=False) + organization_id: Mapped[str | None] = mapped_column( + String, index=True, nullable=True + ) + workspace_id: Mapped[str] = mapped_column(String, index=True, nullable=False) + project_uid: Mapped[str] = mapped_column(String, index=True, nullable=False) + object_uid: Mapped[str] = mapped_column(String, index=True, nullable=False) + object_type: Mapped[str] = mapped_column(String, nullable=False) + scopeweave_work_item_id: Mapped[str] = mapped_column(String, nullable=False) + scopeweave_work_item_url: Mapped[str | None] = mapped_column( + String, nullable=True + ) + promoted_confidence: Mapped[float] = mapped_column(Float, nullable=False) + citation_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + promoted_by_user_id: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + nullable=False, + ) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + onupdate=lambda: datetime.datetime.now(datetime.timezone.utc), + nullable=False, + ) + + class WorkspaceRunnerConfig(Base): __tablename__ = "workspace_runner_configs" diff --git a/backend/services/llm_provider_urls.py b/backend/services/llm_provider_urls.py index 63afc0e86..734a21c7b 100644 --- a/backend/services/llm_provider_urls.py +++ b/backend/services/llm_provider_urls.py @@ -452,3 +452,31 @@ async def build_llm_provider_http_client( transport=_PinnedLLMProviderAsyncTransport(validated), ), ) + + +def build_pinned_https_async_client( + normalized_url: str, + hostname: str, + port: int, + addresses: tuple[str, ...], +) -> httpx.AsyncClient: + """Build a DNS-pinned ``httpx.AsyncClient`` for an already-validated host. + + The caller is responsible for validating the URL against its own host + allowlist (see ``core.url_validation.validate_https_url_host_details``) + before calling this helper. The returned client re-pins every outbound + connection to ``addresses`` and rejects any host/port that differs from the + validated one, closing the DNS-rebinding gap for outbound integrations that + reuse this hardened transport. + """ + pinned = ValidatedLLMProviderBaseURL( + normalized_url=normalized_url, + hostname=hostname, + port=port, + addresses=addresses, + ) + return httpx.AsyncClient( + follow_redirects=False, + trust_env=False, + transport=_PinnedLLMProviderAsyncTransport(pinned), + ) diff --git a/backend/services/scopeweave_client.py b/backend/services/scopeweave_client.py new file mode 100644 index 000000000..490f61a4b --- /dev/null +++ b/backend/services/scopeweave_client.py @@ -0,0 +1,138 @@ +"""Outbound client for pushing promoted work items to a scopeweave instance. + +Security posture mirrors the LLM provider egress path: + +- The destination host must appear in ``ALLOWED_SCOPEWEAVE_HOSTS`` (config + module, never ``os.getenv``) and the URL must be HTTPS. +- The host is resolved and every candidate address must be globally routable + (blocks SSRF to loopback / RFC-1918 / link-local targets). +- The outbound connection is DNS-pinned to the validated addresses, closing the + DNS-rebinding gap between validation and connect. + +The per-workspace ``base_url`` and PAT are resolved from the encrypted database +row by the caller; this module only knows how to validate a URL and speak the +scopeweave import protocol. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + +from core.config import settings +from core.url_validation import ( + ValidatedHTTPSURLHost, + parse_allowed_hosts, + validate_https_url_host_details, +) +from services.llm_provider_urls import build_pinned_https_async_client + +SCOPEWEAVE_BASE_URL_SETTING = "scopeweave base_url" +SCOPEWEAVE_ALLOWED_HOSTS_SETTING = "ALLOWED_SCOPEWEAVE_HOSTS" +SCOPEWEAVE_IMPORT_PATH = "/api/imports/work-items" +_REQUEST_TIMEOUT_SECONDS = 15.0 + + +class ScopeweaveConfigError(ValueError): + """The configured scopeweave base URL is missing or fails validation.""" + + +class ScopeweavePushError(RuntimeError): + """The scopeweave import request could not be completed successfully.""" + + +@dataclass(frozen=True, slots=True) +class ScopeweaveImportResult: + work_item_id: str + work_item_url: str | None + status_code: int + + +def validate_scopeweave_base_url(base_url: str) -> ValidatedHTTPSURLHost: + """Validate ``base_url`` against the scopeweave host allowlist. + + Raises ``ScopeweaveConfigError`` when the URL is not HTTPS, its host is not + allowlisted, or it resolves to a non-global address. + """ + allowed_hosts = parse_allowed_hosts(settings.ALLOWED_SCOPEWEAVE_HOSTS) + if not allowed_hosts: + raise ScopeweaveConfigError( + f"{SCOPEWEAVE_ALLOWED_HOSTS_SETTING} must list at least one trusted host" + ) + try: + return validate_https_url_host_details( + SCOPEWEAVE_BASE_URL_SETTING, + base_url, + allowed_hosts, + SCOPEWEAVE_ALLOWED_HOSTS_SETTING, + ) + except ValueError as exc: + raise ScopeweaveConfigError(str(exc)) from exc + + +def _import_url(validated: ValidatedHTTPSURLHost) -> str: + return f"{validated.normalized_url.rstrip('/')}{SCOPEWEAVE_IMPORT_PATH}" + + +def _parse_import_result(response: httpx.Response) -> ScopeweaveImportResult: + try: + body: Any = response.json() + except ValueError as exc: + raise ScopeweavePushError( + "scopeweave returned a non-JSON import response" + ) from exc + if not isinstance(body, dict): + raise ScopeweavePushError("scopeweave import response was not an object") + work_item_id = body.get("work_item_id") or body.get("id") + if not work_item_id: + raise ScopeweavePushError("scopeweave import response omitted a work item id") + work_item_url = body.get("work_item_url") or body.get("url") + return ScopeweaveImportResult( + work_item_id=str(work_item_id), + work_item_url=str(work_item_url) if work_item_url else None, + status_code=response.status_code, + ) + + +async def push_work_item( + *, + base_url: str, + access_token: str, + payload: dict[str, Any], +) -> ScopeweaveImportResult: + """Validate the target, then POST ``payload`` to the scopeweave import API. + + Uses a DNS-pinned, redirect-blocking HTTPS client and a bearer PAT. Raises + ``ScopeweaveConfigError`` for invalid targets and ``ScopeweavePushError`` + for transport or non-2xx responses. + """ + validated = validate_scopeweave_base_url(base_url) + client = build_pinned_https_async_client( + validated.normalized_url, + validated.hostname, + validated.port, + validated.addresses, + ) + try: + response = await client.post( + _import_url(validated), + json=payload, + headers={ + "authorization": f"Bearer {access_token}", + "content-type": "application/json", + "accept": "application/json", + }, + timeout=_REQUEST_TIMEOUT_SECONDS, + ) + except httpx.HTTPError as exc: + raise ScopeweavePushError("scopeweave import request failed") from exc + finally: + await client.aclose() + + if response.status_code >= 400: + raise ScopeweavePushError( + f"scopeweave import rejected the work item (status {response.status_code})" + ) + return _parse_import_result(response) diff --git a/backend/services/scopeweave_promotion.py b/backend/services/scopeweave_promotion.py new file mode 100644 index 000000000..8299d9285 --- /dev/null +++ b/backend/services/scopeweave_promotion.py @@ -0,0 +1,211 @@ +"""Promote evidence-grounded project-graph objects into scopeweave. + +Flow: resolve the workspace's encrypted scopeweave target from the database, +load the citation-backed evidence for the requested object, push it to the +scopeweave import endpoint, and persist the naruon ``object_uid`` <-> +scopeweave work-item mapping. When no target is configured the caller receives +``ScopeweaveNotConfiguredError`` so the API can degrade gracefully instead of +failing hard. +""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models import ScopeweavePromotionLink, ScopeweavePromotionTarget +from services.project_graph.project_registration import ( + ProjectEvidence, + ProjectGraphQueryScope, +) +from services.project_graph.traceability import get_project_evidence +from services.scopeweave_client import ( + ScopeweaveConfigError, + ScopeweaveImportResult, + ScopeweavePushError, + push_work_item, +) + +SOURCE_SYSTEM = "naruon" + + +class ScopeweaveNotConfiguredError(RuntimeError): + """No active scopeweave promotion target exists for the workspace.""" + + +@dataclass(frozen=True, slots=True) +class ScopeweavePromotionOutcome: + project_uid: str + object_uid: str + object_type: str + scopeweave_work_item_id: str + scopeweave_work_item_url: str | None + promoted_confidence: float + citation_count: int + created: bool + + +async def load_active_target( + session: AsyncSession, + *, + scope: ProjectGraphQueryScope, +) -> ScopeweavePromotionTarget | None: + """Resolve the active scopeweave target for the caller's workspace.""" + statement = select(ScopeweavePromotionTarget).where( + ScopeweavePromotionTarget.workspace_id == scope.workspace_id, + ScopeweavePromotionTarget.organization_id == scope.organization_id, + ScopeweavePromotionTarget.is_active.is_(True), + ) + result = await session.execute(statement) + return result.scalars().first() + + +def build_import_payload(evidence: ProjectEvidence) -> dict[str, Any]: + """Serialize evidence into the scopeweave import contract, keeping citations. + + Every citation carries the source segment uid, the originating email/thread + record, and positional context so scopeweave can trace the work item back to + grounded evidence rather than a free-text summary. + """ + citations = [ + { + "content_segment_uid": citation.content_segment_uid, + "source_kind": citation.source_kind, + "source_record_uid": citation.source_record_uid, + "heading_path": citation.heading_path, + "segment_path": citation.segment_path, + "ordinal_index": citation.ordinal_index, + "safe_text_excerpt": citation.safe_text_excerpt, + } + for citation in evidence.citation_bundle + ] + return { + "source_system": SOURCE_SYSTEM, + "external_ref": { + "project_uid": evidence.project_uid, + "object_uid": evidence.object_uid, + }, + "work_item": { + "object_type": evidence.object_type, + "title": evidence.title, + "summary": evidence.summary, + "status_code": evidence.status_code, + "confidence": evidence.confidence, + }, + "citations": citations, + } + + +async def _upsert_link( + session: AsyncSession, + *, + scope: ProjectGraphQueryScope, + evidence: ProjectEvidence, + actor_user_id: str, + result: ScopeweaveImportResult, + citation_count: int, +) -> bool: + statement = select(ScopeweavePromotionLink).where( + ScopeweavePromotionLink.workspace_id == scope.workspace_id, + ScopeweavePromotionLink.object_uid == evidence.object_uid, + ) + existing = (await session.execute(statement)).scalars().first() + now = datetime.datetime.now(datetime.timezone.utc) + if existing is None: + session.add( + ScopeweavePromotionLink( + user_id=scope.user_id, + organization_id=scope.organization_id, + workspace_id=scope.workspace_id, + project_uid=evidence.project_uid, + object_uid=evidence.object_uid, + object_type=evidence.object_type, + scopeweave_work_item_id=result.work_item_id, + scopeweave_work_item_url=result.work_item_url, + promoted_confidence=evidence.confidence, + citation_count=citation_count, + promoted_by_user_id=actor_user_id, + ) + ) + return True + + existing.project_uid = evidence.project_uid + existing.object_type = evidence.object_type + existing.scopeweave_work_item_id = result.work_item_id + existing.scopeweave_work_item_url = result.work_item_url + existing.promoted_confidence = evidence.confidence + existing.citation_count = citation_count + existing.promoted_by_user_id = actor_user_id + existing.updated_at = now + return False + + +async def promote_project_object( + session: AsyncSession, + *, + scope: ProjectGraphQueryScope, + project_uid: str, + object_uid: str, + actor_user_id: str, +) -> ScopeweavePromotionOutcome: + """Push a project-graph object to scopeweave and persist the mapping. + + Raises ``ScopeweaveNotConfiguredError`` when the workspace has no active + target (graceful degradation), ``ProjectGraphNotFoundError`` when the object + or its evidence is missing, ``ScopeweaveConfigError`` for an invalid target + URL, and ``ScopeweavePushError`` when scopeweave rejects the request. + """ + target = await load_active_target(session, scope=scope) + if target is None: + raise ScopeweaveNotConfiguredError( + "Scopeweave promotion is not configured for this workspace" + ) + + evidence = await get_project_evidence( + session, + scope=scope, + project_uid=project_uid, + object_uid=object_uid, + ) + + payload = build_import_payload(evidence) + result = await push_work_item( + base_url=target.base_url, + access_token=target.access_token, + payload=payload, + ) + + created = await _upsert_link( + session, + scope=scope, + evidence=evidence, + actor_user_id=actor_user_id, + result=result, + citation_count=len(evidence.citation_bundle), + ) + + return ScopeweavePromotionOutcome( + project_uid=evidence.project_uid, + object_uid=evidence.object_uid, + object_type=evidence.object_type, + scopeweave_work_item_id=result.work_item_id, + scopeweave_work_item_url=result.work_item_url, + promoted_confidence=evidence.confidence, + citation_count=len(evidence.citation_bundle), + created=created, + ) + + +__all__ = [ + "ScopeweaveConfigError", + "ScopeweaveNotConfiguredError", + "ScopeweavePromotionOutcome", + "ScopeweavePushError", + "build_import_payload", + "load_active_target", + "promote_project_object", +] diff --git a/backend/tests/test_alembic_migrations.py b/backend/tests/test_alembic_migrations.py index 09cfa0d22..81242a706 100644 --- a/backend/tests/test_alembic_migrations.py +++ b/backend/tests/test_alembic_migrations.py @@ -249,6 +249,31 @@ def test_llm_batch_orchestrator_has_incremental_revision(): assert "op.drop_column(" in revision_text +def test_scopeweave_promotion_has_incremental_revision(): + versions_dir = BACKEND_ROOT / "alembic" / "versions" + revision_path = versions_dir / "0013_scopeweave_promotion.py" + assert revision_path.exists() + revision_text = revision_path.read_text() + + assert 'revision = "0013_scopeweave_promotion"' in revision_text + assert 'down_revision = "0012_llm_batch_orchestrator"' in revision_text + assert '"scopeweave_promotion_target"' in revision_text + assert '"scopeweave_promotion_link"' in revision_text + assert '"base_url"' in revision_text + assert '"access_token"' in revision_text + assert '"scopeweave_work_item_id"' in revision_text + assert '"scopeweave_work_item_url"' in revision_text + assert "uq_scopeweave_promotion_target_scope" in revision_text + assert "uq_scopeweave_promotion_link_object" in revision_text + assert "ix_scopeweave_promotion_link_scope" in revision_text + assert "has_table" in revision_text + assert "op.create_table(" in revision_text + assert "op.create_index(" in revision_text + assert "if_not_exists=True" in revision_text + assert "op.drop_index(" in revision_text + assert "if_exists=True" in revision_text + + def test_migration_runner_uses_alembic_upgrade_head_not_bootstrap_create_all(): migration_runner = BACKEND_ROOT / "scripts" / "migrate_db.py" diff --git a/backend/tests/test_scopeweave_promotion.py b/backend/tests/test_scopeweave_promotion.py new file mode 100644 index 000000000..cb2a6299f --- /dev/null +++ b/backend/tests/test_scopeweave_promotion.py @@ -0,0 +1,464 @@ +import importlib + +import httpx +import pytest + +import api.projects as projects_api +from core.url_validation import ValidatedHTTPSURLHost +from db.models import ScopeweavePromotionLink, ScopeweavePromotionTarget +from db.session import get_db +from main import app +from services.project_graph.project_registration import ( + ProjectCitation, + ProjectEvidence, + ProjectGraphNotFoundError, + ProjectGraphQueryScope, +) +from services.scopeweave_client import ( + ScopeweaveConfigError, + ScopeweaveImportResult, + ScopeweavePushError, +) +from services.scopeweave_promotion import ( + ScopeweaveNotConfiguredError, + ScopeweavePromotionOutcome, + build_import_payload, + promote_project_object, +) + +scopeweave_client = importlib.import_module("services.scopeweave_client") +scopeweave_promotion = importlib.import_module("services.scopeweave_promotion") + + +def _evidence() -> ProjectEvidence: + return ProjectEvidence( + project_uid="project_candidate:demo", + object_uid="issue:demo", + object_type="issue", + title="결제 실패 재현 필요", + summary="결제 승인 단계에서 간헐적 오류가 보고됨", + status_code="confirmed", + confidence=0.82, + citation_bundle=( + ProjectCitation( + content_segment_uid="seg-1", + source_kind="email_body", + source_record_uid="", + heading_path="Issues", + segment_path="/document[1]/paragraph[3]", + ordinal_index=3, + safe_text_excerpt="결제 승인 오류 근거 문단", + ), + ), + ) + + +def _validated_host() -> ValidatedHTTPSURLHost: + return ValidatedHTTPSURLHost( + normalized_url="https://scopeweave.example.com", + hostname="scopeweave.example.com", + port=443, + addresses=("203.0.113.10",), + ) + + +class _FakeResponse: + def __init__(self, status_code: int, payload): + self.status_code = status_code + self._payload = payload + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +class _FakeAsyncClient: + def __init__(self, response: _FakeResponse): + self._response = response + self.requests: list[dict] = [] + self.closed = False + + async def post(self, url, *, json, headers, timeout): + self.requests.append( + {"url": url, "json": json, "headers": headers, "timeout": timeout} + ) + return self._response + + async def aclose(self): + self.closed = True + + +class _FakeResult: + def __init__(self, value): + self._value = value + + def scalars(self): + return self + + def first(self): + return self._value + + +class _FakeSession: + def __init__(self, results): + self._results = list(results) + self.added: list = [] + self.committed = False + self.rolled_back = False + + async def execute(self, _statement): + return _FakeResult(self._results.pop(0)) + + def add(self, obj): + self.added.append(obj) + + async def commit(self): + self.committed = True + + async def rollback(self): + self.rolled_back = True + + +def _scope() -> ProjectGraphQueryScope: + return ProjectGraphQueryScope( + user_id="promoter", + organization_id="org-1", + workspace_id="workspace-org-1", + ) + + +def _target() -> ScopeweavePromotionTarget: + return ScopeweavePromotionTarget( + user_id="promoter", + organization_id="org-1", + workspace_id="workspace-org-1", + base_url="https://scopeweave.example.com", + access_token="pat-secret", + is_active=True, + ) + + +def test_build_import_payload_carries_citations(): + payload = build_import_payload(_evidence()) + + assert payload["source_system"] == "naruon" + assert payload["external_ref"] == { + "project_uid": "project_candidate:demo", + "object_uid": "issue:demo", + } + assert payload["work_item"]["object_type"] == "issue" + assert payload["work_item"]["confidence"] == 0.82 + assert len(payload["citations"]) == 1 + citation = payload["citations"][0] + assert citation["content_segment_uid"] == "seg-1" + assert citation["source_record_uid"] == "" + + +def test_validate_base_url_requires_configured_allowlist(monkeypatch): + monkeypatch.setattr(scopeweave_client.settings, "ALLOWED_SCOPEWEAVE_HOSTS", "") + with pytest.raises(ScopeweaveConfigError): + scopeweave_client.validate_scopeweave_base_url( + "https://scopeweave.example.com" + ) + + +def test_validate_base_url_rejects_non_allowlisted_host(monkeypatch): + monkeypatch.setattr( + scopeweave_client.settings, + "ALLOWED_SCOPEWEAVE_HOSTS", + "scopeweave.example.com", + ) + with pytest.raises(ScopeweaveConfigError): + scopeweave_client.validate_scopeweave_base_url("https://evil.example.net") + + +def test_validate_base_url_rejects_http_scheme(monkeypatch): + monkeypatch.setattr( + scopeweave_client.settings, + "ALLOWED_SCOPEWEAVE_HOSTS", + "scopeweave.example.com", + ) + with pytest.raises(ScopeweaveConfigError): + scopeweave_client.validate_scopeweave_base_url( + "http://scopeweave.example.com" + ) + + +@pytest.mark.asyncio +async def test_push_work_item_posts_bearer_payload(monkeypatch): + fake_client = _FakeAsyncClient( + _FakeResponse( + 201, + { + "work_item_id": "WI-42", + "work_item_url": "https://scopeweave.example.com/w/WI-42", + }, + ) + ) + monkeypatch.setattr( + scopeweave_client, + "validate_scopeweave_base_url", + lambda _base_url: _validated_host(), + ) + monkeypatch.setattr( + scopeweave_client, + "build_pinned_https_async_client", + lambda *_args: fake_client, + ) + + result = await scopeweave_client.push_work_item( + base_url="https://scopeweave.example.com", + access_token="pat-secret", + payload={"hello": "world"}, + ) + + assert result == ScopeweaveImportResult( + work_item_id="WI-42", + work_item_url="https://scopeweave.example.com/w/WI-42", + status_code=201, + ) + assert fake_client.closed is True + sent = fake_client.requests[0] + assert sent["url"] == ( + "https://scopeweave.example.com/api/imports/work-items" + ) + assert sent["headers"]["authorization"] == "Bearer pat-secret" + assert sent["json"] == {"hello": "world"} + + +@pytest.mark.asyncio +async def test_push_work_item_raises_on_error_status(monkeypatch): + fake_client = _FakeAsyncClient(_FakeResponse(422, {"detail": "bad"})) + monkeypatch.setattr( + scopeweave_client, + "validate_scopeweave_base_url", + lambda _base_url: _validated_host(), + ) + monkeypatch.setattr( + scopeweave_client, + "build_pinned_https_async_client", + lambda *_args: fake_client, + ) + + with pytest.raises(ScopeweavePushError): + await scopeweave_client.push_work_item( + base_url="https://scopeweave.example.com", + access_token="pat-secret", + payload={}, + ) + assert fake_client.closed is True + + +@pytest.mark.asyncio +async def test_promote_degrades_when_not_configured(): + session = _FakeSession([None]) + with pytest.raises(ScopeweaveNotConfiguredError): + await promote_project_object( + session, + scope=_scope(), + project_uid="project_candidate:demo", + object_uid="issue:demo", + actor_user_id="promoter", + ) + assert session.added == [] + + +@pytest.mark.asyncio +async def test_promote_pushes_and_persists_mapping(monkeypatch): + # results: 1) active target lookup, 2) existing link lookup (none) + session = _FakeSession([_target(), None]) + + async def fake_get_evidence(_session, *, scope, project_uid, object_uid): + return _evidence() + + async def fake_push(*, base_url, access_token, payload): + assert base_url == "https://scopeweave.example.com" + assert access_token == "pat-secret" + assert payload["external_ref"]["object_uid"] == "issue:demo" + return ScopeweaveImportResult( + work_item_id="WI-7", + work_item_url="https://scopeweave.example.com/w/WI-7", + status_code=201, + ) + + monkeypatch.setattr( + scopeweave_promotion, "get_project_evidence", fake_get_evidence + ) + monkeypatch.setattr(scopeweave_promotion, "push_work_item", fake_push) + + outcome = await promote_project_object( + session, + scope=_scope(), + project_uid="project_candidate:demo", + object_uid="issue:demo", + actor_user_id="promoter", + ) + + assert outcome.created is True + assert outcome.scopeweave_work_item_id == "WI-7" + assert outcome.citation_count == 1 + assert len(session.added) == 1 + link = session.added[0] + assert isinstance(link, ScopeweavePromotionLink) + assert link.object_uid == "issue:demo" + assert link.scopeweave_work_item_id == "WI-7" + assert link.promoted_confidence == 0.82 + + +@pytest.mark.asyncio +async def test_promote_updates_existing_mapping(monkeypatch): + existing = ScopeweavePromotionLink( + user_id="promoter", + organization_id="org-1", + workspace_id="workspace-org-1", + project_uid="project_candidate:demo", + object_uid="issue:demo", + object_type="issue", + scopeweave_work_item_id="WI-old", + scopeweave_work_item_url=None, + promoted_confidence=0.1, + citation_count=0, + promoted_by_user_id="promoter", + ) + session = _FakeSession([_target(), existing]) + + async def fake_get_evidence(_session, *, scope, project_uid, object_uid): + return _evidence() + + async def fake_push(*, base_url, access_token, payload): + return ScopeweaveImportResult( + work_item_id="WI-new", + work_item_url="https://scopeweave.example.com/w/WI-new", + status_code=200, + ) + + monkeypatch.setattr( + scopeweave_promotion, "get_project_evidence", fake_get_evidence + ) + monkeypatch.setattr(scopeweave_promotion, "push_work_item", fake_push) + + outcome = await promote_project_object( + session, + scope=_scope(), + project_uid="project_candidate:demo", + object_uid="issue:demo", + actor_user_id="promoter", + ) + + assert outcome.created is False + assert session.added == [] + assert existing.scopeweave_work_item_id == "WI-new" + assert existing.promoted_confidence == 0.82 + + +# --- API endpoint tests (mocked service) --- + + +def _client() -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + headers={ + "X-User-Id": "promoter", + "X-Organization-Id": "org-1", + "X-User-Role": "member", + }, + ) + + +@pytest.fixture +def _override_db(): + async def override_get_db(): + yield _FakeSession([]) + + app.dependency_overrides[get_db] = override_get_db + yield + app.dependency_overrides.pop(get_db, None) + + +@pytest.mark.asyncio +async def test_promote_endpoint_returns_200( + dev_auth_dependency_overrides, _override_db, monkeypatch +): + async def fake_promote(session, *, scope, project_uid, object_uid, actor_user_id): + assert project_uid == "project_candidate:demo" + assert object_uid == "issue:demo" + return ScopeweavePromotionOutcome( + project_uid=project_uid, + object_uid=object_uid, + object_type="issue", + scopeweave_work_item_id="WI-99", + scopeweave_work_item_url="https://scopeweave.example.com/w/WI-99", + promoted_confidence=0.82, + citation_count=1, + created=True, + ) + + monkeypatch.setattr(projects_api, "promote_project_object", fake_promote) + + async with _client() as client: + response = await client.post( + "/api/projects/project_candidate:demo/promote", + json={"object_uid": "issue:demo"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["scopeweave_work_item_id"] == "WI-99" + assert body["created"] is True + assert body["citation_count"] == 1 + + +@pytest.mark.asyncio +async def test_promote_endpoint_returns_409_when_unconfigured( + dev_auth_dependency_overrides, _override_db, monkeypatch +): + async def fake_promote(*_args, **_kwargs): + raise ScopeweaveNotConfiguredError("not configured") + + monkeypatch.setattr(projects_api, "promote_project_object", fake_promote) + + async with _client() as client: + response = await client.post( + "/api/projects/project_candidate:demo/promote", + json={"object_uid": "issue:demo"}, + ) + + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_promote_endpoint_returns_404_when_object_missing( + dev_auth_dependency_overrides, _override_db, monkeypatch +): + async def fake_promote(*_args, **_kwargs): + raise ProjectGraphNotFoundError("missing") + + monkeypatch.setattr(projects_api, "promote_project_object", fake_promote) + + async with _client() as client: + response = await client.post( + "/api/projects/project_candidate:demo/promote", + json={"object_uid": "issue:demo"}, + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_promote_endpoint_returns_502_on_push_failure( + dev_auth_dependency_overrides, _override_db, monkeypatch +): + async def fake_promote(*_args, **_kwargs): + raise ScopeweavePushError("scopeweave import request failed") + + monkeypatch.setattr(projects_api, "promote_project_object", fake_promote) + + async with _client() as client: + response = await client.post( + "/api/projects/project_candidate:demo/promote", + json={"object_uid": "issue:demo"}, + ) + + assert response.status_code == 502 diff --git a/docs/papers/README.md b/docs/papers/README.md index 2cbf7a5b7..94437d5fb 100644 --- a/docs/papers/README.md +++ b/docs/papers/README.md @@ -2,6 +2,22 @@ Background reading referenced by the codebase. +## Project graph promotion and requirements engineering + +Background for the project-graph to scopeweave promotion path: naruon extracts +evidence-grounded requirements, issues, and features from natural-language email +threads, and this literature grounds that requirements-engineering work. + +- **`nlp-in-software-requirements-engineering-slr.pdf`** - + Sabina-Cristiana Necula, Florin Dumitriu, Valerica Greavu-Serban, + *"A Systematic Literature Review on Using Natural Language Processing in + Software Requirements Engineering"* (*Electronics* 2024, 13(11), 2055). + DOI: https://doi.org/10.3390/electronics13112055 + License: Creative Commons Attribution 4.0 International (CC BY 4.0), + https://creativecommons.org/licenses/by/4.0/ + Redistributed unmodified under CC BY 4.0. Commercial reuse is permitted with + attribution; no GPL/AGPL obligations apply. + ## LLM cost, routing, and load balancing Background for routing batch-tolerant embedding work through diff --git a/docs/papers/nlp-in-software-requirements-engineering-slr.pdf b/docs/papers/nlp-in-software-requirements-engineering-slr.pdf new file mode 100644 index 000000000..27faea642 Binary files /dev/null and b/docs/papers/nlp-in-software-requirements-engineering-slr.pdf differ